Décompiler les arbres d'expressions C# : la lambda devenue un arbre d'appels de fabrique
Le même `x => x + 1` compile de deux façons complètement différentes selon le type auquel il est affecté. Comme délégué, c'est une méthode cachée et un ldftn ; comme Expression<Func<int, int>>, c'est un emboîtement d'appels à Expression.Parameter, Expression.Constant, Expression.Add et Expression.Lambda, avec les membres référencés par ldtoken. Voici exactement ce que le compilateur émet pour un arbre d'expressions — variables capturées sous forme de Constant d'une fermeture, égalité de chaînes avec son op_Equality, lambdas imbriquées enveloppées dans Quote — et comment un décompilateur replie les appels de fabrique vers la lambda que vous avez écrite.
Une lambda est l’une des rares constructions de C# dont la compilation dépend de quelque chose d’extérieur à la lambda. Écrivez x => x + 1 et affectez-le à un Func<int, int> : vous obtenez une méthode statique cachée et un délégué ; affectez le texte identique à un Expression<Func<int, int>> et vous obtenez tout autre chose — une séquence imbriquée d’appels aux méthodes de fabrique de System.Linq.Expressions.Expression qui construit, à l’exécution, une structure de données décrivant la lambda nœud par nœud. Il n’y a ni opcode d’arbre d’expressions ni métadonnées dédiées. La méthode décompilée est une page d’appels à Expression.Parameter, Expression.Constant, Expression.Add et Expression.Lambda, chaque membre nommé par la source étant atteint via ldtoken. Lire cet abaissement explique ce qu’un décompilateur comme Glass.NET peut replier vers la ligne d’origine, pourquoi les variables capturées apparaissent comme un Constant d’un objet de fermeture, et pourquoi le fournisseur de requêtes à l’autre bout d’un IQueryable doit réellement travailler pour trouver la valeur de votre variable.
Même lambda, deux compilations
Commençons par le fait qui décide de tout. La conversion d’une lambda est choisie par son type cible, pas par son texte :
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 première ligne compile comme les lambdas et les fermetures le font toujours — le corps devient une méthode <M>b__0_0 sur la classe singleton <>c du compilateur, et l’affectation est un ldftn plus un newobj de Func<int, int>, mis en cache dans un champ statique. L’IL du corps tient en trois instructions : ldarg.1, ldc.i4.1, add.
La seconde ligne ne produit aucune méthode cachée. Le + n’est jamais émis comme une instruction add. Le compilateur émet à la place du code qui construit une description de l’addition :
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 });
C’est tout l’abaissement, et c’est déjà ce qu’un décompilateur sans reconnaissance des arbres d’expressions vous montrera. Trois choses à remarquer : le nom du paramètre "x" est conservé comme littéral de chaîne, le littéral 1 est encadré (boxed) en object parce qu’Expression.Constant prend (object, Type), et le nœud de paramètre est créé une seule fois, stocké dans une locale et utilisé deux fois — une fois dans le corps et une fois dans le tableau des paramètres. En IL, c’est une chaîne d’instructions call vers les méthodes de fabrique statiques, typeof(int) s’écrivant ldtoken plus 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[])
(Les qualificateurs d’assembly sont omis ici pour la lisibilité ; l’IL réel écrit [System.Linq.Expressions]System.Linq.Expressions.Expression à chaque fois, ce qui explique en grande partie pourquoi la vue brute d’une méthode à arbre d’expressions est si longue.)
Tout arbre d’expressions que vous décompilerez a ce squelette : un appel Parameter par paramètre de la lambda, stocké dans une locale, un corps construit de bas en haut à partir d’appels de fabrique qui consomment la pile, et un Lambda<TDelegate> de clôture qui prend le corps et un ParameterExpression[] explicite. L’argument de type générique de ce dernier appel est le type du délégué — c’est le seul endroit du corps où la signature d’origine Expression<Func<int, int>> est visible.
Les membres sont atteints par jeton, pas par nom
Dès que la lambda touche une propriété, un champ ou une méthode, l’abaissement doit dire quel membre, et il le fait de la seule façon dont le runtime peut garantir l’exactitude : un jeton de métadonnées. Prenez un filtre sur un type qui possède une propriété :
Expression<Func<Customer, bool>> adults = c => c.Age >= 18;
L’accès à la propriété devient Expression.Property, et le PropertyInfo dont il a besoin est obtenu à partir du jeton de la méthode 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 référence au membre est ldtoken method instance int32 Customer::get_Age() suivi de call MethodBase::GetMethodFromHandle(RuntimeMethodHandle) et d’un castclass MethodInfo. Les champs passent par ldtoken field plus FieldInfo.GetFieldFromHandle et Expression.Field. Les méthodes passent par la même paire jeton-plus-handle vers Expression.Call. Quand le membre vit sur un type générique, le compilateur utilise la surcharge à deux arguments GetMethodFromHandle(RuntimeMethodHandle, RuntimeTypeHandle) et passe aussi le jeton du type déclarant, parce qu’un handle de méthode seul n’identifie pas l’instanciation à laquelle il appartient.
C’est le détail qui rend les arbres d’expressions à la fois robustes et faciles à décompiler. L’arbre ne contient pas la chaîne "Age" ; il contient un jeton qui se résout exactement vers le get_Age auquel la source s’est liée, surcharge comprise. Un décompilateur lit le jeton directement dans l’opérande du ldtoken et imprime c.Age. Ces mêmes jetons sont ceux qu’un obfuscateur de renommage réécrit de façon cohérente, ce qui explique pourquoi les arbres d’expressions survivent au renommage alors que la réflexion par chaîne n’y survit pas.
Les opérateurs que C# définit sur un type plutôt que sur un primitif emportent leur méthode d’opérateur avec eux. La comparaison de chaînes est le cas courant :
Expression<Func<Customer, bool>> named = c => c.Name == "Ada";
ne s’abaisse pas vers l’Expression.Equal à deux arguments. Il s’abaisse vers la surcharge à quatre arguments, avec l’opérateur défini par l’utilisateur attaché :
Expression.Equal(
Expression.Property(c, /* get_Name */),
Expression.Constant("Ada", typeof(string)),
false, // liftToNull
(MethodInfo)MethodBase.GetMethodFromHandle(/* ldtoken string::op_Equality */))
Le MethodInfo est String.op_Equality(string, string) — le compilateur a enregistré quel == il a résolu, pour qu’un fournisseur de requêtes puisse distinguer l’égalité de chaînes de l’égalité de références. && et || deviennent AndAlso et OrElse ; les & et | sans court-circuit deviennent And et Or ; ?: devient Expression.Condition et ?? devient Expression.Coalesce. Chacun correspond exactement à un élément de syntaxe C#, et un décompilateur qui connaît la table reconstruit le corps nœud par nœud.
Variables capturées : un Constant qui n’est pas constant
L’abaissement devient vraiment intéressant quand la lambda utilise une variable de la méthode englobante :
static Expression<Func<Customer, bool>> OlderThan(int min) => c => c.Age > min;
Pour un délégué, min serait hissé dans une display class et le corps de la lambda le lirait comme un champ — le motif de fermeture habituel. Un arbre d’expressions a besoin de la même chose, mais il doit exprimer « lis le champ min de cet objet de fermeture précis » sous forme de nœud d’arbre. Le compilateur le fait avec un Constant dont la valeur est l’instance de la fermeture, et un accès Field par-dessus :
<>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 });
Deux conséquences découlent de cette forme, et toutes deux ne sont visibles que dans la forme abaissée. D’abord, l’arbre ne contient pas la valeur de min ; il contient une référence à l’objet de fermeture et un champ à y lire. La valeur est récupérée quand l’arbre est compilé ou parcouru, de sorte qu’un arbre construit à partir d’une locale mutable voit les mutations ultérieures exactement comme le ferait un délégué. Ensuite, c’est précisément la raison pour laquelle tout fournisseur de requêtes LINQ a une étape qui évalue les sous-arbres Field-sur-Constant avant de traduire une requête : du point de vue du fournisseur, min n’est pas un littéral dans l’arbre, c’est un accès à un membre sur un objet opaque, et le fournisseur doit exécuter cette sous-expression pour découvrir 18 et le transformer en paramètre de requête. Si vous vous êtes déjà demandé pourquoi une variable capturée devient @p0 dans le SQL généré alors qu’un littéral en ligne devient un littéral, voilà la forme qui se cache derrière.
Pour un décompilateur, le motif est une empreinte. Expression.Field(Expression.Constant(x, typeof(T)), f) où T est une display class générée par le compilateur et x une locale de ce type se replie vers le nom de la variable capturée — le champ de la display class conserve le nom d’origine min, de sorte que la lambda récupérée se lit c => c.Age > min même sans PDB. Le new <>c__DisplayClass0_0() et le closure.min = min environnants sont la même mise en place de fermeture dont un délégué aurait besoin, et sont repliés avec elle.
Conversions, arithmétique checked et boxing
C# insère les conversions implicites en silence ; les arbres d’expressions ne le peuvent pas, parce que chaque nœud a un type exact et que les méthodes de fabrique le valident. Une lambda qui mélange les types se couvre donc de nœuds Convert que la source n’a jamais écrits :
Expression<Func<int, double>> half = x => x / 2.0;
devient
Expression.Divide(
Expression.Convert(x, typeof(double)), // the implicit int -> double promotion, made explicit
Expression.Constant(2.0, typeof(double)))
Il en va de même pour le boxing — Expression<Func<int, object>> box = x => x; a pour corps Expression.Convert(x, typeof(object)) — et pour le relèvement des nullables, où un int? comparé à un littéral voit son littéral enveloppé dans Expression.Convert(Expression.Constant(3, typeof(int)), typeof(int?)) pour que les deux opérandes de la comparaison aient le même type nullable. Un contexte checked change la méthode de fabrique plutôt que d’ajouter un nœud : checked(x + 1) s’abaisse vers Expression.AddChecked, et checked((byte)x) vers Expression.ConvertChecked, de sorte que le comportement en cas de dépassement que vous avez demandé voyage dans l’arbre sous la forme du choix du type de nœud.
C’est le premier endroit où un décompilateur doit trancher. Un Expression.Convert(x, typeof(double)) peut être un cast écrit par l’auteur, (double)x, ou la promotion ajoutée par le compilateur. Les deux lectures recompilent vers le même arbre, donc un décompilateur peut soit imprimer le cast — fidèle mais plus bruyant — soit abandonner les conversions que C# réinsérerait de toute façon et imprimer x / 2.0. Glass.NET préfère la forme courte quand elle est prouvablement équivalente et conserve le Convert explicite quand il change le sens, comme un cast rétrécissant ou un boxing en object dont dépend une résolution de surcharge.
Lambdas imbriquées et Expression.Quote
Les vrais arbres d’expressions sont généralement des fragments de requête, et les fragments de requête appellent des méthodes qui prennent elles-mêmes des lambdas. Ce vers quoi la lambda interne s’abaisse dépend, encore une fois, de son type cible. Considérez un filtre écrit sur IQueryable :
Expression<Func<IQueryable<Customer>, IQueryable<Customer>>> active =
q => q.Where(c => c.Active);
Queryable.Where prend un Expression<Func<Customer, bool>>, donc la lambda interne doit elle-même devenir un arbre — et l’arbre externe doit transporter cet arbre interne comme valeur. Le compilateur abaisse la lambda interne vers son propre Expression.Lambda et l’enveloppe dans 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 });
Remarquez que le ldtoken de Where nomme la méthode générique construite Where<Customer>, de sorte que l’argument de type inféré par le compilateur est enregistré dans l’arbre et que le décompilateur peut le laisser inféré dans la sortie. Remarquez aussi que l’appel statique passe null comme argument d’instance — Roslyn utilise systématiquement la surcharge Call(Expression, MethodInfo, Expression[]) pour les appels d’instance comme statiques, ce qui est une empreinte petite mais fiable distinguant les arbres générés par le compilateur des arbres construits à la main.
Changez maintenant la source pour que la séquence soit un List<Customer>. L’appel se lie à Enumerable.Where, dont le paramètre est un simple Func<Customer, bool>, et le Quote disparaît : le nœud interne Expression.Lambda<Func<Customer, bool>>(...) est passé directement comme argument. Un nœud LambdaExpression est du type du délégué, donc aucun enveloppement n’est nécessaire. La présence ou l’absence de Quote vous dit donc, sans regarder la signature de la méthode, si l’appelé reçoit la lambda interne comme donnée à traduire ou comme code à exécuter — et un décompilateur replie les deux formes vers le même c => c.Active, parce que c’est ce que disait la source dans les deux cas.
Création d’objets, initialiseurs et types anonymes
Les projections s’abaissent vers une petite famille de fabriques de nœuds qu’un décompilateur doit reconnaître comme un groupe. Un new avec initialiseur d’objet devient un MemberInit sur un New, avec un Bind par membre affecté, chacun référençant le jeton du setter de la propriété :
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 */))
})
Les types anonymes empruntent une autre voie, parce qu’ils n’ont pas de propriétés affectables. c => new { c.Name, c.Age } s’abaisse vers la surcharge d’Expression.New qui prend le constructeur, les expressions des arguments et un tableau des getters des propriétés comme membres. Cette liste de membres est la façon dont un fournisseur de requêtes associe chaque argument du constructeur à la propriété qu’il initialise, et c’est aussi la façon dont un décompilateur récupère la syntaxe new { Name = ..., Age = ... } — la classe de type anonyme générée elle-même n’est que l’habituel <>f__AnonymousType0 avec ses propriétés en lecture seule.
Ce que l’arbre ne peut pas vous dire
Le repliement des constantes a lieu avant l’abaissement de l’arbre d’expressions, donc l’arithmétique sur les littéraux a disparu au moment où l’arbre est construit. x => x * (60 * 60) s’abaisse vers Expression.Multiply(x, Expression.Constant(3600, typeof(int))) ; aucun décompilateur ne peut récupérer 60 * 60, parce que l’assembly compilé ne l’a jamais contenu. Il en va de même pour les champs const référencés depuis la lambda — ils sont inlinés comme des nœuds Constant avec la valeur, et le nom de la constante est perdu.
La seconde limite est imposée par le langage plutôt que par le compilateur. Une lambda d’arbre d’expressions ne peut avoir qu’un corps d’expression : pas d’instructions, pas d’affectation, pas de ?., pas de filtrage par motif, pas de dynamic, pas d’async, pas de tuples, pas de fonctions locales, et le compilateur refuse la conversion (CS0834 et consorts) plutôt que de les abaisser. La conséquence pratique pour la décompilation est qu’un arbre généré par le compilateur est toujours un pur emboîtement d’appels de fabrique sans flux de contrôle entre eux — donc si vous voyez des branchements, des boucles ou la construction d’un nœud Block, vous regardez un arbre construit à la main, pas une lambda abaissée, et un décompilateur le laisse à juste titre sous forme d’appels de fabrique.
Ce qu’un décompilateur replie, et ce qu’il montre
Tout ce qui précède se réduit à un petit ensemble de règles. L’Expression.Lambda<TDelegate>(body, new ParameterExpression[] { ... }) externe avec des paramètres créés par Expression.Parameter(typeof(T), "name") donne l’en-tête de la lambda, y compris les noms d’origine des paramètres, qui sont des littéraux de chaîne dans l’IL. Le corps se replie de bas en haut : chaque fabrique binaire et unaire correspond à un opérateur, Property/Field/Call impriment le membre vers lequel leur ldtoken se résout, Constant imprime un littéral, Field sur un Constant d’une display class imprime la variable capturée, Quote est transparent, MemberInit/Bind et le New à trois arguments reconstruisent les initialiseurs et les types anonymes, et Convert est abandonné là où C# le réinsérerait. Ce qui reste ambigu — un Convert qui pourrait être un cast explicite, un Constant qui a pu être une expression arithmétique, un Equal à quatre arguments dont la méthode d’opérateur n’est que == dans la source — est rendu de la façon la plus courte qui recompile vers le même arbre.
C’est exactement ce que fait Glass.NET quand il rencontre une méthode qui construit un arbre : il montre la lambda quand la forme est un abaissement du compilateur, et les appels de fabrique quand ce n’en est pas un. Les deux vues ont leur valeur. La lambda repliée vous dit ce que l’auteur a écrit ; la vue abaissée vous dit ce que le fournisseur de requêtes à l’autre bout reçoit réellement — le Constant de fermeture qu’il doit évaluer, les nœuds Convert qu’il doit traduire, l’op_Equality qu’il doit mapper vers une comparaison de chaînes, et le Quote qui marque une sous-requête qu’il doit traduire plutôt qu’exécuter. Ouvrez un assembly qui construit des requêtes, choisissez une méthode qui renvoie un Expression<...>, et basculez entre les vues C# et IL pour voir une lambda d’une ligne se déplier en l’arbre que le runtime reçoit vraiment. Pour le flux de travail complet, voir comment décompiler une DLL .NET en C#.
Essayez Nebula.NET
Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.