Extension-Methoden dekompilieren: das [Extension]-Attribut ist die einzige Spur, der Receiver war nur das erste Argument, und der Aufruf, der statisch aussieht, ist es nicht
Eine Extension-Methode existiert zur Laufzeit nicht als Extension. Sie ist eine schlichte statische Methode, deren erster Parameter zufällig markiert ist, und ein Aufruf – text.Trim().Count() – kompiliert zu einem gewöhnlichen statischen Aufruf, Enumerable.Count(string.Trim(text)), ohne Punktsyntax, ohne Receiver, ohne irgendetwas, das verriete, dass er je fluent geschrieben wurde. Das Einzige, was der Compiler zurücklässt, ist ein [Extension]-Attribut: an der Methode, damit der erste Parameter mit this dargestellt werden kann, und an der umgebenden statischen Klasse, damit ein Werkzeug überhaupt weiß, dass es dort nachsehen soll. Ein Decompiler muss dieses Attribut lesen, entscheiden, dass ein statischer Aufruf, dessen Ziel es trägt, als Member-Aufruf auf seinem ersten Argument ausgegeben werden soll, und die Präzedenz richtig hinbekommen, damit eine Kette aus zehn LINQ-Operatoren als Kette zurückkommt und nicht als zehn Ebenen tief verschachtelte statische Aufrufe. Dieser Beitrag geht das IL für eine definierte Extension-Methode und für eine Aufrufstelle durch, zeigt, was Glass.NET rekonstruiert, erklärt, warum das Attribut – nicht der Aufruf – die maßgebliche Wahrheit ist, und behandelt die neuere Extension-Block-Form von C# 14, deren Member zu denselben statischen Methoden unter einer anderen Haut kompilieren.
Eine Extension-Methode ist eines der reinsten Beispiele für C#-Zucker, der im IL verdampft. Sie schreiben this an den ersten Parameter, rufen sie als text.WordCount() auf und haben das Gefühl, string eine Methode hinzugefügt zu haben. Die Laufzeit sieht nichts davon. Es gibt eine statische Methode, es gibt einen statischen Aufruf, und es gibt ein Attribut – [Extension] –, zurückgelassen wie ein Zettel, auf dem steht: das hätte man mit einem Punkt schreiben können. Die ganze Aufgabe eines Decompilers besteht hier darin, diesen Zettel zu lesen und sich für den Punkt zu entscheiden, und dann die Präzedenz richtig hinzubekommen, damit eine LINQ-Pipeline aus zehn Operatoren als Pipeline zurückkommt und nicht als zehn Ebenen tiefe Verschachtelung. Dieser Beitrag geht das für Glass.NET durch.
Die Methode: eine statische Methode mit einem einzigen Attribut
Beginnen Sie mit der Definition. Eine triviale Extension auf string:
public static class StringExtensions
{
public static int WordCount(this string s)
=> s.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length;
}
Kompilieren Sie sie und sehen Sie nach, was aus this geworden ist. Es ist vom Parameter verschwunden – s ist ein gewöhnlicher string-Parameter – und an seiner Stelle stehen zwei [Extension]-Attribute, eines an der Methode und eines an der Klasse:
.class public abstract sealed auto ansi beforefieldinit StringExtensions
extends [System.Runtime]System.Object
{
.custom instance void [System.Runtime]System.Runtime.CompilerServices.ExtensionAttribute::.ctor() = ( 01 00 00 00 )
.method public hidebysig static int32 WordCount(string s) cil managed
{
.custom instance void [System.Runtime]System.Runtime.CompilerServices.ExtensionAttribute::.ctor() = ( 01 00 00 00 )
// ... ldarg.0; call String::Split(...); ldlen; ...
}
}
Das ist der gesamte Unterschied zwischen einer Extension-Methode und einem statischen Helfer. Die Methode ist public static, der erste Parameter ist ein normaler Parameter eines normalen Typs, es gibt keine besondere Aufrufkonvention. Das Attribut an der Methode ist es, was einem Decompiler erlaubt, den ersten Parameter als this s darzustellen; das Attribut an der Klasse ist es, was einem konsumierenden Compiler überhaupt erst sagt, dass es sich lohnt, die Klasse nach Extensions zu durchsuchen. Entfernen Sie beide, und Sie haben StringExtensions.WordCount(x) und sonst nichts; fügen Sie sie hinzu, und x.WordCount() wird ebenfalls gültig.
Die Aufrufstelle: byteidentisch mit einem statischen Aufruf
Rufen Sie sie nun auf zwei Arten auf und vergleichen Sie das IL:
int a = "the quick brown fox".WordCount(); // fluent
int b = StringExtensions.WordCount("the quick brown fox"); // explicit static
Beide kompilieren zu denselben drei Instruktionen – den String laden, call auf die statische Methode, speichern:
ldstr "the quick brown fox"
call int32 StringExtensions::WordCount(string)
stloc.0
Es gibt keine zweite Form. Die CLR kennt kein Konzept eines Extension-Aufrufs, also wird das this zur Kompilierzeit aufgelöst und verworfen, und die Aufrufstelle behält keinerlei Aufzeichnung darüber, welche Syntax der Autor verwendet hat. Das ist der Knackpunkt des ganzen Problems: Ein Decompiler kann aus dem Aufruf nicht erfahren, ob der Quellcode fluent war. Er kann nur die aufgerufene Methode betrachten, [Extension] an ihr sehen und sich dafür entscheiden, den Aufruf als Member-Zugriff auf seinem ersten Argument auszugeben – "the quick brown fox".WordCount() –, weil das die idiomatische Form und fast immer die ursprüngliche ist. Beide Darstellungen sind gültig und kompilieren identisch, die Wahl ist also eine Frage der Lesbarkeit, nicht der Korrektheit. Glass.NET trifft sie, und deshalb liest sich die Ausgabe wie von einem Menschen geschriebenes C# und nicht wie eine Makro-Expansion.
Die Kette: eine Regel, nach außen angewandt
Der Grund, warum der Punkt zählt, ist LINQ, wo das wörtliche Lesen des IL etwas Korrektes und Unlesbares ergibt. Nehmen Sie eine Pipeline:
var top = people.Where(p => p.Active).Select(p => p.Name).OrderBy(n => n).ToList();
Jedes von Where, Select, OrderBy, ToList ist eine statische Extension-Methode auf Enumerable, das IL ist also eine Verschachtelung – der äußerste Aufruf wird zuletzt emittiert, jedes Ergebnis speist das erste Argument des nächsten Aufrufs:
call ...ToList( call ...OrderBy( call ...Select( call ...Where(people, <lambda>), <lambda>), <lambda>))
Lesen Sie das wörtlich, und Sie erhalten Enumerable.ToList(Enumerable.OrderBy(Enumerable.Select(Enumerable.Where(people, …), …), …)) – eine vier Ebenen tiefe Verschachtelung, die Sie von innen nach außen lesen müssen. Weil jede Methode [Extension] trägt, wendet Glass.NET die eine Regel wiederholt an: Jeden statischen Aufruf als Member-Zugriff auf seinem ersten Argument ausgeben. Das innerste Where(people, …) wird zu people.Where(…); dieser gesamte Ausdruck ist das erste Argument von Select, es liest sich also people.Where(…).Select(…); und so weiter nach außen, bis die Verschachtelung sich zur ursprünglichen Kette entrollt hat. Die Präzedenz muss exakt sein – ein Lambda-Rumpf mit einem bedingten Ausdruck oder ein mitten in der Kette eingefügter Cast braucht die richtigen Klammern, sonst verschiebt sich die Bedeutung –, aber die Form ergibt sich unmittelbar daraus, „Member-Zugriff auf dem ersten Argument“ vom innersten Aufruf nach außen anzuwenden.
Warum das Attribut, nicht der Aufruf, die maßgebliche Wahrheit ist
Es lohnt sich, genau zu sein, worauf Glass vertraut. Die Aufrufstelle ist konstruktionsbedingt mehrdeutig – es ist dasselbe IL für beide Syntaxen –, sie liefert also kein Signal. Die Metadaten der Methode sind eindeutig: [Extension] ist vorhanden oder nicht. Glass macht seine Entscheidung also an der Methode fest:
- Die Zielmethode trägt
[Extension]→ den Aufruf alsarg0.Method(rest)darstellen. - Die Zielmethode trägt es nicht → ihn als
Type.Method(args)darstellen.
Das behandelt auch die unangenehmen Praxisfälle korrekt. Wenn jemand absichtlich Enumerable.Where(list, p) in expliziter statischer Form geschrieben hat, gibt Glass trotzdem list.Where(p) aus – was in Ordnung ist, weil es zu demselben IL zurückkompiliert und sich besser liest. Und eine generische Extension-Methode wie Select<TSource, TResult> trägt das Attribut genauso; Glass stellt src.Select(f) dar und überlässt es der Typinferenz von C#, die Typargumente beim Rekompilieren wiederzugewinnen, und schreibt sie nur dann aus, wenn die Inferenz es nicht kann. Das Attribut ist ein verlässliches, lokales Signal an der aufgerufenen Methode, was genau das ist, was ein Decompiler will – keine globale Analyse, kein Raten anhand umgebender using-Direktiven, die nicht einmal im IL stehen.
Die Extension-Block-Form von C# 14
Neuerer Code kann Extension-Blöcke verwenden, die Member unter einem Receiver gruppieren und zum ersten Mal Extension-Properties erlauben:
public static class StringExtensions
{
extension(string s)
{
public int WordCount => s.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length;
public bool IsBlank() => string.IsNullOrWhiteSpace(s);
}
}
Das ist neue Syntax, keine neue Laufzeit. Die Member kompilieren weiterhin zu statischen Methoden auf der umschließenden statischen Klasse, markiert, damit der Compiler eines Konsumenten name.WordCount und name.IsBlank() zu ihnen routen kann – eine Extension-Property wird zu einer statischen Accessor-Methode, eine Extension-Methode zu einer statischen Methode mit dem Receiver als erstem Parameter, plus Gruppierungsmetadaten, die mehrere Member an einen Receiver binden. Ein Decompiler, der eine solche Assembly liest, sieht statische Methoden mit Marker-Metadaten und einem Receiver-Parameter, weitgehend wie zuvor. Glass.NET rekonstruiert sie als Extensions; eine schlichte Extension-Methode kann entweder in der klassischen this-Parameter-Form oder innerhalb eines Blocks gezeigt werden, aber eine Extension-Property hat keine klassische Schreibweise, also kommt sie als Block zurück. Die Semantik ist in beiden Fällen identisch, weil die erzeugten statischen Methoden identisch sind – die Haut hat sich geändert, nicht das IL.
Das Fazit
Eine Extension-Methode hinterlässt genau eine Spur in den Metadaten: das [Extension]-Attribut, an der Methode, damit der erste Parameter this tragen kann, und an der Klasse, damit ein Compiler weiß, dass er dort nachsehen soll. Die Aufrufstelle hinterlässt überhaupt keine Spur – text.WordCount() und StringExtensions.WordCount(text) sind byteidentisches IL –, ein Decompiler kann die fluent-Form also nicht aus dem Aufruf erfahren und muss sie stattdessen an der Methode ablesen. Glass.NET tut genau das: Es gibt jeden statischen Aufruf, dessen Ziel [Extension] trägt, als Member-Zugriff auf dem ersten Argument aus, wendet diese eine Regel vom innersten Aufruf nach außen an, um eine LINQ-Verschachtelung zurück in eine Kette zu entrollen, und erkennt das Lowering der Extension-Blöcke von C# 14 als dieselben statischen Methoden unter einer neueren Syntax. Das Ergebnis liest sich wie die Pipeline, die geschrieben wurde, nicht wie die Verschachtelung, die der Compiler hinterließ – weil der Decompiler dem Attribut vertraut hat, das als Einziges überlebt hat.
Nebula.NET testen
Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.