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

Decompiling yield return: how iterator blocks become state machines

An iterator method with yield return has no direct IL representation — the compiler rewrites it into a generated class that implements IEnumerator and resumes inside a MoveNext switch. Here is the shape of that state machine, how a decompiler recognizes it, and how yield return and yield break are recovered from the generated code.

Some C# constructs compile to a single IL instruction. yield return compiles to an entire class. There is no “yield” opcode in the CLR, so when you write an iterator method, the compiler rewrites the whole thing into a generated type that implements IEnumerator<T> and remembers where it was between calls. Understanding that rewrite explains why a decompiler sometimes shows you a cryptic <GetItems>d__0 with a switch in it — and how a good one turns that back into the yield return you actually wrote.

The method you write vs. the method that ships

Here is an ordinary iterator:

public IEnumerable<int> Evens(int max)
{
    for (int i = 0; i <= max; i++)
        if (i % 2 == 0)
            yield return i;
}

What ships in the assembly is almost none of that. The method Evens survives only as a stub that news up a generated class and returns it. The real work moves into a compiler-generated nested type — conventionally named <Evens>d__0 — that implements IEnumerable<int>, IEnumerator<int>, and IDisposable. The decompiled skeleton looks like this:

[CompilerGenerated]
private sealed class <Evens>d__0 : IEnumerable<int>, IEnumerator<int>, IDisposable
{
    private int <>1__state;    // where we are in the method
    private int <>2__current;  // the value the last yield produced
    public int max;            // the parameter, hoisted to a field
    private int <i>5__1;       // the local 'i', hoisted to a field

    int IEnumerator<int>.Current => <>2__current;
    bool IEnumerator.MoveNext() { /* the rewritten body — see below */ }
    // ... Reset/Dispose/GetEnumerator ...
}

Two kinds of fields appear. <>1__state and <>2__current are the machinery: the state field records where to resume, and the current field holds the value Current returns. The others — max and <i>5__1 — are your parameter and your local i, hoisted from the stack onto the heap. They have to be fields, because a local on the stack would vanish the moment MoveNext returned, and the iterator needs i to still be there on the next call.

MoveNext: a switch that resumes

The body of your iterator is rewritten into MoveNext, structured as a dispatch on <>1__state. Each yield return becomes three steps — stash the value, record a resume state, return true — and the matching case is where the next call jumps back in:

bool IEnumerator.MoveNext()
{
    switch (<>1__state)
    {
        case 0:                         // first call: start the method
            <>1__state = -1;
            <i>5__1 = 0;                // i = 0
            break;
        case 1:                         // resume point after the yield
            <>1__state = -1;
            <i>5__1++;                  // the i++ from the for-loop
            break;
        default:
            return false;
    }

    while (<i>5__1 <= max)
    {
        if (<i>5__1 % 2 == 0)
        {
            <>2__current = <i>5__1;     // yield return i  →  stash value
            <>1__state = 1;             // remember where to resume
            return true;                //   and hand control back
        }
        <i>5__1++;
    }
    return false;                        // falling off the end = yield break
}

Read it as a resumable program. State 0 is the entry; the method runs until the first yield return, which sets <>2__current, sets the state to 1, and returns true. The foreach consuming this iterator calls MoveNext again, the switch sees state 1 and jumps back after the yield to continue the loop. When the loop finally ends, MoveNext returns false, which is exactly what yield break (and falling off the end) compiles to — the signal to foreach that enumeration is over.

state 0enteryield return icurrent=i; state=1return true →state 1resume after yieldloop: next MoveNext()return falseyield break / end

How the decompiler puts yield back

A decompiler reading the shipped assembly sees the generated class, not your method. Recovering the iterator is pattern recognition on a well-defined shape:

  1. Identify the state machine. A nested [CompilerGenerated] type implementing IEnumerator<T> with a <>1__state and a <>2__current field is the signature of an iterator block — distinct from an async state machine, which implements IAsyncStateMachine and is driven by awaiters rather than MoveNext.
  2. Un-hoist the locals. Fields like <i>5__1 are mapped back to a local i, and fields matching the original parameters (max) are recognized as parameters of the reconstructed method. The <name>5__n encoding makes the original identifier recoverable.
  3. Fold the switch into control flow. Each case in the MoveNext dispatch is a resume point that corresponds to a yield return site. The decompiler stitches the pre-yield code and post-resume code back into one linear body, turning <>2__current = x; <>1__state = n; return true; back into yield return x; and return false; at the end into the implicit yield break.

The result is your Evens method again — a for loop with a yield return i inside — rather than a 60-line state machine. Glass.NET does this reconstruction by default and lets you drop to the raw <Evens>d__0 view when you want to see the actual generated machinery, which is exactly where you look when an iterator misbehaves.

Why it is worth knowing

The reconstructed yield return is what you want almost always, but the state machine underneath explains behaviour the high-level view hides. Deferred execution becomes obvious: the stub method only constructs the class, so no code in the iterator body runs until the first MoveNext — which is why an exception thrown “in” an iterator doesn’t surface until you start enumerating. The per-enumerator state is visible as instance fields, explaining why two foreach loops over the same iterator each get their own independent walk. And when you are reading someone else’s compiled library — or your own without symbols — recognizing <...>d__ with a state/current pair tells you instantly that you are looking at an iterator, and the case labels in MoveNext map one-to-one to the yield points in the original. A decompiler that knows the pattern gives you the readable source; knowing the pattern yourself lets you read the machine when it matters.

Try Nebula.NET

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