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

You already know a closure captures a variable by reference. Now let's open the compiler's output and see exactly what "capture" compiles down to — and why that's precisely why closures can quietly leak memory.

Lesson 102 established the rule you've relied on ever since: a lambda captures the variable itself, not a snapshot of its value, and that variable's lifetime stretches to match the lambda's. You've used that rule correctly a hundred times. But here's a question 102 never answered: where does that captured variable actually live? A local variable is supposed to die with its stack frame. If threshold in price => price > threshold is a completely ordinary local, how does it survive the method returning?

The honest answer is that it doesn't stay an ordinary local at all — the compiler quietly turns it into something else entirely before your code ever runs. Understanding exactly what that "something else" is explains not just how closures work, but a specific, real category of memory-leak bug that only makes sense once you've seen the compiler's output.

What Is It?

The Simple Explanation

When the C# compiler sees a lambda that reaches outside itself for a variable, it doesn't leave that variable where you wrote it. It moves it into a small, invisible, compiler-generated class — one field per captured variable — and rewrites both your method and the lambda to read and write through that class instead. The "closure" you've been picturing as a lambda with a backpack of variables is, quite literally, an object on the heap. The backpack has a real type, and you can inspect it.

The Technical Definition

This compiler-generated class is informally called a closure class or display class (in Roslyn's own source code and in decompiled output you'll often see it literally named something like <>c__DisplayClass0_0). It holds one instance field for every variable captured by any lambda in that particular scope. The enclosing method's own references to those variables are rewritten by the compiler to go through a field access on an instance of this class instead of a normal local slot. The lambda body itself becomes an instance method on that same generated class, and the delegate you hold — the Func<T> or Action you assign the lambda to — has its target set to that instance. None of this is something you opt into or can turn off; it happens automatically, silently, the moment the compiler sees a lambda reference a variable that isn't its own parameter.

Why Does It Exist?

The Problem — Stack Frames Are Temporary, Closures Aren't

An ordinary local variable lives on the stack, inside its method's stack frame. The moment that method returns, its stack frame is popped and every local in it is gone — that memory is immediately reused by whatever the CPU calls next. This is fast and it's exactly what a stack is for. But a closure's entire reason for existing is to keep working after the method that created it has returned — CreateCounter() in lesson 102 returns a Func<int> that keeps incrementing long after CreateCounter's own stack frame is long gone. A plain stack-allocated int count simply cannot do that; the stack doesn't work that way.

The Solution — Promote the Variable to the Heap

The compiler's fix is to stop treating a captured variable as a stack local at all. By moving it into a field on a heap-allocated object, its lifetime is no longer tied to any stack frame — it's tied to ordinary garbage-collection reachability, exactly like any other object field. As long as something reachable (a delegate, another object, a static field) still references that closure-class instance, the field — and the value it holds — stays alive. This is the entire mechanism that makes closures possible at all: a captured local variable becomes, in effect, a heap-allocated field, which is precisely why it survives after its enclosing method returns.

The key insight

Lesson 102 taught you the observable behavior: captured variables live by reference and outlive their method. This lesson explains the mechanism underneath that behavior: the compiler achieves it by silently relocating the variable's storage from a stack frame to a field on a heap object. Same fact, one level deeper.

Big Picture

WHAT YOU WRITE vs WHAT THE COMPILER GENERATES
YOU WRITE
static Func<int> CreateCounter()
{
    int count = 0;
    return () => ++count;
}
ROSLYN GENERATES (SIMPLIFIED — REAL NAMES ARE MANGLED)
sealed class DisplayClass0_0     // a hidden, compiler-generated class
{
    public int count;             // the captured variable, now a FIELD

    public int Lambda0()          // the lambda body, now a METHOD
        => ++count;
}

static Func<int> CreateCounter()
{
    var dc = new DisplayClass0_0(); // heap-allocated instance
    dc.count = 0;
    return new Func<int>(dc.Lambda0); // delegate targets THIS instance
}
WHY IT SURVIVES

How It Works

ONE CLOSURE CLASS CAN SERVE MULTIPLE LAMBDAS
1. ROSLYN GROUPS CAPTURES BY SCOPE, NOT BY LAMBDA

This is the detail lesson 102 never needed to mention: if two or more lambdas in the same enclosing scope capture the same variables (or overlapping sets of variables), Roslyn does not generate a separate hidden class per lambda. It generates one display class for that scope, with a field for each distinct captured variable, and adds one method to that same class per lambda. This is a genuine compiler optimization — fewer allocations, fewer objects for the GC to track — not just an implementation detail you'd never notice.

2. SHARED FIELDS MEAN SHARED MUTATION IS VISIBLE ACROSS LAMBDAS

Because both lambdas become methods on the same instance, a mutation one lambda makes to a shared captured variable is immediately visible to the other lambda — not because of anything special about closures "communicating," but simply because they're two methods reading and writing the same object field.

3. VARIABLES CAPTURED BY DIFFERENT, NON-OVERLAPPING LAMBDAS GET SEPARATE CLASSES

If a method has two lambdas that don't share any captured variables, or that capture variables from genuinely different scopes (like different loop iterations), the compiler is free to generate separate display-class instances for them — it groups by what's actually shared, not just by "everything in this method."

Simple Example

Here are two lambdas in the same method, both capturing runningTotal. Watch how mutating it from one lambda is visible from the other — a direct, observable consequence of them sharing one generated field.

using System;

class Program
{
    static (Action<int> add, Func<int> read) CreateAccumulator()
    {
        int runningTotal = 0; // captured by BOTH lambdas below

        Action<int> add = amount => runningTotal += amount;
        Func<int> read = () => runningTotal;

        return (add, read);
        // Under the hood: ONE display class instance, with a "runningTotal" field.
        // "add" and "read" are two methods on that SAME instance.
    }

    static void Main()
    {
        var (add, read) = CreateAccumulator();

        add(10);
        add(25);
        Console.WriteLine(read()); // 35 — "read" sees mutations made through "add"
    }
}

Code → Meaning → Result: add and read look like two independent delegates, but they're really two methods sharing one hidden object's runningTotal field. That's not a coincidence of "closures being clever" — it's the direct, mechanical result of Roslyn generating one display class for the scope both lambdas were written in.

Real-World Example — A Closure-Shaped Memory Leak

Now the payoff for understanding the mechanism: a genuinely common production bug that only makes sense once you know a captured variable is really a heap field kept alive by whatever holds the delegate. Picture a reporting screen that loads a large dataset once, and wires up a "refresh" button whose handler is a lambda:

public class ReportViewModel
{
    // Loaded once when the report opens — potentially tens of megabytes.
    private readonly List<RowData> _fullDataset;

    public ReportViewModel(List<RowData> fullDataset, IEventAggregatorLike bus)
    {
        _fullDataset = fullDataset;
        var rowCountAtLoad = _fullDataset.Count; // small int — the ONLY thing this handler needs

        //  This lambda captures "this" implicitly (via the instance method group
        // it could have used instead) — but even written this way, it captures
        // "rowCountAtLoad" AND, because it's an instance context, potentially "this."
        bus.RefreshRequested += () =>
        {
            Console.WriteLine($"Refresh requested. Loaded {rowCountAtLoad} rows at open.");
        };
        // "bus" is a long-lived, application-scoped event source (lesson 191 covers
        // exactly this shape of leak from the event side). As long as "bus" is alive,
        // its invocation list holds this lambda's delegate — and that delegate's
        // target is a display-class instance that, depending on what it captured,
        // may be keeping this whole ReportViewModel (and _fullDataset) reachable
        // long after the report window was closed.
    }
}

The specific danger: if that lambda had captured this instead of just the small int rowCountAtLoad — which happens automatically the moment a lambda inside an instance method references any instance field or instance method, not just when you type this explicitly — then the generated closure class's field wouldn't hold a small integer. It would hold a reference to the entire ReportViewModel, which in turn keeps _fullDataset reachable. A long-lived event bus subscribed to by a lambda like that keeps the whole view model and its entire dataset alive in memory for as long as the bus itself is alive — even after the report screen has been closed and the user has moved on. This is a textbook "closure keeps a large object alive longer than expected" leak, and it's exactly the publisher/subscriber shape lesson 191 covers from the events side — this lesson is the closures-side explanation of the same underlying mechanism.

Analogy

The Backpack Isn't a Metaphor — It's a Real Object

Lesson 102 described a closure as "a lambda plus a backpack of the outer variables it needs." That was already accurate — but now you know the backpack is not a figure of speech. It's a literal object, allocated on the heap, with a compiler-generated class and real fields. If you pack something heavy into that backpack — an entire dataset, an entire view model, via this — the backpack doesn't get lighter just because you only meant to carry one small item. Whatever's reachable through the backpack's fields stays alive as long as the backpack does, and the backpack stays alive as long as anything is still holding the lambda.

Under the Hood — The Loop Variable, Revisited at the Object Level

Lesson 102 taught the modern, per-iteration foreach scoping rule as an observable fact: since C# 5, each iteration gets its own fresh loop variable, so a lambda created inside a foreach captures a distinct value per iteration with no workaround needed. You now have the vocabulary to see exactly why, at the object level, instead of just trusting the rule:

WHY foreach IS SAFE AND A CLASSIC for IS NOT — IN DISPLAY-CLASS TERMS
A CLASSIC for LOOP: ONE VARIABLE, ONE DISPLAY-CLASS INSTANCE FOR THE WHOLE LOOP
A foreach LOOP (SINCE C# 5): A FRESH DISPLAY-CLASS INSTANCE PER ITERATION
THE MANUAL FIX FROM LESSON 102 DOES THE SAME THING, BY HAND

Nothing about the rule has changed from lesson 102 — this is the same behavior, described one layer lower. The reason "each iteration gets its own closure-captured variable" isn't a special loop feature; it's a direct consequence of how many separate display-class instances the compiler ends up allocating, which in turn depends entirely on how many separate variables actually exist to capture.

Common Confusion

1. "Every lambda gets its own hidden class" — not necessarily

It's tempting to assume a 1:1 mapping between lambdas and generated closure classes. As shown above, Roslyn groups by scope and shared captures, not by lambda count. Two, five, or ten lambdas in the same method that all capture the same set of variables can share one display-class instance with ten methods on it. Don't reason about closures by counting lambdas — reason about which variables each one actually touches.

2. "Capturing 'this' is obviously visible in the code" — often it isn't

A lambda inside an instance method captures this the moment it references any instance member — a field, a property, or even calling another instance method — even if you never type the word this anywhere. This is the single most common way a closure ends up keeping an entire object graph alive by accident: the capture is invisible at the call site, and only becomes visible once you think in terms of "what does the generated display class's field actually point to."

Common Mistakes

Mistake 1 — Handing a long-lived subscription a lambda that implicitly captures an entire object

Subscribing a lambda that references an instance field to a static event, an application-scoped event aggregator, or any publisher that will outlive the current object — as shown in the real-world example, this keeps the whole object reachable for as long as the publisher lives, not just the field the lambda actually needed.

Extract only the small values genuinely needed into local variables before creating the long-lived lambda, and capture those instead of referencing instance members directly. If you need actual instance behavior, prefer a named method group subscription you can explicitly unsubscribe (-=) — which lesson 191 covers as the direct fix for this exact leak shape from the publisher's side.

Mistake 2 — Assuming shared captures between lambdas is a bug, not a feature

Being surprised when two lambdas in the same method see each other's mutations to a shared captured variable, and treating it as unexpected or accidental coupling.

Recognize it as the intended behavior — it's the same reference-capture semantics from lesson 102, now visible as two methods sharing one object's field. If you genuinely need two lambdas to be independent, make sure they don't reference the same outer variable, or copy the value into separate locals first.

When Should I Use It?

Mental Model

A captured variable = a field on a compiler-generated, heap-allocated class.
A lambda body = a method on that same class.
Multiple lambdas sharing captures = multiple methods on one instance — a real Roslyn optimization.
The delegate you hold = a reference to that instance. As long as the delegate is reachable, so is everything its display class points to — including an implicitly captured this.

Key Takeaway


Check Your Understanding

You've seen exactly what the compiler generates for a closure, and how that explains both a real memory-leak pattern and the loop-variable behavior from lesson 102. Let's confirm the details.

1. Why does a captured local variable survive after its enclosing method returns, when an ordinary local variable does not?

Show answer

Correct: B

Why B is correct: This is the exact mechanism this lesson covers — the compiler generates a hidden class with a field for the captured variable, and the delegate's target references an instance of that class. Once it's a heap field, ordinary GC reachability rules apply, and stack-frame lifetime is no longer relevant.

Why A is incorrect: Stack frames genuinely are popped and reused when a method returns — nothing keeps them alive. The captured variable isn't on the stack anymore by the time this matters.

Why C is incorrect: CPU registers aren't involved in this mechanism at all — this is a compile-time transformation of where the variable's storage lives (stack vs. heap object field), not a hardware-level detail.

Why D is incorrect: Garbage collection continues to run normally; a closure's object simply stays reachable (and therefore uncollected) for as long as something references it, exactly like any other heap object.

Reinforcement: A captured variable's extended lifetime is a direct, mechanical consequence of it becoming a heap-allocated field instead of a stack local.

2. Two lambdas are written in the same method and both capture the same outer variable total. What does the compiler typically generate?

Show answer

Correct: B

Why B is correct: Roslyn groups captures by scope, generating one display class per set of shared captured variables, with one method per lambda on that shared instance — a genuine optimization over allocating a separate object per lambda.

Why A is incorrect: If this were true, mutating total through one lambda would not be visible to the other — but it is, precisely because they share one field on one instance.

Why C is incorrect: Multiple lambdas capturing the same variable is not only legal, it's one of the most common closure patterns — used throughout lessons 098 and 102.

Why D is incorrect: There's no thread-local mechanism involved here; this is an ordinary instance field on an ordinary (if hidden) class.

Reinforcement: Don't count lambdas to predict how many hidden classes exist — count the distinct sets of variables actually being captured.

3. A lambda inside an instance method reads one of the object's private fields directly (not through a local copy) and is subscribed to a static event that lives for the entire application's lifetime. What is the risk?

Show answer

Correct: B

Why B is correct: Referencing any instance member from inside a lambda captures this implicitly, even without typing the word. Since the display class's field then holds a reference to the whole object, and a static event's invocation list holds the delegate (and therefore that display class) for the application's entire lifetime, the whole object becomes unreachable-proof — a real memory leak.

Why A is incorrect: This is exactly the "invisible capture" trap covered in Common Confusion — referencing an instance field or method captures this just as much as writing this explicitly would.

Why C is incorrect: There's no exception here — quite the opposite problem. The object is kept alive and valid, which is precisely why it's a silent memory leak rather than a crash.

Why D is incorrect: The field is captured by reference through this, exactly like any other captured variable — it reflects the object's current, live value whenever the lambda runs, not a frozen snapshot.

Reinforcement: An implicit this capture is one of the most common ways a closure accidentally keeps far more alive than intended.

4. Why does a classic for loop's lambda capture end up seeing the loop's final value, while a foreach loop's lambda capture (since C# 5) sees each iteration's own value?

Show answer

Correct: B

Why B is correct: This is the object-level explanation of lesson 102's rule: how many separate display-class instances get allocated depends directly on how many genuinely separate variables exist. One shared loop counter means one shared instance and one shared field; a fresh per-iteration variable means a fresh instance and field each time.

Why A is incorrect: Both kinds of lambdas run only when actually invoked, after being added to whatever collection or delegate holds them — evaluation timing isn't the distinguishing factor here.

Why C is incorrect: for loops and lambdas work together fine, as shown throughout lesson 102 — the issue is specifically about what gets captured, not whether it's legal.

Why D is incorrect: This is a real, observable, functional difference in captured behavior — not a stylistic one — as demonstrated by lesson 102's 012 vs. 333 output comparison.

Reinforcement: The loop-variable trap and its fix are both fully explained by counting how many display-class instances actually get allocated.

You now know exactly what a closure compiles to — a heap-allocated class with fields for captured variables and methods for lambda bodies — and why that mechanism is both what makes closures work and how they can accidentally leak memory. Next: the same "publisher holds a strong reference" leak shape, from the events side.


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