A decompiler is very good at putting a lambda back the way you wrote it. But drop one level — ask to see what the compiler actually generated — and lambdas reveal themselves as something more concrete: ordinary classes with your local variables turned into fields. Understanding that transformation explains two things every .NET developer eventually trips over: why some lambdas allocate and others are free, and why a loop full of lambdas can all end up seeing the same value.
The no-capture case: a cached singleton
Start with a lambda that captures nothing from its surroundings:
items.Where(x => x.IsActive);
x => x.IsActive depends only on its argument. It is the same delegate on every call, so the compiler generates a hidden singleton class — conventionally <>c — holds one cached instance, and stores the delegate in a static field. Decompiled, it looks like this:
[CompilerGenerated]
private sealed class <>c
{
public static readonly <>c <>9 = new <>c();
public static Func<Item, bool> <>9__0_0; // the cached delegate
internal bool <Run>b__0_0(Item x) => x.IsActive;
}
// call site:
items.Where(<>c.<>9__0_0 ??= new Func<Item, bool>(<>c.<>9.<Run>b__0_0));
The ??= is the point: the delegate is created once, cached, and reused forever. A non-capturing lambda in a hot path is effectively free after the first call — no per-call allocation. Seeing <>c in a decompile tells you immediately that this lambda captures nothing.
The capture case: a display class
Now capture a local:
public Func<int, int> MakeAdder(int delta)
{
int calls = 0;
return x => { calls++; return x + delta; };
}
The lambda uses delta and calls, both of which belong to MakeAdder''s stack frame — a frame that is gone the moment MakeAdder returns, while the returned delegate lives on. The compiler''s answer is to move those variables off the stack and onto the heap, inside a display class:
[CompilerGenerated]
private sealed class <>c__DisplayClass0_0
{
public int delta; // was the parameter
public int calls; // was the local
internal int <MakeAdder>b__0(int x) { calls++; return x + delta; }
}
public Func<int, int> MakeAdder(int delta)
{
var cs = new <>c__DisplayClass0_0(); // one allocation per call
cs.delta = delta;
cs.calls = 0;
return cs.<MakeAdder>b__0; // delegate over the display-class method
}
Three things are now visible that the source hid. The captured variables became fields (delta, calls). The method MakeAdder was rewritten to read and write those fields instead of locals — so calls++ inside the lambda and any use of calls in the method are the same field, which is how a closure shares mutable state. And there is one allocation of the display class every time MakeAdder runs. That allocation is the real cost of a capturing lambda; in a tight loop it is the thing to notice.
Reading a closure bug
Capture-as-fields is also where the most famous closure surprise lives. Consider building delegates in a loop:
var actions = new List<Action>();
for (int i = 0; i < 3; i++)
actions.Add(() => Console.Write(i));
foreach (var a in actions) a(); // prints 333, not 012
Why 333? Because in a for loop the index i is one variable shared across all iterations. Decompiled, there is a single display class created once, all three lambdas capture the same i field, and by the time they run the loop has left i at 3. The generated code makes it unambiguous: one display-class instance, one i field, three delegates pointing at it.
Contrast a foreach over a collection in C# 5 and later, where the loop variable is fresh per iteration by language rule:
foreach (var item in items)
actions.Add(() => Use(item)); // each lambda sees its own 'item'
Here the decompiler shows a new display class allocated inside the loop body, once per iteration, each holding that iteration''s item. Same syntax shape as the for loop, completely different generated code — and the decompiled form is the fastest way to see which one you have when a capture behaves unexpectedly. If you need per-iteration capture in a for loop, copy the index into a local declared inside the loop (int j = i;) and capture j; the decompile will then show a display class per iteration, exactly as the foreach does.
Why the generated view is worth a look
Most of the time you want the decompiler''s reconstructed lambda — it reads like your source and shows intent. Glass.NET gives you that, and lets you drop to the raw <>c__DisplayClass when a question is really about mechanics: Is this lambda allocating on a hot path (display class) or cached (<>c)? Do these loop lambdas share one captured variable or get a fresh one each time? Those are exactly the questions a profiler points you at and the reconstructed C# cannot answer — but the generated classes state them plainly. A lambda is syntax; a closure is a class, and once you can read the class, the runtime behaviour stops being surprising.
Top comments (0)