Skip to content
← Todas las publicaciones
· Delta1 Labs Glass.NETDecompiladorGuía

Decompilar la palabra clave dynamic: leyendo el IL de los call sites en Glass.NET

Una línea de C# con dynamic se compila en un call site cacheado, un binder, un array de flags de información de argumentos y una invocación a través de un campo delegado: una docena de miembros de andamiaje generado para una sola llamada a método. Esto es lo que el compilador emite realmente para el despacho dynamic, cómo un decompilador ingenuo te muestra la maquinaria CallSite en crudo y cómo Glass.NET reconoce el patrón y te devuelve la única línea que escribiste.

Escribe una línea de C# con dynamic y el compilador escribe un pequeño programa en tu nombre. No hay ningún callvirt a un método llamado Add; hay un call site cacheado, un binder que describe qué significa «llamar a Add», un array de flags que describe cada argumento y una invocación a través de un delegado almacenado en un campo generado. Un decompilador tiene que ver todo ese andamiaje y entender que suma la única expresión que teclaste; de lo contrario, te entrega el andamiaje y te deja hacer ingeniería inversa de tu propio código fuente.

Este es un análisis en profundidad, con código, de exactamente eso: qué emite el compilador de C# para el despacho dynamic, cómo se ve el lowering en crudo cuando nada lo pliega de vuelta, y cómo Glass.NET reconoce el patrón de call site y reconstruye el dynamic con el que empezaste. Es una de las demostraciones más limpias de por qué «se decompila» y «se decompila en algo que puedes leer» son afirmaciones distintas.

El código fuente

Aquí está el programa completo. Tres operaciones dynamic: una invocación de miembro, un operador binario y una conversión implícita de un resultado dynamic de vuelta a una local tipada.

public static class Demo
{
    public static int Run(dynamic a, dynamic b)
    {
        dynamic sum = a.Add(b);   // invocación de miembro dynamic
        dynamic twice = sum + sum; // operador binario dynamic
        int result = twice;        // conversión dynamic -> int
        return result;
    }
}

Nada exótico. Pero ninguna de esas tres operaciones puede resolverse en tiempo de compilación, porque el compilador no sabe qué son a y b. Así que, en lugar de emitir llamadas, emite la maquinaria para resolverlas después, en el primer uso, contra los tipos en tiempo de ejecución que realmente aparezcan.

Lo que el compilador emite realmente

Para cada operación dynamic el compilador genera un call site cacheado: un campo estático de tipo CallSite<T> que vive en una clase oculta (llamada <>o__0 y similares), donde T es la firma de delegado de la operación. La primera vez que se ejecuta la operación, una comprobación de null inicializa perezosamente el site a partir de un CallSiteBinder construido por una factoría en Microsoft.CSharp.RuntimeBinder.Binder. Al binder se le entrega un conjunto de CSharpBinderFlags y un CSharpArgumentInfo por argumento, que describe cómo participa cada valor (si es un ref, si es una constante en tiempo de compilación, si es el destino de la operación). Después se invoca el delegado Target del site con el site y los argumentos.

Decompilada sin reconocer el patrón —que es lo que obtienes de un visor que solo deshace las transformaciones obvias—, la invocación de miembro a.Add(b) vuelve con este aspecto:

// Lowering en crudo de: dynamic sum = a.Add(b);
if (<>o__0.<>p__0 == null)
{
    <>o__0.<>p__0 = CallSite<Func<CallSite, object, object, object>>.Create(
        Microsoft.CSharp.RuntimeBinder.Binder.InvokeMember(
            CSharpBinderFlags.None,
            "Add",
            null,
            typeof(Demo),
            new CSharpArgumentInfo[]
            {
                CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.None, null),
                CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.None, null)
            }));
}
object sum = <>o__0.<>p__0.Target(<>o__0.<>p__0, a, b);

Eso es fiel al IL y casi inútil de leer. Cada operación dynamic se multiplica de la misma manera: el + se convierte en un site Binder.BinaryOperation con un ExpressionType.Add, y el int result = twice; se convierte en un site Binder.Convert dirigido a typeof(int). Un método de tres líneas se infla en tres clases caché, tres campos de site, tres inicializadores perezosos y tres invocaciones .Target(...). La forma es completamente mecánica, que es exactamente por lo que puede reconocerse y deshacerse.

dynamic sum = a.Add(b);lowering del compiladorCallSite<Func<…>> <>p__0 (campo cacheado)Binder.InvokeMember("Add", …)CSharpArgumentInfo[] { … }.Target(site, a, b)a.Add(b)Glass pliega las cuatropartes de vuelta en laúnica expresión

Cómo Glass lo pliega de vuelta

Glass está construido sobre ICSharpCode.Decompiler, el mismo motor detrás de ILSpy, y reconstruir dynamic es una de sus transformaciones de patrones. El decompilador reconoce la forma completa del call site —la caché <>o__/<>p__, la inicialización perezosa protegida por la comprobación de null, la llamada a la factoría Binder.* y la invocación .Target(...) correspondiente— como una única operación lógica, lee el binder para saber qué operación es (InvokeMember "Add", BinaryOperation Add, Convert int), lee el array CSharpArgumentInfo para saber cómo se pasaron los operandos y colapsa todo el conjunto de vuelta en la única expresión que lo produjo. Abre Demo.Run en la vista decompilada de Glass y obtienes exactamente lo que escribiste:

public static int Run(dynamic a, dynamic b)
{
    dynamic sum = a.Add(b);
    dynamic twice = sum + sum;
    int result = twice;
    return result;
}

El andamiaje ha desaparecido porque Glass entendió para qué servía. Esa es la diferencia entre una herramienta que te muestra IL transliterado a una sintaxis con forma de C# y una herramienta que te muestra código fuente.

dynamic frente a object, y dónde se guarda la diferencia

Hay una sutileza que la reconstrucción tiene que resolver bien. A nivel del sistema de tipos, dynamic es System.Object: los dos son el mismo tipo en los metadatos, y el IL de arriba trafica con object en todas partes. Entonces, ¿cómo sabe Glass que debe escribir dynamic a y no object a?

El compilador deja una miga de pan: System.Runtime.CompilerServices.DynamicAttribute. Dondequiera que aparezca un dynamic en una firma —un parámetro, un campo, un tipo de retorno, incluso una posición dentro de un genérico como List<dynamic>— el compilador adjunta este atributo para marcar qué objects eran realmente dynamic en el código fuente. Para los casos anidados lleva una transformación bool[] que indica qué posiciones de un tipo compuesto eran dynamic. Glass lee ese atributo y restaura dynamic precisamente donde corresponde, recurriendo a object donde el atributo está ausente. Entra en la vista de información del ensamblado y podrás ver el atributo directamente sobre los miembros; son los metadatos que hacen que la distinción borrada sea recuperable en absoluto.

Leer el C#, y luego bajar al IL

Para la mayoría de los propósitos, el dynamic reconstruido es todo lo que quieres. Pero la maquinaria del call site codifica detalles que la expresión plegada oculta deliberadamente, y cuando esos detalles importan bajas a la vista de IL junto al C#. Vale la pena conocer dos:

  • Paso de argumentos. Cada CSharpArgumentInfo lleva flags como IsRef/IsOut y UseCompileTimeType. Una llamada dynamic que pasa un argumento ref, o que fija un argumento a su tipo estático en lugar de a su tipo en tiempo de ejecución, lo registra en el array de información de argumentos. El C# plegado muestra foo.Bar(ref x); el IL muestra los CSharpArgumentInfoFlags que lo hicieron así.
  • Identidad del operador. Una operación binaria dynamic almacena su operador como un System.Linq.Expressions.ExpressionType (Add, LessThan, Equal, …) y unos CSharpBinderFlags que registran contexto como CheckedContext. Si necesitas confirmar que un + reconstruido se compiló en una región checked, el flag del binder en el IL es donde vive eso.

Glass pone ambas vistas a un clic de distancia a propósito. Lee el C# decompilado para recuperar la intención: la única línea que alguien escribió realmente. Abre el IL cuando necesites ver qué flags de binder y qué tipos de información de argumentos eligió el compilador por debajo. La ruta de exportación (glass export --in app.dll --out ./src) escribe el C# reconstruido en disco como un proyecto compilable, así que también puedes hacer grep en un ensamblado completo buscando los call sites dynamic y leerlos en tu propio editor. En cualquier caso, la ganancia es la misma: una docena de miembros de andamiaje de call site generado se convierten en la única expresión que representan, y tu tiempo va a entender el código en lugar de la contabilidad del compilador.

Prueba Nebula.NET

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