yield return is the quiet sibling of async/await. Both compile to a state machine; both replace the method you wrote with a generated class and an integer-driven MoveNext. But iterators came first — they shipped in C# 2.0 — and the machine the compiler builds for them has its own distinctive shape. A decompiler that hands you that raw machine gives you a class implementing IEnumerable<T> and IEnumerator<T> at once, a <>1__state field, a <>2__current field, and a MoveNext full of gotos. What you wrote was four lines with two yield returns. Closing that gap is iterator reconstruction, and it is a great way to understand exactly what the C# compiler is doing on your behalf.
What yield actually compiles to
Consider a method that cannot get much simpler:
public IEnumerable<int> Evens(int limit)
{
for (int i = 0; i < limit; i += 2)
yield return i;
}
There is no loop in the compiled Evens. The method body is replaced by a one-liner that constructs a generated class and returns it:
public IEnumerable<int> Evens(int limit)
{
var d = new <Evens>d__0(-2); // -2 = "fresh, not yet enumerated"
d.<>3__limit = limit; // parameters are copied into fields
return d;
}
All the logic moves into that <Evens>d__0 class, which implements both IEnumerable<int> and IEnumerator<int>. The loop variable i becomes a field so it survives between calls to MoveNext, and the for loop is turned inside-out into a state machine driven by <>1__state:
bool IEnumerator.MoveNext()
{
switch (<>1__state)
{
case 0:
<>1__state = -1;
<i>5__1 = 0; // i = 0
goto check;
case 1:
<>1__state = -1;
<i>5__1 += 2; // i += 2 (resume point)
goto check;
check:
if (<i>5__1 < <>3__limit)
{
<>2__current = <i>5__1; // the value of `yield return i`
<>1__state = 1; // remember where to resume
return true; // hand control back to the caller
}
return false; // enumeration finished
}
return false;
}
public int Current => <>2__current;
The two things a reader needs — "it yields i" and "then it continues the loop" — are split across a switch, two gotos, and a field assignment. That is faithful to the IL and almost unreadable, which is the problem.
How Glass.NET puts it back together
As with async methods, Glass does not trust names — an obfuscator renames <Evens>d__0, <>1__state and <i>5__1 to noise, and the <> convention is not guaranteed. It matches on the contract and shape that the runtime requires and the compiler cannot avoid emitting:
- A generated class that implements
IEnumerator<T>(and usuallyIEnumerable<T>as well, so the result can be re-enumerated), with anintstate field and aCurrent-backing field. - A source method whose entire body is "construct this class, copy the parameters into its fields, return it."
- A
MoveNextwhose returns arereturn true(a value is ready, readCurrent) andreturn false(done) — the tell that distinguishes an iterator from an asyncMoveNext, which drives a builder and never returns a bool.
Reconstruction then runs in four moves:
-
Map the states to resume points. Each
casein theswitchis a pointMoveNextcan re-enter. Glass builds the state → offset map, exactly as it does for async, so it knows where each resumption lands inside the original control flow. -
Recognise the yield pattern. The sequence assign
<>2__current, set<>1__state = N,return trueis the fingerprint of oneyield return. Glass collapses the three steps back intoyield return <the value assigned to current>. A barereturn falsewith the terminal state becomesyield breakor the natural end of the method. -
De-hoist the fields.
<i>5__1is the loop variableilifted to a field so it survives suspension. Glass lifts it back to a local.<>3__limitis the captured parameterlimit; it maps back to the parameter name. -
Re-roll the control flow. With yields folded and states understood, the inside-out
switch/gotomachine is matched against structured-control-flow templates and re-rolled into the originalforloop.
Telling a yield apart from an await — and the lazy-execution trap
The most common mistake a weak decompiler makes here is confusing the two state machines. They look alike, but three signals keep them apart, and Glass checks all three: iterators implement IEnumerator<T> and their MoveNext returns bool; async methods implement IAsyncStateMachine and drive an AsyncTaskMethodBuilder that has no bool return. Get this wrong and you print an await where a yield belongs, which is not just ugly — it is semantically false.
There is a subtler trap that matters when you are reading someone else's binary to understand its behaviour: iterators are lazy. The body of Evens does not run when you call it — it runs as you enumerate. That is why the parameter copy happens in the stub and the real work happens in MoveNext. A decompiler that silently inlined the machine back into an eager method would hide a real bug class: an argument validated inside an iterator block is not checked until the first MoveNext, so the exception surfaces at the foreach, not at the call. Glass preserves the iterator structure precisely so that this laziness stays visible in the reconstructed source — because if you are reading the code to find out why a guard clause "isn't firing," that deferral is the answer.
What obfuscation does and doesn't do to this
The same boundary applies as with async, and it is worth stating plainly rather than overselling the tool:
-
Renaming changes nothing.
IEnumerator<T>is a contract the runtime enforces; the bool-returningMoveNextand theCurrentproperty cannot be renamed away. Glass reconstructs a renamed iterator exactly as well as a named one — the identifiers it prints are synthesised, the control flow is real. -
Control-flow flattening reaches inside
MoveNext. A tool like Nebula.NET can flatten the iterator's ownMoveNext, dissolving its tidyswitchinto a dispatcher with opaque predicates. Glass still produces correct, compilable code, but the cleanfor-loop re-rolling degrades toward literal gotos the more the method was flattened. That is the honest trade: decompilation always recovers behaviour; it recovers the original shape only as far as obfuscation left it intact.
The practical upshot is the mirror image of the obfuscation story: if you ship un-obfuscated iterators, assume they decompile back to near-original source — a state machine is a compiler convenience, not protection. If the logic inside an iterator is worth protecting, it needs a real obfuscation pass on top, and even then the honest goal is cost, not impossibility.
Glass.NET is a free, VS-native .NET decompiler that reconstructs iterators, async/await, and yield state machines back to readable C#. Download Glass.NET and open your first assembly.
Top comments (0)