foreach is one of the first things a C# programmer learns and one of the more elaborate things the compiler does. There is no foreach opcode, no loop instruction, nothing in the IL that says "this was a foreach." What there is depends entirely on what you iterated: iterate an interface and you get an enumerator and a hidden try/finally; iterate a List<T> and you get a struct enumerator and no boxing; iterate an array and you get a plain counted for loop with no enumerator at all. Three source loops that look identical compile to three different machines.
A decompiler sees only the machine. Its job is to recognize which of the three it is looking at and print the single foreach you actually wrote — and to know when it cannot be sure, and should show the machinery instead. This post reads the real IL for each case and shows how Glass.NET matches the shape back to a loop.
The general case: an enumerator and a finally you never wrote
Start with the textbook form — iterating an IEnumerable<T> you only know by its interface:
int Sum(IEnumerable<int> items)
{
int total = 0;
foreach (int x in items)
total += x;
return total;
}
You wrote a loop with a body. The compiler wrote considerably more. Conceptually it lowered the foreach into this:
int total = 0;
IEnumerator<int> e = items.GetEnumerator();
try
{
while (e.MoveNext())
{
int x = e.Current;
total += x;
}
}
finally
{
if (e != null) e.Dispose();
}
return total;
The try/finally and the Dispose are not in your source. They are the lowering of foreach's cleanup contract: the language guarantees that the enumerator is disposed when the loop ends for any reason — normal completion, a break, a return, or an exception thrown inside the body — and the only construct that delivers cleanup across all of those is a finally. So the compiler manufactures one. The IL makes the machinery explicit:
IL_0000: ldc.i4.0
IL_0001: stloc.0 // total = 0
IL_0002: ldarg.0
IL_0003: callvirt instance class IEnumerator`1<!0> IEnumerable`1<int32>::GetEnumerator()
IL_0008: stloc.1 // e = items.GetEnumerator()
.try
{
IL_0009: br.s IL_0018 // jump to the MoveNext test (test-at-bottom)
IL_000b: ldloc.1
IL_000c: callvirt instance !0 IEnumerator`1<int32>::get_Current()
IL_0011: stloc.2 // x = e.Current
IL_0012: ldloc.0
IL_0013: ldloc.2
IL_0014: add
IL_0015: stloc.0 // total += x
IL_0018: ldloc.1
IL_0019: callvirt instance bool IEnumerator::MoveNext()
IL_001e: brtrue.s IL_000b // loop while MoveNext() is true
IL_0020: leave.s IL_0032 // normal exit runs the finally
}
finally
{
IL_0022: ldloc.1
IL_0023: brfalse.s IL_0031 // if (e == null) skip
IL_0025: ldloc.1
IL_0026: callvirt instance void IDisposable::Dispose()
IL_0031: endfinally
}
IL_0032: ldloc.0
IL_0033: ret
Every element of the foreach fingerprint is visible here, and these are the features a decompiler keys on: GetEnumerator() feeding a local; a loop whose condition is a MoveNext() call; a body that begins by reading get_Current into the iteration variable; and a .try region whose matching finally does nothing but null-check and Dispose() the same enumerator local. When those four things line up on one local, the structure is a foreach with high confidence.
A decompiler that did not recognize the pattern would print exactly the lowered code above — correct, compilable, and far noisier than the one-line loop you wrote. The recovery is what turns a faithful-but-ugly decompile into a readable one.
The struct-enumerator case: same shape, no boxing
Now iterate a concrete List<int> instead of the interface:
int Sum(List<int> items)
{
int total = 0;
foreach (int x in items)
total += x;
return total;
}
The source is identical. The IL is not. List<T>.GetEnumerator() does not return IEnumerator<T> — it returns a struct, List<T>.Enumerator, and foreach is duck-typed, so the compiler binds to that concrete method before it ever looks at the interface. The enumerator local is now a value type, and the MoveNext/get_Current/Dispose calls are made on it directly:
IL_0003: callvirt instance valuetype List`1/Enumerator<!0> List`1<int32>::GetEnumerator()
IL_0008: stloc.1 // e is a List<int>.Enumerator VALUE, not an interface
...
IL_0018: ldloca.s e // address of the struct — no boxing
IL_001a: call instance bool List`1/Enumerator<int32>::MoveNext()
...
finally
{
IL_0025: ldloca.s e
IL_0027: constrained. List`1/Enumerator<int32>
IL_002d: callvirt instance void IDisposable::Dispose()
}
Two things changed and both matter to a decompiler. First, the calls are on ldloca.s e — the address of a value-type local — using call and constrained. callvirt, with no allocation and no boxing. That is the deliberate performance design behind List<T>: the common loop allocates nothing. A decompiler must preserve that it is a value-type enumerator rather than inventing an IEnumerator<int> that is not there. Second, the finally is still present here because List<T>.Enumerator implements IDisposable — but when the struct enumerator is known not to need disposal, the compiler omits the try/finally entirely, and the loop is just the MoveNext/Current pair with no cleanup region. A decompiler has to treat the finally as optional: its absence does not mean "not a foreach," it means "an enumerator that needed no disposing."
The array case: no enumerator at all
The third shape is the one that surprises people, because the enumerator vanishes completely. Iterate an array:
int Sum(int[] items)
{
int total = 0;
foreach (int x in items)
total += x;
return total;
}
The compiler knows the operand is an array, and arrays have a special, cheaper iteration: it does not call GetEnumerator at all. It emits a plain index-counted for loop — a length snapshot, an index variable, a bounds-limited condition, and ldelem to read each element:
IL_0002: ldarg.0
IL_0003: stloc.1 // array copy (the thing iterated)
IL_0004: ldc.i4.0
IL_0005: stloc.2 // i = 0
IL_0006: br.s IL_0016
IL_0008: ldloc.1
IL_0009: ldloc.2
IL_000a: ldelem.i4 // x = items[i]
IL_000b: stloc.3
... // total += x
IL_0012: ldloc.2
IL_0013: ldc.i4.1
IL_0014: add
IL_0015: stloc.2 // i++
IL_0016: ldloc.2
IL_0017: ldloc.1
IL_0018: ldlen
IL_0019: conv.i4
IL_001a: blt.s IL_0008 // while (i < items.Length)
This IL is indistinguishable from a hand-written for (int i = 0; i < items.Length; i++) that reads items[i]. There is genuinely no metadata that records whether you wrote foreach or for; the compiler lowered both to the same instructions. So a decompiler faces a real choice, and different decompilers answer it differently. The heuristic Glass.NET applies: if the index variable is used only to index the array and to increment — never read for its own value inside the body — and the bound is exactly the array's Length, the loop was almost certainly a foreach, and rendering it as one is both more faithful to intent and more readable. If the index is used in the body (say, items[i] + i), it must have been a for, because foreach gives you no index. The same logic extends to Span<T> and ReadOnlySpan<T>, which the compiler also lowers to an index loop over the span's Length.
How Glass.NET decides
Putting the three shapes together, foreach recovery is pattern matching with a confidence threshold rather than a guaranteed inverse. Glass.NET runs the general control-flow structuring first — basic blocks, the loop, the try/finally regions — and then asks whether the loop matches one of the foreach fingerprints:
-
Enumerator form. Is there a
GetEnumerator()call whose result feeds a local that is used only byMoveNext,get_Currentand (if present)Dispose? Is the loop condition thatMoveNext, and does the body open by readingget_Currentinto the iteration variable? Is any surroundingfinallydoing nothing but disposing that enumerator? If so, fold all of it intoforeachand drop the synthetic local, thetry/finallyand theDispose. -
Array/span form. Is the loop a counted index from 0 to
Lengthwhere the index is used only to index the collection and advance? If so, preferforeach; if the index escapes into the body, keep it afor.
When every piece lines up, the output is the one-line loop you wrote. When a piece is missing — the enumerator local is reused for something else, the finally does more than dispose, the index is read in the body — Glass.NET deliberately declines to fold and shows the literal while with its try/finally, or the explicit for. That is the right failure mode for a decompiler: a slightly noisier loop that is unquestionably correct beats a pretty foreach that quietly misrepresents what the code does.
The takeaway
foreach is a good lens on what decompilation actually is. The compiler took one construct and lowered it three different ways depending on the operand, erasing the fact that it was ever a foreach; the try/finally and the Dispose the loop appears to contain were never in your source, and over an array the enumerator the loop appears to use never existed. Reading the code back means recognizing each machine by its shape and having the discipline to fold it only when the shape is unambiguous — printing the loop you wrote when the evidence supports it, and the machinery when it does not.
Top comments (0)