Skip to content
← Todas las publicaciones
· Delta1 Labs Decompilación.NETGuía

En qué se convierten las lambdas y los closures al decompilar: display classes y variables capturadas

Decompila un método que usa una lambda y encontrarás un tipo llamado <>c__DisplayClass con tus variables locales como campos. Esto es cómo el compilador de C# convierte los closures en clases, por qué una lambda que captura asigna memoria y una que no captura no lo hace, y qué te dice el código generado sobre un bug de closure.

Un decompilador es muy bueno devolviendo una lambda a como la escribiste. Pero baja un nivel —pide ver lo que el compilador generó en realidad— y las lambdas se revelan como algo más concreto: clases corrientes con tus variables locales convertidas en campos. Entender esa transformación explica dos cosas con las que todo desarrollador de .NET tropieza tarde o temprano: por qué algunas lambdas asignan memoria y otras son gratis, y por qué un bucle lleno de lambdas puede acabar viendo todas el mismo valor.

El caso sin captura: un singleton cacheado

Empieza con una lambda que no captura nada de su entorno:

items.Where(x => x.IsActive);

x => x.IsActive depende solo de su argumento. Es el mismo delegado en cada llamada, así que el compilador genera una clase singleton oculta —convencionalmente <>c—, mantiene una instancia cacheada y guarda el delegado en un campo estático. Decompilado, se ve así:

[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));

El ??= es la clave: el delegado se crea una vez, se cachea y se reutiliza para siempre. Una lambda sin captura en una ruta caliente es efectivamente gratis tras la primera llamada —sin asignación por llamada—. Ver <>c en una decompilación te dice de inmediato que esta lambda no captura nada.

El caso con captura: una display class

Ahora captura una local:

public Func<int, int> MakeAdder(int delta)
{
    int calls = 0;
    return x => { calls++; return x + delta; };
}

La lambda usa delta y calls, ambos pertenecientes al marco de pila de MakeAdder —un marco que desaparece en cuanto MakeAdder retorna, mientras el delegado devuelto sigue vivo—. La respuesta del compilador es mover esas variables fuera de la pila y al montículo, dentro de una 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
}

Ahora se ven tres cosas que el código fuente ocultaba. Las variables capturadas se convirtieron en campos (delta, calls). El método MakeAdder se reescribió para leer y escribir esos campos en lugar de locales, así que calls++ dentro de la lambda y cualquier uso de calls en el método son el mismo campo, que es cómo un closure comparte estado mutable. Y hay una asignación de la display class cada vez que MakeAdder se ejecuta. Esa asignación es el coste real de una lambda que captura; en un bucle apretado es lo que hay que notar.

Marco de pila de MakeAdderint deltaint callscapturado<>c__DisplayClass0_0 (heap)public int delta;public int calls;int b__0(int x) { ... }

Leer un bug de closure

La captura como campos es también donde vive la sorpresa de closure más famosa. Considera construir delegados en un bucle:

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

¿Por qué 333? Porque en un bucle for el índice i es una variable compartida entre todas las iteraciones. Decompilado, hay una única display class creada una vez, las tres lambdas capturan el mismo campo i, y para cuando se ejecutan el bucle ha dejado i en 3. El código generado lo deja inequívoco: una instancia de display class, un campo i, tres delegados apuntando a ella.

Contrasta un foreach sobre una colección en C# 5 y posteriores, donde la variable del bucle es nueva por iteración por regla del lenguaje:

foreach (var item in items)
    actions.Add(() => Use(item));   // each lambda sees its own 'item'

Aquí el decompilador muestra una nueva display class asignada dentro del cuerpo del bucle, una por iteración, cada una con el item de esa iteración. Misma forma sintáctica que el bucle for, código generado completamente distinto —y la forma decompilada es la vía más rápida para ver cuál tienes cuando una captura se comporta de forma inesperada—. Si necesitas captura por iteración en un bucle for, copia el índice en una local declarada dentro del bucle (int j = i;) y captura j; la decompilación mostrará entonces una display class por iteración, exactamente como el foreach.

Por qué vale la pena mirar la vista generada

La mayoría de las veces quieres la lambda reconstruida por el decompilador —se lee como tu código fuente y muestra la intención—. Glass.NET te da eso, y te deja bajar a la <>c__DisplayClass en bruto cuando una pregunta es realmente sobre mecánica: ¿esta lambda asigna en una ruta caliente (display class) o está cacheada (<>c)? ¿Estas lambdas de bucle comparten una variable capturada u obtienen una nueva cada vez? Esas son justo las preguntas a las que te apunta un profiler y que el C# reconstruido no puede responder, pero las clases generadas las dejan claras. Una lambda es sintaxis; un closure es una clase, y en cuanto sabes leer la clase, el comportamiento en tiempo de ejecución deja de sorprender.

Prueba Nebula.NET

Endurece tu código .NET en minutos — empieza con la edición gratuita.