Das dynamic-Schlüsselwort dekompilieren: Call-Site-IL in Glass.NET lesen
Eine Zeile C# mit dynamic kompiliert zu einer gecachten Call-Site, einem Binder, einem Array von Argument-Info-Flags und einem Aufruf über ein Delegate-Feld — ein Dutzend Member generiertes Gerüst für einen einzigen Methodenaufruf. Hier ist, was der Compiler für dynamischen Dispatch tatsächlich ausgibt, wie ein naiver Decompiler Ihnen die rohe CallSite-Maschinerie zeigt und wie Glass.NET das Muster erkennt und Ihnen die eine Zeile zurückgibt, die Sie geschrieben haben.
Schreiben Sie eine Zeile C# mit dynamic darin, und der Compiler schreibt in Ihrem Namen ein kleines Programm. Es gibt kein callvirt auf eine Methode namens Add; es gibt eine gecachte Call-Site, einen Binder, der beschreibt, was „Add aufrufen” bedeutet, ein Array von Flags, das jedes Argument beschreibt, und einen Aufruf über ein Delegate, das in einem generierten Feld gespeichert ist. Ein Decompiler muss dieses gesamte Gerüst sehen und verstehen, dass es sich zu dem einen Ausdruck summiert, den Sie getippt haben — sonst reicht er Ihnen das Gerüst und lässt Sie Ihren eigenen Quellcode zurückentwickeln.
Dies ist ein praktischer Deep-Dive genau dazu: was der C#-Compiler für dynamic-Dispatch ausgibt, wie das rohe Lowering aussieht, wenn nichts es zurückfaltet, und wie Glass.NET das Call-Site-Muster erkennt und das dynamic rekonstruiert, mit dem Sie begonnen haben. Es ist eine der klareren Demonstrationen dafür, warum „es dekompiliert” und „es dekompiliert zu etwas, das Sie lesen können” unterschiedliche Behauptungen sind.
Der Quellcode
Hier ist das ganze Programm. Drei dynamische Operationen: ein Memberaufruf, ein binärer Operator und eine implizite Konvertierung eines dynamic-Ergebnisses zurück in eine typisierte lokale Variable.
public static class Demo
{
public static int Run(dynamic a, dynamic b)
{
dynamic sum = a.Add(b); // dynamischer Memberaufruf
dynamic twice = sum + sum; // dynamischer binärer Operator
int result = twice; // Konvertierung dynamic -> int
return result;
}
}
Nichts Exotisches. Aber keine dieser drei Operationen kann zur Kompilierzeit aufgelöst werden, weil der Compiler nicht weiß, was a und b sind. Statt Aufrufe auszugeben, gibt er also die Maschinerie aus, um sie später aufzulösen, bei der ersten Verwendung, gegen die Laufzeittypen, die tatsächlich auftauchen.
Was der Compiler tatsächlich ausgibt
Für jede dynamische Operation generiert der Compiler eine gecachte Call-Site: ein statisches Feld vom Typ CallSite<T>, das in einer versteckten Klasse (mit Namen wie <>o__0) lebt, wobei T die Delegate-Signatur der Operation ist. Beim ersten Ausführen der Operation initialisiert eine Null-Prüfung die Site verzögert aus einem CallSiteBinder, den eine Factory in Microsoft.CSharp.RuntimeBinder.Binder baut. Dem Binder werden eine Menge von CSharpBinderFlags und ein CSharpArgumentInfo pro Argument übergeben, die beschreiben, wie jeder Wert teilnimmt (ist er ein ref, ist er eine Kompilierzeitkonstante, ist er das Ziel der Operation). Dann wird das Target-Delegate der Site mit der Site und den Argumenten aufgerufen.
Dekompiliert ohne das Muster zu erkennen — was Sie von einem Viewer bekommen, der nur die offensichtlichen Transformationen rückgängig macht — kommt der Memberaufruf a.Add(b) so zurück:
// Rohes Lowering von: 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);
Das ist dem IL treu und fast nutzlos zu lesen. Jede dynamische Operation multipliziert sich auf dieselbe Weise aus — das + wird zu einer Binder.BinaryOperation-Site mit einem ExpressionType.Add, und das int result = twice; wird zu einer Binder.Convert-Site, die auf typeof(int) zielt. Eine dreizeilige Methode bläht sich zu drei Cache-Klassen, drei Site-Feldern, drei Lazy-Initializern und drei .Target(...)-Aufrufen auf. Die Form ist vollkommen mechanisch, und genau deshalb kann sie erkannt und rückgängig gemacht werden.
Wie Glass es zurückfaltet
Glass baut auf ICSharpCode.Decompiler auf, derselben Engine hinter ILSpy, und das Rekonstruieren von dynamic ist eine ihrer Mustertransformationen. Der Decompiler erkennt die vollständige Call-Site-Form — den <>o__-/<>p__-Cache, die durch die Null-Prüfung abgesicherte verzögerte Initialisierung, den Binder.*-Factory-Aufruf und den passenden .Target(...)-Aufruf — als eine einzige logische Operation, liest den Binder, um zu erfahren, welche Operation es ist (InvokeMember "Add", BinaryOperation Add, Convert int), liest das CSharpArgumentInfo-Array, um zu erfahren, wie die Operanden übergeben wurden, und kollabiert das Ganze zurück in den einen Ausdruck, der es erzeugt hat. Öffnen Sie Demo.Run in der dekompilierten Ansicht von Glass, und Sie bekommen genau das, was Sie geschrieben haben:
public static int Run(dynamic a, dynamic b)
{
dynamic sum = a.Add(b);
dynamic twice = sum + sum;
int result = twice;
return result;
}
Das Gerüst ist weg, weil Glass verstanden hat, wofür es da war. Das ist der Unterschied zwischen einem Werkzeug, das Ihnen IL in C#-förmige Syntax transliteriert zeigt, und einem Werkzeug, das Ihnen Quellcode zeigt.
dynamic versus object, und wo der Unterschied gespeichert ist
Es gibt eine Feinheit, die die Rekonstruktion richtig hinbekommen muss. Auf Typsystem-Ebene ist dynamic System.Object — die beiden sind in den Metadaten derselbe Typ, und das IL oben hantiert überall mit object. Woher weiß Glass also, dass es dynamic a und nicht object a schreiben soll?
Der Compiler hinterlässt eine Brotkrume: System.Runtime.CompilerServices.DynamicAttribute. Wo immer ein dynamic in einer Signatur erscheint — ein Parameter, ein Feld, ein Rückgabetyp, sogar eine Position innerhalb eines Generics wie List<dynamic> — hängt der Compiler dieses Attribut an, um zu markieren, welche objects im Quellcode wirklich dynamic waren. Für verschachtelte Fälle trägt es eine bool[]-Transformation, die sagt, welche Positionen in einem zusammengesetzten Typ dynamisch waren. Glass liest dieses Attribut und stellt dynamic genau dort wieder her, wo es hingehört, und fällt auf object zurück, wo das Attribut fehlt. Wechseln Sie in die Assembly-Informationen-Ansicht, und Sie sehen das Attribut direkt an den Membern; es sind die Metadaten, die die gelöschte Unterscheidung überhaupt erst wiederherstellbar machen.
Das C# lesen, dann zu IL wechseln
Für die meisten Zwecke ist das rekonstruierte dynamic alles, was Sie wollen. Aber die Call-Site-Maschinerie kodiert Details, die der gefaltete Ausdruck absichtlich verbirgt, und wenn diese Details zählen, wechseln Sie neben dem C# in die IL-Ansicht. Zwei sind wissenswert:
- Argumentübergabe. Jedes
CSharpArgumentInfoträgt Flags wieIsRef/IsOutundUseCompileTimeType. Eindynamic-Aufruf, der einref-Argument übergibt oder ein Argument auf seinen statischen statt seinen Laufzeittyp festnagelt, hält das im Argument-Info-Array fest. Das gefaltete C# zeigtfoo.Bar(ref x); das IL zeigt dieCSharpArgumentInfoFlags, die es dazu gemacht haben. - Operatoridentität. Eine dynamische binäre Operation speichert ihren Operator als
System.Linq.Expressions.ExpressionType(Add,LessThan,Equal, …) undCSharpBinderFlags, die Kontext wieCheckedContextfesthalten. Wenn Sie bestätigen müssen, dass ein rekonstruiertes+in einemchecked-Bereich kompiliert wurde, liegt das im Binder-Flag im IL.
Glass legt beide Ansichten mit Absicht einen Klick auseinander. Lesen Sie das dekompilierte C#, um die Absicht wiederzugewinnen — die eine Zeile, die jemand tatsächlich geschrieben hat. Öffnen Sie das IL, wenn Sie sehen müssen, welche Binder-Flags und Argument-Info-Arten der Compiler darunter gewählt hat. Der Exportpfad (glass export --in app.dll --out ./src) schreibt das rekonstruierte C# als baubares Projekt auf die Festplatte, sodass Sie auch ein ganzes Assembly nach den dynamic-Call-Sites greppen und sie in Ihrem eigenen Editor lesen können. So oder so ist der Gewinn derselbe: Ein Dutzend Member generiertes Call-Site-Gerüst wird zu dem einen Ausdruck, für den sie stehen, und Ihre Zeit fließt in das Verständnis des Codes statt in die Buchhaltung des Compilers.
Nebula.NET testen
Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.