Ce que les lambdas et les closures donnent à la décompilation : display classes et variables capturées
Décompilez une méthode qui utilise une lambda et vous trouvez un type nommé <>c__DisplayClass avec vos variables locales en champs. Voici comment le compilateur C# transforme les closures en classes, pourquoi une lambda qui capture alloue et une qui ne capture pas n'alloue pas, et ce que le code généré révèle d'un bug de closure.
Un décompilateur est très doué pour remettre une lambda telle que vous l”avez écrite. Mais descendez d”un cran — demandez à voir ce que le compilateur a réellement généré — et les lambdas se révèlent comme quelque chose de plus concret : des classes ordinaires avec vos variables locales transformées en champs. Comprendre cette transformation explique deux choses sur lesquelles tout développeur .NET finit par trébucher : pourquoi certaines lambdas allouent et d”autres sont gratuites, et pourquoi une boucle pleine de lambdas peut finir par toutes voir la même valeur.
Le cas sans capture : un singleton mis en cache
Commencez par une lambda qui ne capture rien de son entourage :
items.Where(x => x.IsActive);
x => x.IsActive ne dépend que de son argument. C”est le même délégué à chaque appel, donc le compilateur génère une classe singleton cachée — par convention <>c —, garde une instance en cache et stocke le délégué dans un champ statique. Décompilé, cela ressemble à :
[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));
Le ??= est le point clé : le délégué est créé une fois, mis en cache et réutilisé pour toujours. Une lambda sans capture dans un chemin chaud est effectivement gratuite après le premier appel — aucune allocation par appel. Voir <>c dans une décompilation vous dit immédiatement que cette lambda ne capture rien.
Le cas avec capture : une display class
Capturez maintenant une locale :
public Func<int, int> MakeAdder(int delta)
{
int calls = 0;
return x => { calls++; return x + delta; };
}
La lambda utilise delta et calls, qui appartiennent tous deux au cadre de pile de MakeAdder — un cadre qui disparaît dès que MakeAdder retourne, alors que le délégué renvoyé continue de vivre. La réponse du compilateur est de déplacer ces variables hors de la pile, sur le tas, à l”intérieur d”une 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
}
Trois choses sont maintenant visibles que le source cachait. Les variables capturées sont devenues des champs (delta, calls). La méthode MakeAdder a été réécrite pour lire et écrire ces champs au lieu de locales — donc calls++ dans la lambda et tout usage de calls dans la méthode sont le même champ, c”est ainsi qu”une closure partage un état mutable. Et il y a une allocation de la display class à chaque exécution de MakeAdder. Cette allocation est le vrai coût d”une lambda qui capture ; dans une boucle serrée, c”est la chose à remarquer.
Lire un bug de closure
La capture-en-champs est aussi là où vit la surprise de closure la plus célèbre. Considérez la construction de délégués dans une boucle :
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
Pourquoi 333 ? Parce que dans une boucle for, l”index i est une seule variable partagée entre toutes les itérations. Décompilé, il y a une unique display class créée une fois, les trois lambdas capturent le même champ i, et au moment où elles s”exécutent la boucle a laissé i à 3. Le code généré le rend sans ambiguïté : une instance de display class, un champ i, trois délégués pointant dessus.
Comparez avec un foreach sur une collection en C# 5 et ultérieur, où la variable de boucle est fraîche par itération par règle du langage :
foreach (var item in items)
actions.Add(() => Use(item)); // each lambda sees its own 'item'
Ici le décompilateur montre une nouvelle display class allouée dans le corps de la boucle, une par itération, chacune contenant l”item de cette itération. Même forme syntaxique que la boucle for, code généré complètement différent — et la forme décompilée est le moyen le plus rapide de voir lequel vous avez quand une capture se comporte de façon inattendue. Si vous avez besoin d”une capture par itération dans une boucle for, copiez l”index dans une locale déclarée à l”intérieur de la boucle (int j = i;) et capturez j ; la décompilation montrera alors une display class par itération, exactement comme le foreach.
Pourquoi la vue générée vaut le coup d’œil
La plupart du temps, vous voulez la lambda reconstruite par le décompilateur — elle se lit comme votre source et montre l”intention. Glass.NET vous le donne, et vous laisse descendre à la <>c__DisplayClass brute quand une question porte vraiment sur la mécanique : cette lambda alloue-t-elle sur un chemin chaud (display class) ou est-elle mise en cache (<>c) ? Ces lambdas de boucle partagent-elles une variable capturée ou en obtiennent-elles une fraîche à chaque fois ? Ce sont exactement les questions vers lesquelles un profileur vous dirige et auxquelles le C# reconstruit ne peut pas répondre — mais les classes générées les énoncent clairement. Une lambda est de la syntaxe ; une closure est une classe, et dès que vous savez lire la classe, le comportement à l”exécution cesse de surprendre.
Essayez Nebula.NET
Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.