Skip to content
← Todas las publicaciones
· Delta1 Labs Glass.NETDecompilaciónC#

Decompilar árboles de expresiones de C#: la lambda que se convirtió en un árbol de llamadas de fábrica

El mismo `x => x + 1` compila de dos formas completamente distintas según el tipo al que se asigna. Como delegado es un método oculto y un ldftn; como Expression<Func<int, int>> es un nido de llamadas a Expression.Parameter, Expression.Constant, Expression.Add y Expression.Lambda con los miembros referenciados por ldtoken. Aquí tienes exactamente qué emite el compilador para un árbol de expresiones — variables capturadas como un Constant de una clausura, igualdad de cadenas con su op_Equality, lambdas anidadas envueltas en Quote — y cómo un decompilador pliega las llamadas de fábrica de vuelta a la lambda que escribiste.

Una lambda es una de las pocas construcciones de C# cuya compilación depende de algo externo a la propia lambda. Escribe x => x + 1 y asígnalo a un Func<int, int> y obtienes un método estático oculto y un delegado; asigna el texto idéntico a un Expression<Func<int, int>> y obtienes algo completamente distinto — una secuencia anidada de llamadas a los métodos de fábrica de System.Linq.Expressions.Expression que construye una estructura de datos en tiempo de ejecución describiendo la lambda, nodo a nodo. No hay opcode de árbol de expresiones ni metadatos dedicados. El método decompilado es una página de llamadas a Expression.Parameter, Expression.Constant, Expression.Add y Expression.Lambda, con cada miembro que nombró el fuente alcanzado mediante ldtoken. Leer esa reducción explica qué puede plegar de vuelta a la línea original un decompilador como Glass.NET, por qué las variables capturadas aparecen como un Constant de un objeto de clausura, y por qué el proveedor de consultas al otro lado de un IQueryable tiene que trabajar de verdad para encontrar el valor de tu variable.

La misma lambda, dos compilaciones

Empieza por el hecho que lo decide todo. La conversión de una lambda la elige su tipo de destino, no su texto:

Func<int, int>             f = x => x + 1;   // delegate: a method plus a delegate object
Expression<Func<int, int>> e = x => x + 1;   // expression tree: a data structure

La primera línea compila como siempre lo hacen las lambdas y las clausuras — el cuerpo se convierte en un método <M>b__0_0 en la clase singleton <>c del compilador, y la asignación es un ldftn más un newobj de Func<int, int>, cacheado en un campo estático. El IL del cuerpo son tres instrucciones: ldarg.1, ldc.i4.1, add.

La segunda línea no produce ningún método oculto. El + nunca se emite como una instrucción add. En su lugar el compilador emite código que construye una descripción de la suma:

ParameterExpression x = Expression.Parameter(typeof(int), "x");
Expression<Func<int, int>> e = Expression.Lambda<Func<int, int>>(
    Expression.Add(x, Expression.Constant(1, typeof(int))),
    new ParameterExpression[] { x });

Esa es la reducción completa, y ya es lo que te mostrará un decompilador sin reconocimiento de árboles de expresiones. Tres cosas a observar: el nombre del parámetro "x" se conserva como literal de cadena, el literal 1 se encajona (box) en un object porque Expression.Constant recibe (object, Type), y el nodo del parámetro se crea una sola vez, se guarda en una local y se usa dos veces — una en el cuerpo y otra en el array de parámetros. En IL es una cadena de instrucciones call contra los métodos de fábrica estáticos, con typeof(int) escrito como ldtoken más Type.GetTypeFromHandle:

ldtoken    System.Int32
call       Type Type::GetTypeFromHandle(RuntimeTypeHandle)
ldstr      "x"
call       ParameterExpression Expression::Parameter(Type, string)
stloc.0
ldloc.0                                   // left operand: the parameter node
ldc.i4.1
box        System.Int32                   // Constant takes object
ldtoken    System.Int32
call       Type Type::GetTypeFromHandle(RuntimeTypeHandle)
call       ConstantExpression Expression::Constant(object, Type)
call       BinaryExpression Expression::Add(Expression, Expression)
ldc.i4.1
newarr     ParameterExpression
dup
ldc.i4.0
ldloc.0
stelem.ref
call       Expression`1<!!0> Expression::Lambda<Func`2<int32,int32>>(Expression, ParameterExpression[])

(Los calificadores de ensamblado se han omitido aquí por legibilidad; el IL real escribe [System.Linq.Expressions]System.Linq.Expressions.Expression cada vez, lo que explica en gran parte por qué la vista cruda de un método con árbol de expresiones es tan larga.)

Todo árbol de expresiones que decompiles tendrá este esqueleto: una llamada Parameter por parámetro de la lambda guardada en una local, un cuerpo construido de abajo arriba a partir de llamadas de fábrica que consumen la pila, y un Lambda<TDelegate> de cierre que recibe el cuerpo y un ParameterExpression[] explícito. El argumento de tipo genérico de esa última llamada es el tipo del delegado — es el único lugar del cuerpo donde la firma original Expression<Func<int, int>> es visible.

Los miembros se alcanzan por token, no por nombre

En cuanto la lambda toca una propiedad, un campo o un método, la reducción tiene que decir qué miembro, y lo hace de la única forma que el runtime puede garantizar exacta: un token de metadatos. Toma un filtro sobre un tipo con una propiedad:

Expression<Func<Customer, bool>> adults = c => c.Age >= 18;

El acceso a la propiedad se convierte en Expression.Property, y el PropertyInfo que necesita se obtiene a partir del token del método getter:

ParameterExpression c = Expression.Parameter(typeof(Customer), "c");
Expression<Func<Customer, bool>> adults = Expression.Lambda<Func<Customer, bool>>(
    Expression.GreaterThanOrEqual(
        Expression.Property(c, (MethodInfo)MethodBase.GetMethodFromHandle(/* ldtoken Customer::get_Age */)),
        Expression.Constant(18, typeof(int))),
    new ParameterExpression[] { c });

En IL la referencia al miembro es ldtoken method instance int32 Customer::get_Age() seguido de call MethodBase::GetMethodFromHandle(RuntimeMethodHandle) y un castclass MethodInfo. Los campos pasan por ldtoken field más FieldInfo.GetFieldFromHandle y Expression.Field. Los métodos pasan por el mismo par token-más-handle hacia Expression.Call. Cuando el miembro vive en un tipo genérico, el compilador usa la sobrecarga de dos argumentos GetMethodFromHandle(RuntimeMethodHandle, RuntimeTypeHandle) y pasa también el token del tipo declarante, porque un handle de método por sí solo no identifica a qué instanciación pertenece.

Este es el detalle que hace a los árboles de expresiones robustos y fáciles de decompilar al mismo tiempo. El árbol no contiene la cadena "Age"; contiene un token que resuelve exactamente al get_Age al que se enlazó el fuente, sobrecarga incluida. Un decompilador lee el token directamente del operando del ldtoken e imprime c.Age. Esos mismos tokens son los que un ofuscador de renombrado reescribe de forma consistente, y por eso los árboles de expresiones sobreviven al renombrado mientras que la reflexión por cadena no.

Los operadores que C# define sobre un tipo en lugar de sobre un primitivo llevan consigo su método de operador. Una comparación de cadenas es el caso habitual:

Expression<Func<Customer, bool>> named = c => c.Name == "Ada";

no se reduce al Expression.Equal de dos argumentos. Se reduce a la sobrecarga de cuatro argumentos con el operador definido por el usuario adjunto:

Expression.Equal(
    Expression.Property(c, /* get_Name */),
    Expression.Constant("Ada", typeof(string)),
    false,                                                   // liftToNull
    (MethodInfo)MethodBase.GetMethodFromHandle(/* ldtoken string::op_Equality */))

El MethodInfo es String.op_Equality(string, string) — el compilador registró qué == resolvió, para que un proveedor de consultas pueda distinguir la igualdad de cadenas de la igualdad de referencias. && y || se convierten en AndAlso y OrElse; los & y | sin cortocircuito se convierten en And y Or; ?: se convierte en Expression.Condition y ?? en Expression.Coalesce. Cada uno se corresponde exactamente con una pieza de sintaxis de C#, y un decompilador que conoce la tabla reconstruye el cuerpo nodo a nodo.

Variables capturadas: un Constant que no es constante

La reducción se vuelve realmente interesante cuando la lambda usa una variable del método que la contiene:

static Expression<Func<Customer, bool>> OlderThan(int min) => c => c.Age > min;

Para un delegado, min se elevaría a una display class y el cuerpo de la lambda la leería como un campo — el patrón de clausura familiar. Un árbol de expresiones necesita lo mismo, pero tiene que expresar «lee el campo min de este objeto de clausura concreto» como un nodo del árbol. El compilador lo hace con un Constant cuyo valor es la instancia de la clausura, y un acceso Field encima:

<>c__DisplayClass0_0 closure = new <>c__DisplayClass0_0();
closure.min = min;
ParameterExpression c = Expression.Parameter(typeof(Customer), "c");
return Expression.Lambda<Func<Customer, bool>>(
    Expression.GreaterThan(
        Expression.Property(c, /* get_Age */),
        Expression.Field(
            Expression.Constant(closure, typeof(<>c__DisplayClass0_0)),
            FieldInfo.GetFieldFromHandle(/* ldtoken <>c__DisplayClass0_0::min */))),
    new ParameterExpression[] { c });
Expression.Lambda<Func<Customer,bool>>raíz: cuerpo + parámetrosGreaterThanBinaryExpressionParameterExpression[]{ Parameter(Customer, "c") }Property(c, get_Age por ldtoken)Field(…, "min" por ldtoken)Constant(closure, <>c__DisplayClass0_0)el decompilador pliega ac => c.Age > mincampo de clausura → variable

De esta forma se siguen dos consecuencias, y ambas solo son visibles en la forma reducida. Primera, el árbol no contiene el valor de min; contiene una referencia al objeto de clausura y un campo del que leer. El valor se obtiene cuando el árbol se compila o se recorre, así que un árbol construido a partir de una local mutable ve las mutaciones posteriores exactamente igual que lo haría un delegado. Segunda, esta es precisamente la razón por la que todo proveedor de consultas LINQ tiene un paso que evalúa los subárboles Field-sobre-Constant antes de traducir una consulta: desde el punto de vista del proveedor, min no es un literal en el árbol, es un acceso a miembro sobre un objeto opaco, y el proveedor tiene que ejecutar esa subexpresión para descubrir 18 y convertirlo en un parámetro de consulta. Si alguna vez te has preguntado por qué una variable capturada se convierte en @p0 en el SQL generado mientras que un literal en línea se convierte en un literal, esta es la forma que hay detrás.

Para un decompilador el patrón es una huella. Expression.Field(Expression.Constant(x, typeof(T)), f) donde T es una display class generada por el compilador y x es una local de ese tipo colapsa al nombre de la variable capturada — el campo de la display class conserva el nombre original min, así que la lambda recuperada se lee c => c.Age > min incluso sin PDB. El new <>c__DisplayClass0_0() y el closure.min = min circundantes son la misma preparación de clausura que necesitaría un delegado, y se pliegan junto con ella.

Conversiones, aritmética checked y boxing

C# inserta conversiones implícitas en silencio; los árboles de expresiones no pueden, porque cada nodo tiene un tipo exacto y los métodos de fábrica lo validan. Así que una lambda que mezcla tipos crece con nodos Convert que el fuente nunca escribió:

Expression<Func<int, double>> half = x => x / 2.0;

se convierte en

Expression.Divide(
    Expression.Convert(x, typeof(double)),      // the implicit int -> double promotion, made explicit
    Expression.Constant(2.0, typeof(double)))

Lo mismo ocurre con el boxing — Expression<Func<int, object>> box = x => x; tiene un cuerpo de Expression.Convert(x, typeof(object)) — y con la elevación de nullables, donde un int? comparado con un literal recibe su literal envuelto en Expression.Convert(Expression.Constant(3, typeof(int)), typeof(int?)) para que ambos operandos de la comparación tengan el mismo tipo nullable. Un contexto checked cambia el método de fábrica en lugar de añadir un nodo: checked(x + 1) se reduce a Expression.AddChecked, y checked((byte)x) a Expression.ConvertChecked, de modo que el comportamiento ante desbordamiento que pediste viaja dentro del árbol como la elección del tipo de nodo.

Este es el primer lugar donde un decompilador tiene que tomar una decisión. Un Expression.Convert(x, typeof(double)) podría ser una conversión que escribió el autor, (double)x, o la promoción que añadió el compilador. Ambas lecturas recompilan al mismo árbol, así que un decompilador puede imprimir la conversión — fiel pero más ruidosa — o descartar las conversiones que C# reinsertaría de todos modos e imprimir x / 2.0. Glass.NET prefiere la forma más corta cuando es demostrablemente equivalente y conserva el Convert explícito cuando cambia el significado, como en una conversión de estrechamiento o un boxing a object del que depende una resolución de sobrecarga.

Lambdas anidadas y Expression.Quote

Los árboles de expresiones reales suelen ser fragmentos de consulta, y los fragmentos de consulta llaman a métodos que a su vez reciben lambdas. A qué se reduce la lambda interna depende, de nuevo, de su tipo de destino. Considera un filtro escrito sobre IQueryable:

Expression<Func<IQueryable<Customer>, IQueryable<Customer>>> active =
    q => q.Where(c => c.Active);

Queryable.Where recibe un Expression<Func<Customer, bool>>, así que la lambda interna debe convertirse a su vez en un árbol — y el árbol externo necesita llevar ese árbol interno como valor. El compilador reduce la lambda interna a su propio Expression.Lambda y la envuelve en Expression.Quote:

ParameterExpression q = Expression.Parameter(typeof(IQueryable<Customer>), "q");
ParameterExpression c = Expression.Parameter(typeof(Customer), "c");
Expression.Lambda<Func<IQueryable<Customer>, IQueryable<Customer>>>(
    Expression.Call(
        null,                                                             // static method
        (MethodInfo)MethodBase.GetMethodFromHandle(/* ldtoken Queryable::Where<Customer>(IQueryable<Customer>, Expression<Func<Customer,bool>>) */),
        new Expression[]
        {
            q,
            Expression.Quote(
                Expression.Lambda<Func<Customer, bool>>(
                    Expression.Property(c, /* get_Active */),
                    new ParameterExpression[] { c }))
        }),
    new ParameterExpression[] { q });

Observa que el ldtoken de Where nombra el método genérico construido Where<Customer>, así que el argumento de tipo que infirió el compilador queda registrado en el árbol y el decompilador puede dejarlo inferido en la salida. Observa también que la llamada estática pasa null como argumento de instancia — Roslyn usa sistemáticamente la sobrecarga Call(Expression, MethodInfo, Expression[]) tanto para llamadas de instancia como estáticas, lo que es una huella pequeña pero fiable de los árboles generados por el compilador frente a los construidos a mano.

Ahora cambia el fuente para que la secuencia sea un List<Customer>. La llamada se enlaza a Enumerable.Where, cuyo parámetro es un Func<Customer, bool> normal, y el Quote desaparece: el nodo interno Expression.Lambda<Func<Customer, bool>>(...) se pasa directamente como argumento. Un nodo LambdaExpression es del tipo del delegado, así que no hace falta envolverlo. La presencia o ausencia de Quote te dice por tanto, sin mirar la firma del método, si el llamado recibe la lambda interna como datos a traducir o como código a ejecutar — y un decompilador pliega ambas formas al mismo c => c.Active, porque eso es lo que decía el fuente en ambos casos.

Creación de objetos, inicializadores y tipos anónimos

Las proyecciones se reducen a una pequeña familia de fábricas de nodos que un decompilador tiene que reconocer como grupo. Un new con un inicializador de objeto se convierte en MemberInit sobre New, con un Bind por miembro asignado, cada uno referenciando el token del setter de la propiedad:

Expression<Func<Customer, Dto>> toDto = c => new Dto { Name = c.Name, Age = c.Age };
Expression.MemberInit(
    Expression.New((ConstructorInfo)MethodBase.GetMethodFromHandle(/* ldtoken Dto::.ctor() */)),
    new MemberBinding[]
    {
        Expression.Bind((MethodInfo)MethodBase.GetMethodFromHandle(/* set_Name */), Expression.Property(c, /* get_Name */)),
        Expression.Bind((MethodInfo)MethodBase.GetMethodFromHandle(/* set_Age  */), Expression.Property(c, /* get_Age  */))
    })

Los tipos anónimos siguen otro camino, porque no tienen propiedades asignables. c => new { c.Name, c.Age } se reduce a la sobrecarga de Expression.New que recibe el constructor, las expresiones de los argumentos y un array con los getters de las propiedades como miembros. Esa lista de miembros es cómo un proveedor de consultas empareja cada argumento del constructor con la propiedad que inicializa, y es también cómo un decompilador recupera la sintaxis new { Name = ..., Age = ... } — la clase de tipo anónimo generada en sí es simplemente el habitual <>f__AnonymousType0 con sus propiedades de solo lectura.

Lo que el árbol no puede decirte

El plegado de constantes se ejecuta antes de la reducción del árbol de expresiones, así que la aritmética sobre literales ha desaparecido cuando se construye el árbol. x => x * (60 * 60) se reduce a Expression.Multiply(x, Expression.Constant(3600, typeof(int))); ningún decompilador puede recuperar 60 * 60, porque el ensamblado compilado nunca lo contuvo. Lo mismo aplica a los campos const referenciados desde la lambda — se insertan en línea como nodos Constant con el valor, y el nombre de la constante se pierde.

El segundo límite lo impone el lenguaje, no el compilador. Una lambda de árbol de expresiones solo puede tener un cuerpo de expresión: sin sentencias, sin asignación, sin ?., sin coincidencia de patrones, sin dynamic, sin async, sin tuplas, sin funciones locales, y el compilador rechaza la conversión (CS0834 y compañía) en lugar de reducirlas. La consecuencia práctica para la decompilación es que un árbol generado por el compilador es siempre un nido puro de llamadas de fábrica sin flujo de control entre ellas — así que si ves ramas, bucles o la construcción de un nodo Block, estás viendo un árbol construido a mano, no una lambda reducida, y un decompilador correctamente lo deja como las llamadas de fábrica.

Qué pliega un decompilador, y qué muestra

Todo lo anterior se reduce a un pequeño conjunto de reglas. El Expression.Lambda<TDelegate>(body, new ParameterExpression[] { ... }) externo con parámetros creados por Expression.Parameter(typeof(T), "name") da la cabecera de la lambda, incluidos los nombres originales de los parámetros, que son literales de cadena en el IL. El cuerpo se pliega de abajo arriba: cada fábrica binaria y unaria se corresponde con un operador, Property/Field/Call imprimen el miembro al que resuelve su ldtoken, Constant imprime un literal, Field sobre un Constant de una display class imprime la variable capturada, Quote es transparente, MemberInit/Bind y el New de tres argumentos reconstruyen inicializadores y tipos anónimos, y Convert se descarta donde C# lo reinsertaría. Lo que queda ambiguo — un Convert que podría ser una conversión explícita, un Constant que podría haber sido una expresión aritmética, un Equal de cuatro argumentos cuyo método de operador es simplemente == en el fuente — se renderiza de la forma más corta que recompila al mismo árbol.

Eso es exactamente lo que hace Glass.NET cuando encuentra un método que construye un árbol: muestra la lambda cuando la forma es una reducción del compilador, y las llamadas de fábrica cuando no lo es. Ambas vistas merecen la pena. La lambda plegada te dice qué escribió el autor; la vista reducida te dice qué recibe realmente el proveedor de consultas al otro lado — el Constant de clausura que debe evaluar, los nodos Convert que debe traducir, el op_Equality que debe mapear a una comparación de cadenas, y el Quote que marca una subconsulta que tiene que traducir en lugar de ejecutar. Abre un ensamblado que construya consultas, elige un método que devuelva un Expression<...>, y alterna entre las vistas C# e IL para ver cómo una lambda de una línea se despliega en el árbol que el runtime recibe de verdad. Para el flujo de trabajo completo, consulta cómo decompilar una DLL de .NET a C#.

Prueba Nebula.NET

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