Wozu Lambdas und Closures dekompilieren: Display-Klassen und erfasste Variablen
Dekompilieren Sie eine Methode mit einer Lambda, und Sie finden einen Typ namens <>c__DisplayClass mit Ihren lokalen Variablen als Feldern. Hier steht, wie der C#-Compiler Closures in Klassen verwandelt, warum eine erfassende Lambda alloziert und eine nicht-erfassende nicht, und was der generierte Code über einen Closure-Bug verrät.
Ein Decompiler ist sehr gut darin, eine Lambda so zurückzugeben, wie Sie sie geschrieben haben. Aber gehen Sie eine Ebene tiefer — bitten Sie zu sehen, was der Compiler tatsächlich generiert hat — und Lambdas offenbaren sich als etwas Konkreteres: gewöhnliche Klassen, deren Felder Ihre lokalen Variablen sind. Diese Transformation zu verstehen erklärt zwei Dinge, über die jeder .NET-Entwickler irgendwann stolpert: warum manche Lambdas allozieren und andere gratis sind, und warum eine Schleife voller Lambdas am Ende alle denselben Wert sehen können.
Der Fall ohne Erfassung: ein gecachtes Singleton
Beginnen Sie mit einer Lambda, die nichts aus ihrer Umgebung erfasst:
items.Where(x => x.IsActive);
x => x.IsActive hängt nur von seinem Argument ab. Es ist bei jedem Aufruf dasselbe Delegate, also generiert der Compiler eine versteckte Singleton-Klasse — konventionell <>c —, hält eine gecachte Instanz und speichert das Delegate in einem statischen Feld. Dekompiliert sieht es so aus:
[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));
Das ??= ist der Punkt: Das Delegate wird einmal erstellt, gecacht und für immer wiederverwendet. Eine nicht-erfassende Lambda in einem heißen Pfad ist nach dem ersten Aufruf praktisch gratis — keine Allokation pro Aufruf. <>c in einer Dekompilierung zu sehen sagt Ihnen sofort, dass diese Lambda nichts erfasst.
Der Fall mit Erfassung: eine Display-Klasse
Erfassen Sie nun eine lokale Variable:
public Func<int, int> MakeAdder(int delta)
{
int calls = 0;
return x => { calls++; return x + delta; };
}
Die Lambda nutzt delta und calls, die beide zum Stack-Frame von MakeAdder gehören — einem Frame, der verschwindet, sobald MakeAdder zurückkehrt, während das zurückgegebene Delegate weiterlebt. Die Antwort des Compilers ist, diese Variablen vom Stack auf den Heap zu verschieben, in eine Display-Klasse:
[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
}
Drei Dinge sind nun sichtbar, die der Quellcode verbarg. Die erfassten Variablen wurden zu Feldern (delta, calls). Die Methode MakeAdder wurde umgeschrieben, um diese Felder statt lokaler Variablen zu lesen und zu schreiben — also sind calls++ in der Lambda und jede Nutzung von calls in der Methode dasselbe Feld, so teilt eine Closure veränderlichen Zustand. Und es gibt eine Allokation der Display-Klasse bei jeder Ausführung von MakeAdder. Diese Allokation ist die wahren Kosten einer erfassenden Lambda; in einer engen Schleife ist sie das, worauf man achten muss.
Einen Closure-Bug lesen
Die Erfassung-als-Felder ist auch dort, wo die berühmteste Closure-Überraschung lebt. Betrachten Sie das Erstellen von Delegates in einer Schleife:
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
Warum 333? Weil in einer for-Schleife der Index i eine Variable ist, die über alle Iterationen geteilt wird. Dekompiliert gibt es eine einzige, einmal erstellte Display-Klasse, alle drei Lambdas erfassen dasselbe i-Feld, und wenn sie laufen, hat die Schleife i bei 3 belassen. Der generierte Code macht es eindeutig: eine Display-Klassen-Instanz, ein i-Feld, drei Delegates, die darauf zeigen.
Vergleichen Sie ein foreach über eine Sammlung in C# 5 und später, wo die Schleifenvariable per Sprachregel frisch pro Iteration ist:
foreach (var item in items)
actions.Add(() => Use(item)); // each lambda sees its own 'item'
Hier zeigt der Decompiler eine neue, im Schleifenrumpf allozierte Display-Klasse, einmal pro Iteration, jede mit dem item dieser Iteration. Dieselbe syntaktische Form wie die for-Schleife, völlig anderer generierter Code — und die dekompilierte Form ist der schnellste Weg zu sehen, welchen Sie haben, wenn sich eine Erfassung unerwartet verhält. Wenn Sie in einer for-Schleife Erfassung pro Iteration brauchen, kopieren Sie den Index in eine innerhalb der Schleife deklarierte lokale Variable (int j = i;) und erfassen Sie j; die Dekompilierung zeigt dann eine Display-Klasse pro Iteration, genau wie das foreach.
Warum die generierte Ansicht einen Blick wert ist
Meistens wollen Sie die vom Decompiler rekonstruierte Lambda — sie liest sich wie Ihr Quellcode und zeigt die Absicht. Glass.NET gibt Ihnen das und lässt Sie zur rohen <>c__DisplayClass hinabsteigen, wenn eine Frage wirklich um Mechanik geht: Alloziert diese Lambda auf einem heißen Pfad (Display-Klasse) oder ist sie gecacht (<>c)? Teilen diese Schleifen-Lambdas eine erfasste Variable oder bekommen sie jedes Mal eine frische? Das sind genau die Fragen, auf die ein Profiler Sie hinweist und die das rekonstruierte C# nicht beantworten kann — aber die generierten Klassen sagen sie klar aus. Eine Lambda ist Syntax; eine Closure ist eine Klasse, und sobald Sie die Klasse lesen können, hört das Laufzeitverhalten auf zu überraschen.
Nebula.NET testen
Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.