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

C#-Zeichenketteninterpolation und Interpolated String Handler dekompilieren

Eine interpolierte Zeichenkette hat kein eigenes IL — der Compiler wählt eine von drei völlig verschiedenen Absenkungen, je nach Ziel-Framework, den Typen der Lücken und ob eine Methode sich für einen Handler entschieden hat. Hier steht genau, was `$"..."` als string.Concat, als string.Format und als DefaultInterpolatedStringHandler-Tanz erzeugt, dazu der Fall des benutzerdefinierten Handlers, bei dem die Zeichenkette nie gebaut wird, und wo ein Decompiler jede Form zu `$"..."` zurückfalten kann.

Eine interpolierte Zeichenkette ist eines der harmlosesten C#-Stücke, die Sie schreiben können — $"Hallo {name}, du hast {count} Nachrichten" — und eines der am wenigsten vorhersagbaren, sobald es das IL erreicht. Es gibt keine Interpolations-Anweisung. Der Compiler schreibt jedes $"..." in gewöhnliche Methodenaufrufe um, und er hat drei völlig verschiedene Arten, das zu tun: ein string.Concat, ein string.Format mit einer positionsbasierten Vorlage oder — auf modernen Zielen — eine Konstruieren-und-Anhängen-Folge gegen DefaultInterpolatedStringHandler. Welche er wählt, hängt von Ihrem Ziel-Framework ab, von den Typen in den Lücken und davon, ob die empfangende Methode sich für einen eigenen Handler entschieden hat. Jede Absenkung durchzugehen erklärt, was ein Decompiler wie Glass.NET zu $"..." zurückfalten kann und wo er Ihnen nur die Maschinerie zeigen kann, die der Compiler tatsächlich emittiert hat.

Drei Absenkungen, eine Syntax

Die nützlichste Tatsache über $"..." ist, dass die Syntax Ihnen fast nichts über das IL verrät. Der Compiler schaut, wohin die Zeichenkette geht und was in den Lücken steht, und wählt:

$"a{x}b"kein OpcodeZiel + Lücken entscheidenstring-Lücken, kein FormatString.Concat(a, x, b)älteres Ziel / geboxtString.Format("a{0}b", x).NET 6+ string-ZuweisungHandler: Append… ToStringParameter mit Handler-Typans Ziel anhängen, keine Zeichenketteabgesenkte Aufrufepro Fallzurückfalten$"a{x}b"erkannt

Der Rest dieses Beitrags sind diese vier Kästen, der Reihe nach, mit dem IL, das jeder erzeugt, und dem Fingerabdruck, den ein Decompiler zum Erkennen nutzt.

Die Concat-Abkürzung

Wenn jede Lücke der Zeichenkette bereits eine string ist und keine Lücke eine Ausrichtung oder einen Formatbezeichner hat, macht der Compiler das billigstmögliche: Er ruft String.Concat auf. Gegeben

string Greet(string first, string last) => $"{first} {last}";

ist der Rumpf einfach ein Drei-Argument-Concat — das Leerzeichen-Literal ist nur ein weiterer Operand:

ldarg.1              // first
ldstr      " "
ldarg.2              // last
call       string System.String::Concat(string, string, string)
ret

Das ist Byte für Byte identisch mit dem, was first + " " + last kompiliert, und identisch mit einem handgeschriebenen string.Concat(first, " ", last). Es gibt keinen Marker, der sagt „das war Interpolation”. Ein Decompiler darf es frei als $"{first} {last}", als first + " " + last oder als expliziten Concat rendern — alle drei sind getreue Lesarten desselben IL. Die Interpolation als eigenständige Idee hat die Kompilierung nicht überlebt.

Die Formatvorlage

In dem Moment, in dem eine Lücke keine Zeichenkette ist — eine Zahl, ein DateTime, alles, was Formatierung braucht — ist die klassische Absenkung String.Format mit einer positionsbasierten Vorlage. Das bekommen Sie auf netstandard2.0, auf .NET Framework und auf jedem Ziel vor dem Handler-Typ. Für

string Status(int count, double rate) => $"{count} items at {rate:P1}";

baut der Compiler die Vorlagenzeichenkette, boxt die Wertetyp-Argumente in ein object[] und ruft Format auf:

ldstr      "{0} items at {1:P1}"
ldc.i4.2
newarr     [System.Runtime]System.Object
dup
ldc.i4.0
ldarg.1              // count
box        [System.Runtime]System.Int32      // int -> object
stelem.ref
dup
ldc.i4.1
ldarg.2              // rate
box        [System.Runtime]System.Double     // double -> object
stelem.ref
call       string System.String::Format(string, object[])
ret

Zwei Dinge sind hier bemerkenswert, denn sie sind genau das, was Sie in der dekompilierten Ausgabe sehen. Erstens bleibt Ihr Formatbezeichner :P1 wörtlich innerhalb der Vorlage als {1:P1} erhalten — der Decompiler kann ihn direkt aus dem ldstr lesen. Zweitens werden die Wertetypen geboxt; das object[] ist der Allokationskosten, den diese Absenkung mit sich bringt, und der Grund, warum die neuere Handler-Form existiert.

Weil dies ein wörtlicher String.Format-Aufruf ist, ist er nicht von einem zu unterscheiden, den Sie von Hand geschrieben hätten. Ein Decompiler zeigt eine der beiden Formen. Viele drucken den Aufruf string.Format("{0} items at {1:P1}", count, rate) unverändert, weil das die tiefere, portablere Lesart ist; ein C#-bewusster Decompiler kann ihn wieder zu $"{count} items at {rate:P1}" versüßen, indem er die Vorlage parst und {0},{1} auf die Argumentreihenfolge zurückabbildet. Beide sind korrekt.

Der Handler-Tanz

Auf .NET 6 und später läuft eine einfache Zuweisung einer interpolierten Zeichenkette an eine string nicht mehr über String.Format. Der Compiler senkt sie gegen DefaultInterpolatedStringHandler ab, einen ref struct in System.Runtime.CompilerServices, der das Ergebnis in einen internen Puffer akkumuliert — kein Vorlagen-Parsen, kein object[], kein Boxen der Argumente ins Array. Dieselbe Status-Methode auf einem modernen Ziel dekompiliert zu dieser Form:

string Status(int count, double rate)
{
    DefaultInterpolatedStringHandler handler =
        new DefaultInterpolatedStringHandler(9, 2); // literalLength=9, formattedCount=2
    handler.AppendFormatted(count);
    handler.AppendLiteral(" items at ");
    handler.AppendFormatted(rate, "P1");
    return handler.ToStringAndClear();
}

Lesen Sie die Konstruktorargumente, denn sie sind die erste Hälfte des Fingerabdrucks. Der Handler wird mit (int literalLength, int formattedCount) erzeugt — die Gesamtzahl der Literalzeichen (" items at " sind neun, plus alle anderen Literalläufe) und die Anzahl der Lücken. Der Handler nutzt sie, um seinen Puffer vorab zu dimensionieren. Dann ist der Rumpf eine strenge Verschränkung: ein AppendLiteral pro Lauf von Literaltext, ein AppendFormatted pro Lücke, in Quellreihenfolge, abgeschlossen durch genau ein ToStringAndClear(), das die string erzeugt und den Puffer zurückgibt. Im IL ist es eine Folge von call/callvirt gegen eine lokale Variable des Handler-Typs:

ldloca.s   handler
ldc.i4.s   9
ldc.i4.2
call       instance void DefaultInterpolatedStringHandler::.ctor(int32, int32)
ldloca.s   handler
ldarg.1
call       instance void DefaultInterpolatedStringHandler::AppendFormatted<int32>(!!0)
ldloca.s   handler
ldstr      " items at "
call       instance void DefaultInterpolatedStringHandler::AppendLiteral(string)
...
ldloca.s   handler
call       instance string DefaultInterpolatedStringHandler::ToStringAndClear()
ret

Dieser Literal-Länge-und-Anzahl-Konstruktor, die abwechselnden AppendLiteral/AppendFormatted<T>-Aufrufe auf einer einzigen lokalen Handler-Variablen und das abschließende ToStringAndClear sind eine Silhouette, die nichts anderes erzeugt. Ein moderner Decompiler erkennt sie und faltet den ganzen Block zu $"{count} items at {rate:P1}" zurück. Ein Decompiler, der das nicht tut — oder der eine Ausgabe liest, in der der Optimierer einige der Anhänge hochgezogen oder umgeordnet hat — zeigt die Handler-Aufrufe wörtlich. Das ist kein Fehler: Es ist die ehrliche Lesart und oft informativer als der Zucker, denn es zeigt Ihnen genau, wo die Pufferallokation und jede generische Instanziierung von AppendFormatted<T> stattfinden.

Ausrichtung, Format und die Kulturfalle

Die AppendFormatted-Familie hat mehrere Überladungen, und welche der Compiler auswählt, verrät Ihnen, was in den geschweiften Klammern stand. Eine nackte Lücke {x} wird zu AppendFormatted<T>(T value). Ein Formatbezeichner {x:N2} wird zu AppendFormatted<T>(T value, string? format) mit dem Format als string-Literal-Argument. Eine Ausrichtung {x,10} wird zu AppendFormatted<T>(T value, int alignment), und beide zusammen {x,10:N2} werden zu AppendFormatted<T>(T value, int alignment, string? format). Sie können also den genauen Inhalt der Klammern aus der Überladung und ihren konstanten Argumenten rekonstruieren — der Formattext steht direkt im Aufruf.

Es gibt eine Verhaltensfeinheit, die die dekompilierte Sicht sichtbar macht und die jeden beißt, der zwischen den Formen refaktoriert. DefaultInterpolatedStringHandler formatiert standardmäßig mit der aktuellen Kultur, genau wie string.Format. Wenn Sie invariant oder eine bestimmte Kultur brauchen, muss die interpolierte Zeichenkette an eine API übergeben werden, die einen IFormatProvider bis in den Konstruktor des Handlers durchreicht — der Compiler emittiert dann eine Konstruktorüberladung, die den Provider entgegennimmt. Wenn Sie einen DefaultInterpolatedStringHandler sehen, der ohne Provider-Argument konstruiert wird, ist das Ergebnis kultursensitiv; das ist ein echtes semantisches Detail, das im abgesenkten IL versteckt ist, kein Rauschen.

Benutzerdefinierte Handler: wenn die Zeichenkette nie gebaut wird

Das Handler-Muster ist nicht darauf beschränkt, eine string zu bauen. Jede Methode kann eine interpolierte Zeichenkette direkt entgegennehmen, indem sie einen Parameter nimmt, dessen Typ mit [InterpolatedStringHandler] markiert ist. Der Compiler konstruiert dann diesen Handler und hängt in ihn an, sodass die interpolierte Zeichenkette ohne jede zwischengeschaltete string direkt in das Ziel formatiert wird. Das klarste Beispiel ist StringBuilder.Append:

sb.Append($"id={id} name={name}");

baut keine Zeichenkette und hängt sie an. Es senkt sich ab zur Konstruktion eines StringBuilder.AppendInterpolatedStringHandler, der direkt in sb schreibt:

var handler = new StringBuilder.AppendInterpolatedStringHandler(8, 2, sb); // note: sb passed in
handler.AppendLiteral("id=");
handler.AppendFormatted(id);
handler.AppendLiteral(" name=");
handler.AppendFormatted(name);
sb.Append(ref handler);

Der verräterische Hinweis im dekompilierten Code ist, dass der Konstruktor des Handlers ein drittes Argument nimmt — den StringBuilder selbst — und dass der abschließende Aufruf Append(ref handler) statt Append(string) ist. Dieses zusätzliche Konstruktorargument stammt aus einem Attribut [InterpolatedStringHandlerArgument("sb")] am Parameter, das dem Compiler sagt, den Empfänger an den Handler weiterzureichen.

Der auffälligste Fall ist ein Handler, der entscheiden kann, die Zeichenkette überhaupt nicht zu bauen. Debug.Assert(condition, $"expensive {Describe()} message") darf nicht für die Nachricht zahlen, wenn die Assertion durchläuft. Der Konstruktor seines Handlers hat einen abschließenden out bool-Parameter:

var handler = new AssertInterpolatedStringHandler(17, 1, condition, out bool shouldAppend);
if (shouldAppend)
{
    handler.AppendLiteral("expensive ");
    handler.AppendFormatted(Describe());
    handler.AppendLiteral(" message");
}
Debug.Assert(condition, ref handler);

Wenn condition true ist, setzt der Konstruktor shouldAppend = false, der gesamte if-Block wird übersprungen und Describe() wird nie aufgerufen. Das ist das Verhalten der bedingten Auswertung, auf das man sich verlässt, ohne es zu merken, und es ist nur in der abgesenkten Form sichtbar — es gibt keine Möglichkeit, „überspringe die Interpolation” in der $"..."-Syntax selbst auszudrücken. Ein Decompiler, der dies zu Debug.Assert(condition, $"...") zurückfaltet, liegt beim Ergebnis richtig, verbirgt aber die shouldAppend-Wächterbedingung; auf die IL- oder abgesenkte-C#-Sicht in Glass.NET umzuschalten ist die Art, zu bestätigen, dass die Nachricht auf dem heißen Pfad wirklich übersprungen wird.

Konstante Interpolation verschwindet vollständig

Der letzte Fall ist der, bei dem nichts zu rekonstruieren ist. Wenn jeder Teil einer interpolierten Zeichenkette eine Kompilierzeit-Konstante ist, wird das Ganze zur Kompilierzeit gefaltet:

const string Version = "2";
const string Banner = $"Acme v{Version}";   // becomes the constant "Acme v2"

Banner wird als das Zeichenketten-Literal "Acme v2" emittiert. Kein Handler, kein Format, kein Concat — die Metadaten halten eine Konstante, und jede Referenz auf Banner ist nur ein ldstr "Acme v2". Ein Decompiler zeigt eine schlichte Zeichenketten-Konstante, weil das alles ist, was existiert. Dasselbe gilt für eine interpolierte Zeichenkette ohne Lücke wie $"ready", die nur das Literal "ready" ist. Interpolation, die der Compiler statisch auflösen konnte, hinterlässt keine Spur davon, Interpolation gewesen zu sein.

Was aus der dekompilierten Sicht mitzunehmen ist

Die Zeichenketteninterpolation ist die sauberste Illustration eines Themas, das sich durch diese ganze Serie zieht: Die Oberflächensyntax ist eine Anfrage, und der Compiler beantwortet sie je nach Kontext unterschiedlich. Dieselbe $"..." kann ein String.Concat sein, ein String.Format mit einem geboxten object[], eine DefaultInterpolatedStringHandler-Folge, ein direktes Anhängen in einen StringBuilder oder — wenn die Eingaben konstant sind — nichts als ein eingebackenes Literal. Nur einige davon tragen einen Fingerabdruck, der stark genug ist, das $"..." zu rekonstruieren: Der Handler-Tanz und die Formen mit benutzerdefiniertem Handler sind erkennbar; die Concat- und Format-Formen sind nicht von handgeschriebenen Aufrufen zu unterscheiden; die konstante Form ist bereits weg. Ein guter Decompiler faltet zurück, was er beweisen kann, und zeigt die abgesenkten Aufrufe dort, wo er es nicht kann — und die abgesenkten Aufrufe sind oft der Punkt, denn dort sehen Sie das Boxen, die Pufferallokation, die Kulturabhängigkeit und die shouldAppend-Wächterbedingung, die der Zucker dezent verbirgt. Öffnen Sie das Assembly in Glass.NET, schalten Sie eine Methode auf IL um und sehen Sie zu, wie sich eine einzeilige $"..." in die Aufrufe entfaltet, die die Laufzeit tatsächlich macht. Für den vollständigen Arbeitsablauf siehe wie man eine .NET-DLL zu C# dekompiliert.

Nebula.NET testen

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