Skip to content
← Alle Beiträge
· Delta1 Labs Glass.NETDekompilierungC#

C#-Ausdrucksbäume dekompilieren: die Lambda, die zu einem Baum aus Factory-Aufrufen wurde

Dasselbe `x => x + 1` kompiliert auf zwei völlig verschiedene Arten, je nachdem, welchem Typ es zugewiesen wird. Als Delegat ist es eine versteckte Methode und ein ldftn; als Expression<Func<int, int>> ist es ein Geflecht aus Aufrufen von Expression.Parameter, Expression.Constant, Expression.Add und Expression.Lambda, in dem Member per ldtoken referenziert werden. Hier steht genau, was der Compiler für einen Ausdrucksbaum emittiert — erfasste Variablen als Constant einer Closure, Zeichenkettenvergleich mit seinem op_Equality, verschachtelte Lambdas in Quote verpackt — und wie ein Decompiler die Factory-Aufrufe zu der Lambda zurückfaltet, die Sie geschrieben haben.

Eine Lambda ist eines der wenigen Konstrukte in C#, deren Kompilierung von etwas außerhalb der Lambda abhängt. Schreiben Sie x => x + 1 und weisen Sie es einem Func<int, int> zu, erhalten Sie eine versteckte statische Methode und einen Delegaten; weisen Sie denselben Text einem Expression<Func<int, int>> zu, erhalten Sie etwas völlig anderes — eine verschachtelte Folge von Aufrufen der Factory-Methoden von System.Linq.Expressions.Expression, die zur Laufzeit eine Datenstruktur aufbaut, die die Lambda Knoten für Knoten beschreibt. Es gibt keinen Ausdrucksbaum-Opcode und keine eigenen Metadaten. Die dekompilierte Methode ist eine Seite voller Aufrufe von Expression.Parameter, Expression.Constant, Expression.Add und Expression.Lambda, wobei jedes im Quelltext benannte Member über ldtoken erreicht wird. Diese Absenkung zu lesen erklärt, was ein Decompiler wie Glass.NET zum Einzeiler zurückfalten kann, warum erfasste Variablen als Constant eines Closure-Objekts auftauchen, und warum der Abfrageanbieter am anderen Ende eines IQueryable echte Arbeit leisten muss, um den Wert Ihrer Variable zu finden.

Dieselbe Lambda, zwei Kompilierungen

Beginnen wir mit der Tatsache, die alles entscheidet. Die Konvertierung einer Lambda wird von ihrem Zieltyp bestimmt, nicht von ihrem Text:

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

Die erste Zeile kompiliert so, wie Lambdas und Closures es immer tun — der Rumpf wird zu einer Methode <M>b__0_0 auf der Singleton-Klasse <>c des Compilers, und die Zuweisung ist ein ldftn plus ein newobj von Func<int, int>, zwischengespeichert in einem statischen Feld. Das IL des Rumpfs besteht aus drei Instruktionen: ldarg.1, ldc.i4.1, add.

Die zweite Zeile erzeugt überhaupt keine versteckte Methode. Das + wird nie als add-Instruktion emittiert. Stattdessen emittiert der Compiler Code, der eine Beschreibung der Addition konstruiert:

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

Das ist die gesamte Absenkung, und es ist bereits das, was ein Decompiler ohne Ausdrucksbaum-Erkennung Ihnen zeigt. Drei Dinge fallen auf: Der Parametername "x" bleibt als Zeichenkettenliteral erhalten, das Literal 1 wird in ein object geboxt, weil Expression.Constant (object, Type) entgegennimmt, und der Parameterknoten wird einmal erzeugt, in einer Lokalen gespeichert und zweimal verwendet — einmal im Rumpf und einmal im Parameter-Array. Im IL ist es eine Kette von call-Instruktionen gegen die statischen Factory-Methoden, wobei typeof(int) als ldtoken plus Type.GetTypeFromHandle geschrieben wird:

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

(Die Assembly-Qualifizierer sind hier der Lesbarkeit halber weggelassen; das echte IL schreibt jedes Mal [System.Linq.Expressions]System.Linq.Expressions.Expression aus, was zu einem großen Teil erklärt, warum die Rohansicht einer Ausdrucksbaum-Methode so lang ist.)

Jeder Ausdrucksbaum, den Sie je dekompilieren werden, hat dieses Skelett: einen Parameter-Aufruf pro Lambda-Parameter, in einer Lokalen gespeichert, einen Rumpf, der von unten nach oben aus stackverbrauchenden Factory-Aufrufen gebaut wird, und ein abschließendes Lambda<TDelegate>, das den Rumpf und ein explizites ParameterExpression[] entgegennimmt. Das generische Typargument dieses letzten Aufrufs ist der Delegattyp — die einzige Stelle im Rumpf, an der die ursprüngliche Signatur Expression<Func<int, int>> sichtbar ist.

Member werden per Token erreicht, nicht per Name

Sobald die Lambda eine Eigenschaft, ein Feld oder eine Methode berührt, muss die Absenkung sagen, welches Member — und sie tut es auf die einzige Weise, deren Genauigkeit die Laufzeit garantieren kann: mit einem Metadaten-Token. Nehmen Sie einen Filter über einen Typ mit einer Eigenschaft:

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

Der Eigenschaftszugriff wird zu Expression.Property, und die benötigte PropertyInfo wird aus dem Methoden-Token des Getters gewonnen:

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

Im IL ist die Member-Referenz ldtoken method instance int32 Customer::get_Age(), gefolgt von call MethodBase::GetMethodFromHandle(RuntimeMethodHandle) und einem castclass MethodInfo. Felder gehen über ldtoken field plus FieldInfo.GetFieldFromHandle und Expression.Field. Methoden gehen über dasselbe Token-plus-Handle-Paar in Expression.Call. Lebt das Member auf einem generischen Typ, verwendet der Compiler die zweiargumentige Überladung GetMethodFromHandle(RuntimeMethodHandle, RuntimeTypeHandle) und übergibt zusätzlich das Token des deklarierenden Typs, weil ein Methoden-Handle allein nicht festlegt, zu welcher Instanziierung es gehört.

Das ist das Detail, das Ausdrucksbäume zugleich robust und leicht dekompilierbar macht. Der Baum enthält nicht die Zeichenkette "Age"; er enthält ein Token, das genau auf das get_Age auflöst, an das der Quelltext gebunden hat, Überladung inklusive. Ein Decompiler liest das Token direkt aus dem Operanden des ldtoken und druckt c.Age. Dieselben Tokens sind es, die ein umbenennender Obfuscator konsistent umschreibt — weshalb Ausdrucksbäume eine Umbenennung überstehen, Reflection über Zeichenketten aber nicht.

Operatoren, die C# auf einem Typ statt auf einem Primitiv definiert, nehmen ihre Operatormethode mit. Der Zeichenkettenvergleich ist der häufige Fall:

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

wird nicht zum zweiargumentigen Expression.Equal abgesenkt. Es wird zur vierargumentigen Überladung abgesenkt, mit angehängtem benutzerdefiniertem Operator:

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

Die MethodInfo ist String.op_Equality(string, string) — der Compiler hat festgehalten, welches == er aufgelöst hat, damit ein Abfrageanbieter Zeichenkettengleichheit von Referenzgleichheit unterscheiden kann. && und || werden zu AndAlso und OrElse; die nicht kurzschließenden & und | werden zu And und Or; ?: wird zu Expression.Condition und ?? zu Expression.Coalesce. Jedes entspricht genau einem Stück C#-Syntax, und ein Decompiler, der die Tabelle kennt, baut den Rumpf Knoten für Knoten wieder auf.

Erfasste Variablen: ein Constant, das nicht konstant ist

Richtig interessant wird die Absenkung, wenn die Lambda eine Variable der umgebenden Methode verwendet:

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

Bei einem Delegaten würde min in eine Display-Klasse gehoben und der Lambda-Rumpf würde es als Feld lesen — das vertraute Closure-Muster. Ein Ausdrucksbaum braucht dasselbe, muss aber „lies das Feld min aus genau diesem Closure-Objekt” als Baumknoten ausdrücken. Der Compiler tut das mit einem Constant, dessen Wert die Closure-Instanz ist, und einem Field-Zugriff darauf:

<>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>>Wurzel: Rumpf + ParameterGreaterThanBinaryExpressionParameterExpression[]{ Parameter(Customer, "c") }Property(c, get_Age per ldtoken)Field(…, "min" per ldtoken)Constant(closure, <>c__DisplayClass0_0)Decompiler faltet zuc => c.Age > minClosure-Feld → Variablenname

Aus dieser Form folgen zwei Konsequenzen, und beide sind nur in der abgesenkten Form sichtbar. Erstens enthält der Baum nicht den Wert von min; er enthält eine Referenz auf das Closure-Objekt und ein Feld, aus dem gelesen wird. Der Wert wird geholt, wenn der Baum kompiliert oder durchlaufen wird, sodass ein aus einer veränderlichen Lokalen gebauter Baum spätere Änderungen genauso sieht, wie ein Delegat es täte. Zweitens ist das genau der Grund, warum jeder LINQ-Abfrageanbieter einen Schritt hat, der Field-auf-Constant-Teilbäume auswertet, bevor er eine Abfrage übersetzt: Aus Sicht des Anbieters ist min kein Literal im Baum, sondern ein Memberzugriff auf ein undurchsichtiges Objekt, und der Anbieter muss diesen Teilausdruck ausführen, um 18 zu entdecken und daraus einen Abfrageparameter zu machen. Falls Sie sich je gefragt haben, warum eine erfasste Variable im generierten SQL zu @p0 wird, während ein Inline-Literal ein Literal bleibt — das ist die Form dahinter.

Für einen Decompiler ist das Muster ein Fingerabdruck. Expression.Field(Expression.Constant(x, typeof(T)), f), wobei T eine vom Compiler generierte Display-Klasse und x eine Lokale dieses Typs ist, kollabiert zum Namen der erfassten Variable — das Feld der Display-Klasse behält den ursprünglichen Namen min, sodass die wiederhergestellte Lambda auch ohne PDB c => c.Age > min lautet. Das umgebende new <>c__DisplayClass0_0() und closure.min = min sind dieselbe Closure-Vorbereitung, die ein Delegat bräuchte, und werden mit ihr weggefaltet.

Konvertierungen, checked-Arithmetik und Boxing

C# fügt implizite Konvertierungen stillschweigend ein; Ausdrucksbäume können das nicht, weil jeder Knoten einen exakten Typ hat und die Factory-Methoden ihn validieren. Eine Lambda, die Typen mischt, bekommt also Convert-Knoten, die der Quelltext nie ausgeschrieben hat:

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

wird zu

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

Dasselbe passiert beim Boxing — Expression<Func<int, object>> box = x => x; hat den Rumpf Expression.Convert(x, typeof(object)) — und beim Nullable-Lifting, wo ein mit einem Literal verglichenes int? sein Literal in Expression.Convert(Expression.Constant(3, typeof(int)), typeof(int?)) eingepackt bekommt, damit beide Operanden des Vergleichs denselben Nullable-Typ haben. Ein checked-Kontext ändert die Factory-Methode, statt einen Knoten hinzuzufügen: checked(x + 1) wird zu Expression.AddChecked und checked((byte)x) zu Expression.ConvertChecked abgesenkt, sodass das gewünschte Überlaufverhalten als Wahl des Knotentyps im Baum mitreist.

Hier muss ein Decompiler zum ersten Mal eine Entscheidung treffen. Ein Expression.Convert(x, typeof(double)) könnte ein vom Autor geschriebener Cast sein, (double)x, oder die vom Compiler ergänzte Promotion. Beide Lesarten kompilieren zum selben Baum zurück, also kann ein Decompiler entweder den Cast ausgeben — getreu, aber lauter — oder Konvertierungen fallen lassen, die C# ohnehin wieder einfügen würde, und x / 2.0 drucken. Glass.NET bevorzugt die kürzere Form, wo sie beweisbar äquivalent ist, und behält das explizite Convert, wo es die Bedeutung ändert, etwa bei einem einengenden Cast oder einem Boxing nach object, von dem eine Überladungsauflösung abhängt.

Verschachtelte Lambdas und Expression.Quote

Echte Ausdrucksbäume sind meist Abfragefragmente, und Abfragefragmente rufen Methoden auf, die selbst Lambdas entgegennehmen. Wozu die innere Lambda abgesenkt wird, hängt wieder von ihrem Zieltyp ab. Betrachten Sie einen über IQueryable geschriebenen Filter:

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

Queryable.Where nimmt ein Expression<Func<Customer, bool>> entgegen, also muss die innere Lambda selbst zu einem Baum werden — und der äußere Baum muss diesen inneren Baum als Wert transportieren. Der Compiler senkt die innere Lambda zu ihrem eigenen Expression.Lambda ab und verpackt sie in 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 });

Beachten Sie, dass das ldtoken für Where die konstruierte generische Methode Where<Customer> benennt, sodass das vom Compiler inferierte Typargument im Baum festgehalten ist und der Decompiler es in der Ausgabe inferiert lassen kann. Beachten Sie außerdem, dass der statische Aufruf null als Instanzargument übergibt — Roslyn verwendet für Instanz- wie für statische Aufrufe durchgängig die Überladung Call(Expression, MethodInfo, Expression[]), ein kleiner, aber verlässlicher Fingerabdruck, der compilergenerierte von handgebauten Bäumen unterscheidet.

Ändern Sie nun den Quelltext so, dass die Sequenz eine List<Customer> ist. Der Aufruf bindet an Enumerable.Where, dessen Parameter ein gewöhnliches Func<Customer, bool> ist, und das Quote verschwindet: Der innere Knoten Expression.Lambda<Func<Customer, bool>>(...) wird direkt als Argument übergeben. Ein LambdaExpression-Knoten ist vom Delegattyp, eine Hülle ist also nicht nötig. Ob Quote vorhanden ist oder nicht, sagt Ihnen daher — ohne Blick auf die Methodensignatur — ob der Aufgerufene die innere Lambda als zu übersetzende Daten oder als auszuführenden Code erhält; und ein Decompiler faltet beide Formen zum selben c => c.Active, weil der Quelltext in beiden Fällen genau das gesagt hat.

Objekterzeugung, Initialisierer und anonyme Typen

Projektionen werden zu einer kleinen Familie von Knoten-Factories abgesenkt, die ein Decompiler als Gruppe erkennen muss. Ein new mit Objektinitialisierer wird zu MemberInit über New, mit einem Bind pro zugewiesenem Member, das jeweils das Token des Setters der Eigenschaft referenziert:

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  */))
    })

Anonyme Typen gehen einen anderen Weg, weil sie keine setzbaren Eigenschaften haben. c => new { c.Name, c.Age } wird zu der Überladung von Expression.New abgesenkt, die den Konstruktor, die Argumentausdrücke und ein Array der Eigenschafts-Getter als Member entgegennimmt. Über diese Memberliste ordnet ein Abfrageanbieter jedes Konstruktorargument der Eigenschaft zu, die es initialisiert, und über sie stellt ein Decompiler die Syntax new { Name = ..., Age = ... } wieder her — die generierte Klasse des anonymen Typs selbst ist nur das übliche <>f__AnonymousType0 mit seinen schreibgeschützten Eigenschaften.

Was der Baum Ihnen nicht sagen kann

Die Konstantenfaltung läuft vor der Absenkung des Ausdrucksbaums, also ist Arithmetik auf Literalen verschwunden, wenn der Baum gebaut wird. x => x * (60 * 60) wird zu Expression.Multiply(x, Expression.Constant(3600, typeof(int))) abgesenkt; kein Decompiler kann 60 * 60 wiederherstellen, weil die kompilierte Assembly es nie enthalten hat. Dasselbe gilt für const-Felder, die aus der Lambda referenziert werden — sie werden als Constant-Knoten mit dem Wert eingebettet, und der Name der Konstante geht verloren.

Die zweite Grenze setzt die Sprache, nicht der Compiler. Eine Ausdrucksbaum-Lambda darf nur einen Ausdrucksrumpf haben: keine Anweisungen, keine Zuweisung, kein ?., kein Pattern Matching, kein dynamic, kein async, keine Tupel, keine lokalen Funktionen — der Compiler lehnt die Konvertierung ab (CS0834 und Verwandte), statt sie abzusenken. Die praktische Konsequenz für die Dekompilierung: Ein compilergenerierter Baum ist immer ein reines Geflecht aus Factory-Aufrufen ohne Kontrollfluss dazwischen. Sehen Sie also Verzweigungen, Schleifen oder den Bau eines Block-Knotens, schauen Sie auf einen handgebauten Baum, nicht auf eine abgesenkte Lambda, und ein Decompiler belässt ihn zu Recht als Factory-Aufrufe.

Was ein Decompiler faltet und was er zeigt

Alles Obige reduziert sich auf eine kleine Menge von Regeln. Das äußere Expression.Lambda<TDelegate>(body, new ParameterExpression[] { ... }) mit Parametern, die per Expression.Parameter(typeof(T), "name") erzeugt wurden, liefert den Lambda-Kopf, einschließlich der ursprünglichen Parameternamen, die im IL Zeichenkettenliterale sind. Der Rumpf faltet sich von unten nach oben: Jede binäre und unäre Factory entspricht einem Operator, Property/Field/Call drucken das Member, auf das ihr ldtoken auflöst, Constant druckt ein Literal, Field auf einem Constant einer Display-Klasse druckt die erfasste Variable, Quote ist transparent, MemberInit/Bind und das dreiargumentige New bauen Initialisierer und anonyme Typen wieder auf, und Convert entfällt dort, wo C# es wieder einfügen würde. Was mehrdeutig bleibt — ein Convert, das ein expliziter Cast sein könnte, ein Constant, das ein arithmetischer Ausdruck gewesen sein könnte, ein vierargumentiges Equal, dessen Operatormethode im Quelltext bloß == ist — wird in der kürzesten Form gerendert, die zum selben Baum zurückkompiliert.

Genau das tut Glass.NET, wenn es auf eine Methode trifft, die einen Baum baut: Es zeigt die Lambda, wenn die Form eine Compiler-Absenkung ist, und die Factory-Aufrufe, wenn nicht. Beide Ansichten lohnen sich. Die gefaltete Lambda sagt Ihnen, was der Autor geschrieben hat; die abgesenkte Ansicht sagt Ihnen, was der Abfrageanbieter am anderen Ende tatsächlich bekommt — das Closure-Constant, das er auswerten muss, die Convert-Knoten, die er übersetzen muss, das op_Equality, das er auf einen Zeichenkettenvergleich abbilden muss, und das Quote, das eine Unterabfrage markiert, die er übersetzen statt ausführen muss. Öffnen Sie eine Assembly, die Abfragen baut, wählen Sie eine Methode, die ein Expression<...> zurückgibt, und wechseln Sie zwischen C#- und IL-Ansicht, um zuzusehen, wie sich eine einzeilige Lambda in den Baum entfaltet, den die Laufzeit wirklich erhält. Den vollständigen Arbeitsablauf finden Sie unter wie man eine .NET-DLL nach C# dekompiliert.

Nebula.NET testen

Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.