[CallerArgumentExpression] dekompilieren: der Quelltext, den der Compiler in deine Aufrufstellen einbrennt, und wie Glass den sauberen Aufruf wiederherstellt
Wenn du ArgumentNullException.ThrowIfNull(x) oder ein eigenes Guard.Against(x) aufrufst, übergibst du ein Argument. Der kompilierte IL schiebt zwei: deinen Wert und ein String-Literal mit dem exakten Quelltext deines ersten Arguments – weil der Parameter [CallerArgumentExpression] trägt. Das Attribut lebt in den Metadaten des Parameters; der erfasste Text lebt als schlichtes ldstr an jeder Aufrufstelle. Ein naiver Dekompiler gibt dir das überflüssige zweite Argument zurück. Dieser Beitrag liest den IL und den Attribut-Blob, erklärt, warum das Literal dort ist, und zeigt Schritt für Schritt, wie Glass.NET das Attribut erkennt, das Literal mit dem rekonstruierten Ausdruck abgleicht, das synthetisierte Argument unterdrückt und die Methodendeklaration mit intaktem Attribut wiederaufbaut.
Die meisten Compiler-Features, die eine Aufrufstelle berühren, hinterlassen eine zurücklesbare Form: eine Zustandsmaschine, eine Display-Klasse, ein verstecktes Temporär. [CallerArgumentExpression] ist ungewöhnlich, weil das, was es hinterlässt, ein String deines eigenen Quellcodes ist – in die Argumente der aufgerufenen Methode eingefroren in dem Moment, als du kompiliert hast. Dekompiliere ein Projekt, das ArgumentNullException.ThrowIfNull oder ein handgeschriebenes Guard verwendet, und ohne die richtige Behandlung bekommst du die Namen deiner eigenen Argumente als Literal-Strings zurückgespiegelt. Dieser Beitrag liest genau, was der Compiler ausgibt, und zeigt, wie Glass.NET das Attribut erkennt, das Literal abgleicht und den Aufruf wiederherstellt, den du tatsächlich geschrieben hast.
Dekompilierung ist hier die gewöhnliche, legitime Art: IL lesen, um Code zu verstehen, zu debuggen oder zu prüfen, den du geschrieben hast oder zu dessen Einsicht du berechtigt bist.
Das Feature und wozu es kompiliert
[CallerArgumentExpression] lässt einen Parameter als String den textlichen Ausdruck empfangen, den ein Aufrufer für ein anderes Argument übergeben hat. Der kanonische Einsatz liegt in der BCL:
public static void ThrowIfNull(
[NotNull] object? argument,
[CallerArgumentExpression(nameof(argument))] string? paramName = null)
{
if (argument is null)
throw new ArgumentNullException(paramName);
}
Rufe sie mit einem einzigen Argument auf:
static void Use(Order order) => ArgumentNullException.ThrowIfNull(order.Customer);
Du hast ein Argument übergeben. Der Compiler gibt zwei aus. Das zweite ist der Quelltext des ersten, wortwörtlich erfasst:
.method private hidebysig static void Use(class Order order) cil managed
{
IL_0000: ldarg.0
IL_0001: callvirt instance class Customer Order::get_Customer()
IL_0006: ldstr "order.Customer" // <- die eingebrannte caller-expression
IL_000b: call void [System.Runtime]System.ArgumentNullException::ThrowIfNull(object, string)
IL_0010: ret
}
Dieses ldstr "order.Customer" ist das gesamte Feature an der Aufrufstelle. Es wird nicht zur Laufzeit berechnet, es ist kein Verweis auf irgendein Symbol – es ist eine eingefrorene Momentaufnahme der Zeichen, die du zwischen die Klammern getippt hast, erzeugt vom Compiler, der seinen eigenen Syntaxbaum liest und das referenzierte Argument zu Text rendert. Ändere die Abstände oder den Member-Pfad im Quelltext, kompiliere neu, und das Literal ändert sich mit. Veröffentliche einen neuen Aufrufer ohne Neukompilierung, und das alte Literal bleibt, weil der Text in den Aufrufer kopiert und nicht von der aufgerufenen Methode referenziert wurde.
Wo das Attribut in den Metadaten lebt
Die Aufrufstelle erzählt nur die halbe Geschichte. Der Hinweis, dass es ein Aufruferausdruck-Argument ist – und kein gewöhnlicher String, den der Autor zufällig übergeben hat – lebt auf dem Parameter der aufgerufenen Methode, in den Metadaten:
.method public hidebysig static void ThrowIfNull(object argument,
[opt] string paramName) cil managed
{
.param [2]
.custom instance void [System.Runtime]System.Runtime.CompilerServices.CallerArgumentExpressionAttribute::.ctor(string)
= ( 01 00 08 61 72 67 75 6D 65 6E 74 00 00 ) // ...argument..
.param [2] = nullref
...
}
Auf Parameter 2 sind drei Tatsachen kodiert. Das [opt]-Flag markiert ihn als Optional. Das .param [2] = nullref ist sein Standardwert in der Constant-Tabelle – ein Aufruferausdruck-Parameter ist immer optional mit einem Standardwert, damit Aufrufer, die die Erfassung nicht wollen (oder einen älteren Compiler anvisieren), sich trotzdem binden. Und die .custom-Zeile ist das CallerArgumentExpressionAttribute selbst. Dekodiere seinen Blob und du erhältst das Konstruktorargument: 01 00 ist der Attribut-Prolog, 08 ist die Länge des folgenden UTF-8-Strings, 61 72 67 75 6D 65 6E 74 buchstabiert argument, und das abschließende 00 00 ist die (null) Anzahl benannter Eigenschaften. Das Attribut sagt also sinngemäß: „Wenn ein Aufrufer mich auslässt, fülle mich mit dem Quelltext des Parameters namens argument.”
Das ist der Zeiger, dem ein Dekompiler folgen muss. Das Literal an der Aufrufstelle bedeutet für sich genommen nichts – nur das Attribut von Parameter 2 sagt dir, dass der String eine synthetisierte Erfassung von Argument 1 ist und damit ein Kandidat für die Unterdrückung.
Was ein naiver Dekompiler ausgibt
Ein Dekompiler, der den Aufruf durchläuft und nur die Operanden liest, sieht eine vollkommen gewöhnliche Invokation mit zwei Argumenten. Er hat keinen Grund, den zweiten Operanden als besonders zu behandeln, also gibt er beide aus:
// Naive Ausgabe – technisch getreu, aber verrauscht
ArgumentNullException.ThrowIfNull(order.Customer, "order.Customer");
Das ist nicht falsch – es neu zu kompilieren erzeugt identischen IL, weil ein explizites Argument immer über einen Aufruferausdruck-Standardwert siegt. Aber es ist nicht das, was irgendjemand geschrieben hat, und es ist aktiv irreführend: Es suggeriert, dass dem Autor dieser String wichtig war, obwohl der Compiler ihn in Wirklichkeit synthetisiert hat. Multipliziere das über eine Codebasis, die sich auf ThrowIfNull, ArgumentException.ThrowIfNullOrEmpty und eine eigene Guard-Bibliothek stützt, und jede Guard-Klausel trägt ein überflüssiges Echo ihres ersten Arguments. Die Ausgabe liest sich wie Code von jemandem, der dem Feature nicht vertraut hat.
Wie Glass das synthetisierte Argument erkennt und unterdrückt
Glass.NET behandelt die Aufrufstelle und die Metadaten der aufgerufenen Methode als zwei Hälften einer Tatsache. Die Wiederherstellung läuft in vier Schritten, unten dargestellt.
Erstens, die aufgerufene Methode auflösen und ihre Parameter-Metadaten lesen. Glass stellt fest, dass Parameter 2 von ThrowIfNull CallerArgumentExpressionAttribute("argument") trägt, also ist der String an Position 2 ein Kandidat für ein synthetisiertes Argument, gebunden an Position 1.
Zweitens, den referenzierten Ausdruck rekonstruieren. Glass hat bereits einen wiederhergestellten Syntaxbaum für den Aufruf – es weiß, dass Argument 1 order.Customer ist. Es rendert dieses Argument zu kanonischem C#-Text, genau wie es der Compiler getan hätte: Member-Zugriffe, Indexer, Casts und Literale werden alle zur selben Schreibweise zurückgeführt, die der Compiler beim Erzeugen des eingebrannten Strings verwendet hat.
Drittens, normalisieren und vergleichen. Der Compiler erfasst den Quelltext einschließlich beliebiger unwesentlicher Leerzeichen, die der Autor verwendet hat (ThrowIfNull( order . Customer ) würde "order . Customer" einbrennen), während der von Glass regenerierte Text kanonisch ist. Daher normalisiert der Abgleicher beide Seiten bezüglich unwesentlicher Leerzeichen vor dem Vergleich. Sind die normalisierten Strings gleich, ist das Literal das Werk des Compilers selbst.
Viertens, unterdrücken oder behalten. Bei einem Treffer entfernt Glass das Argument und gibt den sauberen Aufruf aus:
// Ausgabe von Glass.NET
ArgumentNullException.ThrowIfNull(order.Customer);
Bei einer Abweichung – der Autor schrieb ThrowIfNull(order.Customer, "the customer") und überschrieb die Erfassung absichtlich, oder das erste Argument ist etwas, dessen Text Glass nicht festlegen kann – wird das Literal behalten, weil sein Entfernen die Bedeutung ändern würde. Die Regel ist bewusst konservativ: Glass entfernt das Argument nur, wenn es beweisen kann, dass das Literal genau das ist, was das Feature regenerieren würde.
Die Deklaration rekonstruieren
Den Aufruf zu unterdrücken ist nur die halbe getreue Dekompilierung; die Methoden-Deklaration muss das Attribut zurücktragen, sonst würde das Rekompilieren der Ausgabe die Erfassung nicht reproduzieren. Glass liest die .custom-Zeile von Parameter 2, dekodiert den längenpräfixierten Konstruktor-String (argument), liest das Optional-Flag und den nullref-Standardwert aus der Constant-Tabelle und gibt alle drei erneut aus:
public static void ThrowIfNull(
object? argument,
[CallerArgumentExpression(nameof(argument))] string? paramName = null)
Glass bevorzugt nameof(argument) gegenüber einem nackten "argument"-String, wenn ein Parameter mit genau diesem Namen im Gültigkeitsbereich ist, um idiomatischem Quelltext zu entsprechen; es greift auf das Literal-String zurück, falls kein solcher Parameter existiert (etwa wenn der referenzierte Name nicht aufgelöst wird, was der C#-Compiler zulässt). Der Standardwert = null und die nullable-Annotation string? stammen direkt aus der Constant-Tabelle bzw. dem NullableAttribute, sodass die rekonstruierte Signatur so wie sie ist rekompilierbar ist.
Es mit der CLI ausprobieren
Die glass-CLI macht das Vorher/Nachher sichtbar. Richte sie auf ein Assembly und einen Typ, und sie gibt standardmäßig sauberes C# aus:
glass decompile MyApp.dll --type MyApp.Services.OrderValidator
Um die rohe, ungesäuberte Form zu sehen – jedes synthetisierte Argument an Ort und Stelle belassen – schalte den Vereinfachungsdurchlauf aus:
glass decompile MyApp.dll --type MyApp.Services.OrderValidator --no-sugar
Beide gegen einen Validator voller Guard-Klauseln laufen zu lassen ist der schnellste Weg, zu sehen, was das Feature an der Aufrufstelle tatsächlich kostet: Mit --no-sugar zeigt jede Guard ihr wiederholtes Literal; mit dem Standarddurchlauf fällt jede zurück zum Aufruf mit einem einzigen Argument, den du geschrieben hast. Und den Guard-Typ selbst zu dekompilieren zeigt das Attribut auf dem Parameter wiederhergestellt, sodass du den Erfassungsvertrag direkt lesen kannst.
Das Fazit
[CallerArgumentExpression] veranschaulicht sauber ein allgemeines Dekompiler-Prinzip: Der IL ist getreu, aber unterspezifiziert. Das Literal an der Aufrufstelle ist echt und rekompilierbar, aber es ist kein Quelltext, den jemand geschrieben hat – es ist eine Kopie des Quelltextes zur Kompilierzeit, und nur die Parameter-Metadaten der aufgerufenen Methode enthüllen das. Ein Dekompiler, der das eine ohne das andere liest, gibt dir Rauschen; einer, der sie verbindet, gibt dir den Aufruf zurück, den du geschrieben hast. Glass.NET zieht die Linie präzise: Es unterdrückt das synthetisierte Argument nur, wenn es beweisen kann, dass das Literal dem Ausdruck entspricht, den es regenerieren würde, und es rekonstruiert das Attribut der Deklaration aus den Metadaten, damit der wiederhergestellte Quelltext zum selben IL kompiliert, aus dem er stammt.
Nebula.NET testen
Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.