← Open in the full interactive course (progress tracking, search & more)

Every yield return method you've ever written became an entire hidden class. This lesson opens that class up and reads it line by line.

You've used IEnumerable<T> since Foundations lesson 038. You've written yield return dozens of times since — in custom collections, in custom LINQ extensions, in the Where and Select reimplementations from Intermediate Part IV. Every one of those lessons told you the same thing in passing: "the compiler turns this into a state machine." None of them showed you that state machine's actual shape. This lesson does exactly that — no more taking it on faith.

You'll see the literal interface members behind foreach, the exact statement-by-statement translation the compiler performs on a foreach loop, and — the centerpiece — a full, field-by-field walkthrough of the hidden class the compiler generates for a yield return method: its numeric state field, its captured-variable fields, and exactly how MoveNext() resumes execution from precisely where the last call left off. By the end, "Where is lazy because it's built with yield return" stops being a fact you were told and becomes a mechanism you can trace yourself.

What Is It?

The Simple Explanation

IEnumerable<T> and IEnumerator<T> are two small, separate contracts that together define "something that can be walked through, one item at a time." You've used both since Foundations. What this lesson adds is the machinery underneath: what the compiler actually builds when you write yield return instead of hand-writing an enumerator class yourself, and exactly why that machinery is what makes an entire LINQ operator like Where do nothing at all until something starts pulling items through it.

The Technical Definition — the Exact Interface Shapes

Both interfaces, in full, exactly as the BCL declares them:

public interface IEnumerable<out T> : IEnumerable { IEnumerator<T> GetEnumerator(); } public interface IEnumerator<out T> : IEnumerator, IDisposable { T Current { get; } // Inherited from the non-generic IEnumerator: // bool MoveNext(); // void Reset(); // object Current { get; } (the non-generic version) }

IEnumerator<T> additionally implements IDisposable — this is why the compiler-generated foreach translation you'll see in a moment always wraps the loop in something that guarantees Dispose() runs, even when the loop exits early via break, return, or an exception. An iterator that holds an open file handle, a database connection, or any other unmanaged resource relies on that guarantee to clean up correctly no matter how the loop ends.

Why Reset() Is Effectively Dead Code

Reset() exists for historical reasons — it predates generics, from the days of COM-style enumerators used by early .NET collection classes. In practice, almost no modern IEnumerator<T> implementation does anything meaningful with it. Every compiler-generated yield return state machine — which is the overwhelming majority of enumerators you'll ever touch in modern C# — implements Reset() by simply throwing NotSupportedException. If you need to "start over," the real, idiomatic answer is to call GetEnumerator() again and get a brand-new enumerator — never to call Reset() on an existing one.

Why Does It Exist?

The Problem — Hand-Writing Enumerators Is Tedious and Error-Prone

Foundations lesson 038 showed you what a hand-written IEnumerator<T> looks like: a private class, a position field, a MoveNext() that increments it and bounds-checks, a Current that indexes into the source. For a flat array, that's manageable. For logic with branches, loops, or multiple exit conditions — imagine hand-writing the enumerator for something like "every third even number, skipping the first five, until a total exceeds 100" — the position bookkeeping alone becomes its own small, fragile state machine, entirely separate from the logic it's supposed to express.

The Solution — Let the Compiler Write the State Machine

yield return lets you write the iteration logic as ordinary-looking, straight-line code with loops and conditionals — the ones from the paragraph above — and the C# compiler mechanically rewrites the entire method body into a class that implements IEnumerable<T> and IEnumerator<T>, correctly, every time. You never see that class in your source code. It exists only in the compiled assembly. This lesson's whole purpose is showing you what it actually contains.

Big Picture

TWO SEPARATE COMPILER TRANSLATIONS, CHAINED TOGETHER
You write two ordinary-looking pieces of code:
IEnumerable<int> EvensBelow(int max)
{
    for (int i = 0; i < max; i++)
        if (i % 2 == 0)
            yield return i;
}

foreach (int n in EvensBelow(10))
    Console.WriteLine(n);
▼ TWO compiler translations happen, independently ▼
Translation A — the METHOD BODY
EvensBelow's body becomes a hidden class implementing IEnumerable<int> / IEnumerator<int> — a state machine.
Translation B — the foreach LOOP
The foreach becomes a GetEnumerator() call plus a try/finally-wrapped while (MoveNext()) loop.
Neither translation knows about the other. They meet purely through the shared IEnumerable<T>/IEnumerator<T> contract — which is exactly why any hand-written enumerator (Foundations 038) plugs into foreach just as seamlessly as a compiler-generated one.

How It Works

Part 1 — Exactly How foreach Desugars

This is Translation B from above, shown in full. It's the same shape you saw a first look at in Foundations 038, repeated here precisely because everything else in this lesson depends on it:

// You write: foreach (var item in source) { DoSomething(item); } // The compiler generates (roughly): { IEnumerator<T> e = source.GetEnumerator(); try { while (e.MoveNext()) { T item = e.Current; DoSomething(item); } } finally { e.Dispose(); // ALWAYS runs — even on break, return, or an exception inside the loop } }

The try/finally is the detail worth pausing on. It means Dispose() is called on the enumerator no matter how the loop body exits — a break midway through, a return from inside the loop, or an exception thrown by DoSomething. This matters more than it looks: as you'll see below, a yield return state machine's Dispose() is what runs any finally blocks that exist inside the iterator method itself — so a resource opened inside an iterator (a file, a connection) is guaranteed to close correctly even if the caller abandons the loop halfway through.

Part 2 — the Real Meat: What yield return Compiles Into

Now Translation A. Take a small iterator method with one captured parameter and one local variable used across iterations:

public IEnumerable<int> CountUpBy(int start, int step) { int current = start; while (current < 20) { yield return current; current += step; } }

The compiler does not compile this as an ordinary method at all. The moment it sees yield return in a method body, it switches to generating an entire hidden class — conventionally named something like <CountUpBy>d__0 — that implements both IEnumerable<int> and IEnumerator<int> on the same type. Four pieces make it work:

THE FOUR PIECES OF A yield return STATE MACHINE
1. A NUMERIC STATE FIELD
2. FIELDS FOR EVERY PARAMETER AND EVERY LOCAL THAT MUST SURVIVE BETWEEN YIELDS
3. A FIELD FOR THE CURRENT YIELDED VALUE
4. A MoveNext() METHOD CONTAINING YOUR ENTIRE METHOD BODY, REWRITTEN

Simple Example

Here is the hidden class the compiler generates for CountUpBy above, written out by hand — simplified for readability, but faithful to the real shape and the real fields you'd see decompiling the actual IL:

// Conceptually equivalent to what the compiler generates for CountUpBy(start, step): private sealed class <CountUpBy>d__0 : IEnumerable<int>, IEnumerator<int> { private int <>1__state; // WHERE execution paused private int <>2__current; // the value the last yield return produced public int start; // captured parameter → field public int step; // captured parameter → field private int <current>5__2; // the LOCAL "current" from your method → field public int Current => <>2__current; public bool MoveNext() { switch (<>1__state) { case 0: goto StateZero; // fresh enumerator, never started case 1: goto StateOne; // resuming after a previous yield return default: return false; // already finished } StateZero: <>1__state = -1; <current>5__2 = start; // int current = start; WhileCheck: if (!(<current>5__2 < 20)) goto Done; <>2__current = <current>5__2; // about to yield return current; <>1__state = 1; // remember: "paused right after a yield" return true; // ← THIS is what yield return actually compiles to StateOne: <>1__state = -1; <current>5__2 += step; // current += step; (resumes HERE next MoveNext() call) goto WhileCheck; Done: return false; } public void Dispose() { /* runs any pending finally blocks from your method */ } public void Reset() => throw new NotSupportedException(); // IEnumerable<int>.GetEnumerator() — usually just returns "this" the first time it's called public IEnumerator<int> GetEnumerator() => this; } // And your original method becomes essentially: public IEnumerable<int> CountUpBy(int start, int step) { var machine = new <CountUpBy>d__0(); machine.start = start; machine.step = step; return machine; }

Code → Meaning → Result: Calling CountUpBy(2, 3) does not run any of your loop logic — it only constructs the state-machine object and stashes the two parameters into fields. The first real work happens on the first MoveNext() call, which runs from the top of the method down to the first yield return, records "state 1" and the yielded value, and returns true. The next MoveNext() call doesn't restart the method — it jumps straight past the while check to the line right after the yield return (current += step;), because the state field told it exactly where to resume. This is the entire trick: pausing and resuming a method isn't magic — it's a saved integer, a switch, and fields standing in for what would otherwise be stack-local variables.

Real-World Example

You've already met the real-world payoff of this mechanism, in Intermediate lesson 111 (Filtering), without seeing the full machinery behind it. Enumerable.Where is, at its core, exactly this pattern:

// Conceptually how System.Linq.Enumerable.Where is implemented: public static IEnumerable<T> Where<T>(this IEnumerable<T> source, Func<T, bool> predicate) { ArgumentNullException.ThrowIfNull(source); ArgumentNullException.ThrowIfNull(predicate); return WhereIterator(source, predicate); // validation is eager; iteration is not — see lesson 121 } private static IEnumerable<T> WhereIterator<T>(IEnumerable<T> source, Func<T, bool> predicate) { foreach (T item in source) { if (predicate(item)) yield return item; } }

Now you can trace, field by field, exactly why products.Where(p => p.Stock > 0) does nothing the instant it's called. That call constructs a state-machine object — call it <WhereIterator>d__1 — with fields for the captured source and predicate, and a state field sitting at its initial value. No item of products has been touched. Your p => p.Stock > 0 delegate has not been invoked even once. Only when something calls MoveNext() — a foreach, a ToList(), an Any() — does the generated code actually start pulling items from source and testing them against predicate, one at a time, pausing with a return true after every match. This is, precisely and completely, why deferred execution (the subject of lesson 199 next) is a fact about LINQ rather than a design choice someone bolted on separately — it falls straight out of what yield return compiles to.

Analogy

A Bookmark That Remembers More Than the Page Number

Think of an ordinary bookmark: it remembers a page number, nothing more. A yield return state machine is a bookmark that also remembers every variable that was "in your head" while reading — which loop you were in, what you'd counted so far, what you were about to do next. Close the book (return from MoveNext()), and when you open it again (call MoveNext() a second time), you don't just find the right page — you pick up mid-thought, exactly where you left off, with every value you were tracking still intact. That's what the state field plus the captured-variable fields together provide: not just "where," but the entire mental state needed to resume correctly.

Under the Hood

DETAILS THAT MATTER ONCE YOU'RE WRITING REAL ITERATORS
1. THE GENERATED CLASS IS ALSO ITS OWN ENUMERATOR — WITH A ONE-TIME TWIST
2. try/finally INSIDE AN ITERATOR BECOMES PART OF Dispose()
3. A yield return METHOD'S CODE DOESN'T RUN UNTIL MoveNext() — NOT EVEN VALIDATION
4. STRUCT-BASED ENUMERATORS EXIST TOO — AND ARE A DIFFERENT OPTIMIZATION ENTIRELY

Common Confusion

1. "The state machine re-runs the whole method on every MoveNext() call" — no, it resumes

This is the single most common misreading of how iterators work. MoveNext() does not restart your method from the top each time — the switch on the state field, shown explicitly in the Simple Example above, jumps directly to the exact statement after the last yield return. Code before that point in the method genuinely does not run again. This is precisely why an iterator can have expensive setup logic once, at the top, that only executes on the very first MoveNext() call — a real, commonly used pattern.

2. "Captured fields" here are not the same mechanism as lambda closures from lesson 189

Both mechanisms solve the same underlying problem — a local variable needs to outlive the method call where it was declared — and both solve it by promoting the variable to a field on a heap-allocated object. But they're genuinely separate compiler features with separate generated types: lambda closures produce a "display class" (lesson 189) wrapping captured variables for a delegate; iterator methods produce this entirely different state-machine class, with its own state field and its own fields, purely for yield return. It's easy to conflate them because the underlying trick — "put the variable on the heap instead of the stack" — is philosophically identical, but seeing one doesn't mean you're looking at the other.

Common Mistakes

Mistake 1 — Assuming a captured mutable field is snapshotted at the moment the iterator is created

Passing a mutable object into an iterator method and assuming its state "at creation time" is what the iterator will see:

IEnumerable<int> ReadValues(List<int> source) { foreach (int v in source) // "source" is a FIELD on the state machine — read live, every MoveNext() yield return v; } var list = new List<int> { 1, 2, 3 }; var iterator = ReadValues(list); // nothing has run yet — the field just holds a reference to 'list' list.Add(4); // mutated BEFORE enumeration starts foreach (var v in iterator) Console.WriteLine(v); // prints 1, 2, 3, 4 — sees the mutation

The captured field holds a reference, read fresh on every access inside MoveNext() — just like any other field. If a stable snapshot at creation time is what's needed, copy the data explicitly (e.g. source.ToList()) before passing it in.

Mistake 2 — Calling Reset() expecting it to rewind a compiler-generated enumerator

enumerator.Reset(); on a state machine produced from a yield return method — this throws NotSupportedException at runtime, as covered in "What Is It?" above. Call GetEnumerator() again to get a fresh, independent enumerator instead — that's the only supported way to "start over" with modern iterators.

Mistake 3 — Forgetting that state-machine construction is not free, even before the first MoveNext()

Assuming calling an iterator method "does nothing at all" — it does construct an object and copy every captured parameter/local into a field, which is a real (if small) heap allocation, before any of your logic runs. In a hot loop that calls an iterator method millions of times but only ever needs the first item, this per-call allocation is a real, measurable cost — one of the specific tradeoffs revisited in lesson 200's performance discussion. Recognize that "deferred" means "the body hasn't run yet," not "nothing has happened yet" — the object itself already exists.

When Should I Use It?

You've been using yield return since Intermediate Part IV — this lesson isn't teaching you a new tool, it's giving you the mental model to reason correctly about the tool you already reach for.

This mental model matters most when

You still don't need to hand-write a state machine

Mental Model

Calling an iterator method = "allocate the bookmark object; don't open the book yet"
MoveNext() = "open to exactly where the bookmark is, read until the next yield return, place the bookmark there, close the book"
The state field = the bookmark's page number
The captured-variable fields = everything you were "holding in your head" while reading, preserved across each pause

Remember:
· foreach desugars to GetEnumerator() + a try/finally-wrapped while (MoveNext()) loop — always, guaranteed disposal.
· yield return compiles your method body into a class holding a state field, one field per surviving local/parameter, and a rewritten MoveNext().
· Reset() is dead weight on virtually every modern enumerator — call GetEnumerator() again instead.
· Where, Select, and nearly every LINQ-to-Objects operator are exactly this pattern — which is precisely why they're lazy.

Key Takeaway


Check Your Understanding

You've opened up the hidden class behind yield return and traced exactly how it resumes. Let's check your understanding.

1. What does the compiler-generated numeric "state field" inside a yield return state machine actually track?

Show answer

Correct: B

Why B is correct: As shown in the Simple Example's hand-written equivalent, <>1__state is checked by a switch at the top of MoveNext() to jump directly to the exact line after the last yield return — it's a resume point, not a counter.

Why A is incorrect: Nothing in the state machine inherently counts total items produced — the state field only ever encodes "where," never "how many."

Why C is incorrect: The current yielded value has its own separate field (<>2__current in the example), distinct from the state field.

Why D is incorrect: Disposal isn't tracked through the same state field in the way this answer implies — it's a separate concern handled by Dispose() and any finally blocks wired into it.

Reinforcement: The state field is a resume pointer — the entire "pause and resume" trick depends on it alone.

2. Why does a local variable used across multiple yield return statements need to become a field on the generated class, instead of staying an ordinary local variable?

Show answer

Correct: B

Why B is correct: As explained in How It Works (Part 2), each yield return compiles to a return true — a genuine method return. Anything that needs to persist across that return must live somewhere other than the stack frame that just unwound, which is exactly what promoting it to a field on the state-machine object accomplishes.

Why A is incorrect: Local variables are entirely legal inside iterator methods — the compiler simply relocates the ones that must survive across yields.

Why C is incorrect: Speed isn't the motivation at all — it's a hard correctness requirement, not a performance choice.

Why D is incorrect: This is a functional necessity, not a stylistic one — without it, resuming a paused iterator correctly would be impossible.

Reinforcement: Fields on the state machine exist specifically to survive what would otherwise be a method return between every single yield return.

3. A second MoveNext() call is made on a yield return-generated enumerator. What actually happens?

Show answer

Correct: B

Why B is correct: As the Simple Example's CountUpBy walkthrough showed explicitly, the second call's switch (<>1__state) jumps straight to StateOne — past the while check, directly to current += step; — never re-executing the initialization that ran on the first call.

Why A is incorrect: This is exactly the Common Confusion misconception this lesson calls out directly — the method does not restart; it resumes.

Why C is incorrect: Calling MoveNext() repeatedly is the entire normal, expected usage pattern — foreach itself does exactly this in a loop; it's Reset() that throws, not repeated MoveNext() calls.

Why D is incorrect: Resumption happens at the exact statement after the last yield return, which may be well past any loop condition check, not limited to re-checking the condition alone.

Reinforcement: "Resume from where you paused," not "start over" — that's the single idea the whole state machine exists to implement.

4. Why is products.Where(p => p.Stock > 0) guaranteed to invoke the predicate zero times the instant it's called, before any enumeration begins?

Show answer

Correct: B

Why B is correct: As the Real-World Example traced directly, WhereIterator's body — the foreach that actually calls predicate(item) — becomes MoveNext(). Calling Where only builds the object holding source and predicate as fields; none of that body has executed yet.

Why A is incorrect: No such emptiness check happens at call time — the deferral has nothing to do with the contents of products at all.

Why C is incorrect: There's no compiler-level caching of LINQ call results — each call to Where simply builds a fresh state machine, as shown throughout this lesson.

Why D is incorrect: Delegates themselves aren't inherently lazy — invoking a Func<T,bool> directly runs it immediately. The laziness here comes specifically from Where's yield return-based implementation, not from any property of delegates in general.

Reinforcement: The predicate is just a field on the state machine until MoveNext() actually reaches the line that calls it.

5. A yield return iterator method contains a try/finally block that closes a file handle. A caller's foreach loop over this iterator exits early via break. Does the file handle still get closed?

Show answer

Correct: B

Why B is correct: As both Part 1 (the foreach desugaring's try/finally) and Under the Hood point 2 explained, the two mechanisms compose deliberately: foreach always disposes the enumerator on exit, and the compiler routes any finally block from inside your iterator body into that same generated Dispose() — so cleanup runs even on early exit.

Why A is incorrect: This is exactly the guarantee the try/finally-wrapped foreach translation exists to prevent — cleanup is never silently skipped on early exit.

Why C is incorrect: The compiler-generated foreach code calls Dispose() automatically — no manual intervention from the caller is required or expected.

Why D is incorrect: The guarantee doesn't depend on timing relative to any particular yield return — it depends only on the resource being opened inside a try whose matching finally does the closing.

Reinforcement: IEnumerator<T> extending IDisposable, plus foreach's guaranteed try/finally, is precisely what makes resource cleanup inside an iterator reliable even under early termination.

You've opened the hood on IEnumerable<T> itself. Next: the interface that looks identical at the call site but works nothing like this underneath — IQueryable<T>.


dotnetmadeeasy.com — Learn C# and .NET, the right way.