String-Interpolation dekompilieren: den Handler zurück zu $"..." lesen
Modernes C# kompiliert $"Hello {name}" nicht mehr zu string.Format — es wird zu einem DefaultInterpolatedStringHandler mit AppendLiteral- und AppendFormatted-Aufrufen abgesenkt. Hier sehen Sie genau, wie das in IL aussieht, wie Glass.NET die Sequenz per Mustererkennung zurück in einen interpolierten String mit intakter Format- und Ausrichtungsangabe verwandelt — und den einen Fall, in dem es das ehrlicherweise nicht kann.
Ein kleines Rätsel. Sie dekompilieren eine moderne .NET-Assembly, landen in einer Methode, die offensichtlich eine Nachricht zusammengebaut hat, und statt des $"...", das Sie geschrieben haben, sehen Sie eine lokale Variable vom Typ DefaultInterpolatedStringHandler, einen Konstruktor mit zwei Ganzzahlen und eine Folge von AppendLiteral-/AppendFormatted-Aufrufen. Nichts ist falsch — das ist Ihr interpolierter String. Seit C# 10 und .NET 6 senkt der Compiler $"..." nicht mehr zu string.Format ab, sondern baut es aus guten Performance-Gründen über einen Handler zusammen. Ein Decompiler, der da nicht mitgezogen hätte, würde Sie diese mechanische Aufrufsequenz von Hand lesen lassen.
Dieser Beitrag ist eine praktische Tour durch diese Absenkung und wie Glass.NET sie zurückliest. Wir sehen uns die echte IL an, die der Compiler emittiert, wie die Teile eins zu eins auf einen interpolierten String abgebildet werden, wie Formatangaben und Ausrichtung die Rundreise überstehen — und den einen ehrlichen Fall — benutzerdefinierte Handler —, in dem sich die Form auf Quellcode-Ebene wirklich von $"..." unterscheidet und ein treuer Decompiler Ihnen genau das zeigt, statt etwas zu erfinden.
Was der Compiler emittiert
Nehmen Sie eine gewöhnliche Interpolation mit zwei Platzhaltern:
string Describe(string name, int count) =>
$"Hello {name}, you have {count} messages";
Auf .NET 6+ wird das nicht zu string.Format. Der Compiler legt einen DefaultInterpolatedStringHandler auf dem Stack an, teilt ihm vorab mit, wie viel Literaltext und wie viele Platzhalter zu erwarten sind, füttert ihn der Reihe nach mit Literalen und Werten und fordert den fertigen String an. In IL:
ldloca.s handler
ldc.i4.s 21 // total length of the literal text
ldc.i4.2 // number of interpolation holes
call instance void [System.Runtime]System.Runtime.CompilerServices.DefaultInterpolatedStringHandler::.ctor(int32, int32)
ldloca.s handler
ldstr "Hello "
call instance void DefaultInterpolatedStringHandler::AppendLiteral(string)
ldloca.s handler
ldarg.1 // name
call instance void DefaultInterpolatedStringHandler::AppendFormatted<string>(!!0)
ldloca.s handler
ldstr ", you have "
call instance void DefaultInterpolatedStringHandler::AppendLiteral(string)
ldloca.s handler
ldarg.2 // count
call instance void DefaultInterpolatedStringHandler::AppendFormatted<int32>(!!0)
ldloca.s handler
ldstr " messages"
call instance void DefaultInterpolatedStringHandler::AppendLiteral(string)
ldloca.s handler
call instance string DefaultInterpolatedStringHandler::ToStringAndClear()
ret
Die Struktur ist starr, und genau das ist der Punkt — genau das macht sie wiederherstellbar. Es gibt einen Konstruktor mit zwei Hinweis-Ganzzahlen, dann eine streng abwechselnde Folge von AppendLiteral (ein konstanter String) und AppendFormatted<T> (ein Wert) und schließlich ein einziges ToStringAndClear, das das Ergebnis erzeugt. Die beiden Konstruktor-Ganzzahlen sind ein Größenhinweis — 21 ist die kombinierte Länge von "Hello ", ", you have " und " messages", und 2 ist die Anzahl der Platzhalter —, damit der Handler seinen Puffer vorab dimensionieren kann. Sie sind nicht Teil des Textes; ein Decompiler liest sie als Bestätigung, nicht als Inhalt.
Die Rekonstruktion, Stück für Stück
Glass.NET erkennt diese Form und geht sie in Emissionsreihenfolge durch, wobei jeder Aufruf wieder zu einem Fragment des ursprünglichen Literals wird:
Jedes AppendLiteral("Hello ") steuert seine Konstante direkt zum Literaltext bei. Jedes AppendFormatted(expr) wird zu einem {expr}-Platzhalter, wobei expr aus dem dekompiliert wird, was als Argument übergeben wurde — eine lokale Variable, ein Feld, ein ganzer Teilausdruck. Das abschließende ToStringAndClear() sagt dem Matcher, dass der String vollständig ist. In Reihenfolge wieder zusammengesetzt erhalten Sie exakt $"Hello {name}, you have {count} messages" zurück — nicht eine Abschrift von Handler-Aufrufen.
Format und Ausrichtung überstehen die Rundreise
Interpolations-Platzhalter tragen zwei optionale Modifikatoren: eine Ausrichtung ({value,-10}) und eine Formatangabe ({value:C2}). Diese verschwinden in der Absenkung nicht — sie wählen eine andere AppendFormatted-Überladung. Gegeben:
string Row(string label, decimal price) =>
$"{label,-12}{price,8:C2}";
ruft der Compiler die Überladungen mit den zusätzlichen Argumenten auf: AppendFormatted<string>(value, int alignment) für das linksbündige Label und AppendFormatted<decimal>(value, int alignment, string format) für den rechtsbündigen, als Währung formatierten Preis:
ldstr "" // no leading literal; holes are adjacent
...
ldarg.1
ldc.i4.s -12
call instance void DefaultInterpolatedStringHandler::AppendFormatted<string>(!!0, int32)
...
ldarg.2
ldc.i4.8
ldstr "C2"
call instance void DefaultInterpolatedStringHandler::AppendFormatted<decimal>(!!0, int32, string)
Glass bildet das nachgestellte int wieder auf die Ausrichtung nach dem Komma und den nachgestellten string auf das Format nach dem Doppelpunkt ab und rekonstruiert so $"{label,-12}{price,8:C2}" mit beiden Modifikatoren an Ort und Stelle. Die Überladung ist die Metadaten — der Decompiler muss nicht raten, welches Argument was bedeutete, denn die Signatur sagt es.
Wenn es gar kein Handler ist
Zwei Fälle erzeugen legitimerweise etwas anderes als die Handler-Form, und sie zu erkennen gehört genauso zur korrekten Dekompilierung wie das Erkennen des Handlers:
Rein konstante Interpolation wird zu einem Literal gefaltet. Wenn jeder Platzhalter eine Compile-Zeit-Konstante ist, gibt es zur Laufzeit nichts zu formatieren — der Compiler berechnet den endgültigen String und emittiert ein einziges ldstr. Aus $"v{1}.{0}" wird in IL die Konstante "v1.0", und der Decompiler zeigt "v1.0" — korrekt, denn die Interpolation existiert im kompilierten Code nicht mehr. Das $ war eine Quellcode-Bequemlichkeit, die der Compiler bereits verbraucht hat.
Ältere Ziele senken zu string.Format ab. Der Handler-Typ kam mit .NET 6. Zielen Sie auf netstandard2.0 oder .NET Framework, wird dasselbe $"Hello {name}" stattdessen zu string.Format("Hello {0}", name) abgesenkt. Glass rekonstruiert $"..." auch aus diesem klassischen Muster — indem es die Platzhalter {0}, {1} des Formatstrings gegen das Argument-Array abgleicht —, sodass Sie die Interpolation unabhängig davon zurückbekommen, welche Absenkung Ihr Ziel verwendet hat. Ob Sie einen Handler oder einen Format-Aufruf sehen, ist in Wahrheit ein Hinweis auf das Zielframework der Assembly.
Die ehrliche Grenze: benutzerdefinierte Handler
Der eine Fall, in dem ein treuer Decompiler Ihnen kein $"..." zeigt, ist ein benutzerdefinierter Handler für interpolierte Strings — ein Typ mit [InterpolatedStringHandler], wie ihn etwa Hochleistungs-Logging und Debug.Assert verwenden. Dort ist der ganze Sinn, dass die Interpolation andere Semantik hat: Der Handler kann entscheiden, die Platzhalter gar nicht auszuwerten. Wenn Sie
logger.LogDebug($"expensive {ComputeDiagnostics()} detail");
gegen eine Überladung schreiben, die einen Logging-Interpolationshandler nimmt, emittiert der Compiler die Konstruktion dieses Handlers mit einem out bool shouldAppend-Aktivierungsflag und schützt jedes AppendFormatted, sodass ComputeDiagnostics() nur dann läuft, wenn das Debug-Level tatsächlich aktiv ist. Diese bedingte Auswertung ist nicht als einfaches $"..." ausdrückbar — sie so darzustellen wäre eine Lüge darüber, wann Ihr Code läuft. Ein guter Decompiler zeigt die Handler-Konstruktion und die geschützten Appends, denn das ist die Wahrheit dessen, was ausgeführt wird. Wenn Sie die lesbare Absicht zurückhaben wollen, ist das eine Stelle, an der Sie beim Projektexport einen Kommentar ergänzen sollten; die mechanische Form ist korrekt, nur tiefer angesiedelt als der Zucker, den Sie getippt haben.
Warum das beim Lesen eines Binaries zählt
String-Interpolation ist überall — Log-Meldungen, Exceptions, UI-Strings, SQL (bitte nicht) — und genau das sind die Strings, nach denen Sie greppen, wenn Sie eine unbekannte Assembly verstehen, nachvollziehen wollen, wo ein Wert formatiert wird, oder eine Abhängigkeit prüfen. Ein Decompiler, der rohe AppendLiteral-Sequenzen ausgibt, macht aus jeder davon eine kleine Entschlüsselungsaufgabe; einer, der $"..." rekonstruiert, lässt Sie den Code so lesen, wie der Autor ihn geschrieben hat. Glass.NET tut Letzteres, weil es auf ICSharpCode.Decompiler aufsetzt, derselben Engine wie ILSpy, die die modernen Absenkungen eng verfolgt. Richten Sie es auf eine beliebige .NET-DLL, navigieren Sie zu einer Methode, und die interpolierten Strings lesen sich wie Quellcode — siehe Erste Schritte und den Navigationsleitfaden, um es an Ihren eigenen Binaries auszuprobieren.
Nebula.NET testen
Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.