Skip to content
← All posts
· Delta1 Labs Glass.NETDecompilation.NETDeep Dive

Decompiling foreach: the enumerator, the hidden try/finally, and when it is really a for loop

There is no foreach in IL. The C# compiler lowers it to one of three completely different shapes depending on what you iterate — a call to GetEnumerator with a while loop and a hidden try/finally that disposes the enumerator, a struct-enumerator version with no dispose at all, or, over an array or span, a plain index-counted for loop with no enumerator in sight. A decompiler sees only the lowered code and has to recognize which pattern it is looking at to print foreach back instead of the machinery. This post reads the real IL for each case, shows why the Dispose lives in a finally the source never wrote, why the struct enumerator skips it, and how Glass.NET matches the shape — the GetEnumerator/MoveNext/get_Current trio, the try/finally, the IDisposable check — to recover the loop you actually wrote.

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.

The foreach fingerprint a decompiler matchese = items.GetEnumerator(); // (1) enumerator local.trywhile ( e.MoveNext() ) // (2) condition is MoveNext{x = e.Current; // (3) body opens with get_Current total += x; }finallyif (e != null) e.Dispose(); // (4) finally only disposes eAll four on the same local e → fold into foreach. Any mismatch → show the while + try/finally literally.

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 by MoveNext, get_Current and (if present) Dispose? Is the loop condition that MoveNext, and does the body open by reading get_Current into the iteration variable? Is any surrounding finally doing nothing but disposing that enumerator? If so, fold all of it into foreach and drop the synthetic local, the try/finally and the Dispose.
  • Array/span form. Is the loop a counted index from 0 to Length where the index is used only to index the collection and advance? If so, prefer foreach; if the index escapes into the body, keep it a for.

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.

Try Nebula.NET

Harden your .NET code in minutes — start with the free edition.