傳統認知認為 foreach 編譯出的 IL 比 for 更優化。通過手寫 Emit 實現兩種循環求和,並對比反編譯 IL,發現 foreach 實際生成更多指令——原來優化點在於語義安全而非指令數量。本文完整還原兩種循環的 IL 邏輯並解析差異根因。
public int ForMethod(int[] ints)
{
int sum = 0;
for (int i = 0; i < ints.Length; i++)
sum += ints[i];
return sum;
}
public int ForeachMethod(int[] ints)
{
int sum = 0;
foreach (int i in ints)
sum += i;
return sum;
}
// 局部變量: sum(0), i(1)
L_0000: ldc.i4.0; stloc.0 // sum = 0
L_0002: ldc.i4.0; stloc.1 // i = 0
L_0004: br.s L_001E // 跳轉到條件檢查
L_0006: ldloc.0 // sum
L_0007: ldarg.0 // ints
L_0008: ldloc.1 // i
L_0009: ldelem.i4 // ints[i]
L_000A: add; stloc.0 // sum += ints[i]
L_000C: ldloc.1; ldc.i4.1; add; stloc.1 // i++
L_001E: ldloc.1 // i
L_001F: ldarg.0; ldlen; conv.i4 // ints.Length
L_0022: clt; brtrue.s L_0006 // i < len ? 進入循環體
L_0024: ldloc.0; ret // return sum
// 局部變量: sum(0), i(1), ints(2), index(3)
L_0000: ldc.i4.0; stloc.0 // sum = 0
L_0002: ldarg.0; stloc.2 // ints = 參數數組 (關鍵差異 1)
L_0004: ldc.i4.0; stloc.3 // index = 0
L_0006: br.s L_0020
L_0008: ldloc.2 // ints
L_0009: ldloc.3 // index
L_000A: ldelem.i4 // ints[index]
L_000B: stloc.1 // i = ints[index] (關鍵差異 2)
L_000C: ldloc.0; ldloc.1; add; stloc.0 // sum += i
L_0010: ldloc.3; ldc.i4.1; add; stloc.3 // index++
L_0020: ldloc.3 // index
L_0021: ldloc.2; ldlen; conv.i4 // ints.Length
L_0024: clt; brtrue.s L_0008
L_0026: ldloc.0; ret
| 維度 | For | Foreach |
|---|---|---|
| 數組訪問 | 直接 ldarg.0 使用參數 | 先 stloc 存局部變量,再 ldloc 讀取 |
| 元素獲取 | ldelem 直接加到 sum | 先 ldelem 存 i,再 ldloc i 加到 sum |
| 指令數量 | 較少 (~18 條) | 較多 (~22 條) |
| 邊界檢查 | 隱含在 ldelem | 同樣隱含 |
| 語義保證 | 無 | 數組引用固定,防止循環中數組被修改 |
foreach 遍歷的是迭代器快照,數組引用不可變。Emit 中體現為先將數組存入局部變量,避免循環體內修改參數引用導致邏輯錯誤。foreach 編譯為通用模式 (GetEnumerator/MoveNext/Current),對數組有特化優化,但仍保留統一語義結構。for 模式,指令更少、可讀性更強。IEnumerable,再實現枚舉器模式 (Callvirt MoveNext + Callvirt get_Current)。il.DefineLabel() 管理跳轉,避免硬編碼偏移量。結論:IL 指令數量不是性能唯一指標,foreach 的「多指令」換取的是語義正確性與泛化能力。