Skip to content
← Tous les articles
· Delta1 Labs Glass.NETDécompilationGuide

Décompiler l'interpolation de chaînes : relire le handler vers $"..."

Le C# moderne ne compile plus $"Hello {name}" en string.Format — il l'abaisse vers un DefaultInterpolatedStringHandler avec des appels AppendLiteral et AppendFormatted. Voici exactement à quoi cela ressemble en IL, comment Glass.NET fait correspondre la séquence à une chaîne interpolée avec son format et son alignement intacts, et le seul cas où, honnêtement, il ne le peut pas.

Voici une petite énigme. Vous décompilez un assembly .NET moderne, vous atterrissez sur une méthode qui construisait clairement un message, et au lieu du $"..." que vous aviez écrit, vous voyez une variable locale de type DefaultInterpolatedStringHandler, un constructeur prenant deux entiers, et une suite d’appels AppendLiteral / AppendFormatted. Rien n’est anormal — c’est votre chaîne interpolée. Depuis C# 10 et .NET 6, le compilateur a cessé d’abaisser $"..." vers string.Format et s’est mis à la construire via un handler, pour de bonnes raisons de performance. Un décompilateur qui n’aurait pas suivi vous laisserait lire cette séquence d’appels mécanique à la main.

Cet article est une visite pratique de cet abaissement et de la façon dont Glass.NET le relit. Nous regarderons l’IL réelle émise par le compilateur, comment les morceaux se mappent un à un sur une chaîne interpolée, comment les spécificateurs de format et l’alignement survivent à l’aller-retour, et le seul cas honnête — les handlers personnalisés — où la forme au niveau source diffère réellement de $"..." et où un décompilateur fidèle vous le montre au lieu de l’inventer.

Ce que le compilateur émet

Prenez une interpolation ordinaire à deux trous :

string Describe(string name, int count) =>
    $"Hello {name}, you have {count} messages";

Sur .NET 6+, cela ne devient pas string.Format. Le compilateur alloue un DefaultInterpolatedStringHandler sur la pile, lui indique d’emblée combien de texte littéral et combien de trous attendre, lui fournit les littéraux et les valeurs dans l’ordre, et demande la chaîne finie. En 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

La structure est rigide et c’est tout l’intérêt — c’est précisément ce qui la rend récupérable. Il y a un constructeur portant deux entiers d’indication, puis une suite strictement alternée d’AppendLiteral (une chaîne constante) et d’AppendFormatted<T> (une valeur), et enfin un unique ToStringAndClear qui produit le résultat. Les deux entiers du constructeur sont une indication de dimensionnement — 21 est la longueur combinée de "Hello ", ", you have " et " messages", et 2 est le nombre de trous — pour que le handler puisse pré-dimensionner son tampon. Ils ne font pas partie du texte ; un décompilateur les lit comme une corroboration, pas comme du contenu.

La reconstruction, morceau par morceau

Glass.NET fait correspondre cette forme et la parcourt dans l’ordre d’émission, retransformant chaque appel en un fragment du littéral d’origine :

AppendLiteral("Hello ")AppendFormatted(name)AppendLiteral(", you have ")AppendFormatted(count)AppendLiteral(" messages")ToStringAndClear()source reconstruite$"Hello {name}, you have {count} messages"fragments littéraux → texte · trous formatés → {expr}

Chaque AppendLiteral("Hello ") verse sa constante directement dans le texte littéral. Chaque AppendFormatted(expr) devient un trou {expr}, où expr est décompilé à partir de ce qui a été poussé comme argument — une locale, un champ, une sous-expression entière. Le ToStringAndClear() de clôture indique au correspondeur que la chaîne est complète. Réassemblé dans l’ordre, vous récupérez exactement $"Hello {name}, you have {count} messages" — pas une transcription d’appels de handler.

Le format et l’alignement survivent au trajet

Les trous d’interpolation portent deux modificateurs optionnels : un alignement ({value,-10}) et un spécificateur de format ({value:C2}). Ils ne disparaissent pas dans l’abaissement — ils sélectionnent une surcharge AppendFormatted différente. Soit :

string Row(string label, decimal price) =>
    $"{label,-12}{price,8:C2}";

le compilateur appelle les surcharges qui prennent les arguments supplémentaires : AppendFormatted<string>(value, int alignment) pour le label aligné à gauche, et AppendFormatted<decimal>(value, int alignment, string format) pour le prix aligné à droite et formaté en devise :

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 mappe le int final vers l’alignement après la virgule et le string final vers le format après les deux-points, donc il reconstruit $"{label,-12}{price,8:C2}" avec les deux modificateurs en place. La surcharge est la métadonnée — le décompilateur n’a pas à deviner quel argument signifiait quoi, parce que la signature le dit.

Quand ce n’est pas du tout un handler

Deux cas produisent légitimement autre chose que la forme handler, et les reconnaître fait tout autant partie d’une décompilation correcte que faire correspondre le handler :

Une interpolation entièrement constante se replie en littéral. Si chaque trou est une constante de compilation, il n’y a rien à formater à l’exécution — le compilateur calcule la chaîne finale et émet un seul ldstr. Donc $"v{1}.{0}" devient la constante "v1.0" en IL, et le décompilateur montre "v1.0", à juste titre, parce que l’interpolation n’existe plus dans le code compilé. Le $ était une commodité de source que le compilateur a déjà dépensée.

Les cibles plus anciennes s’abaissent vers string.Format. Le type handler est arrivé avec .NET 6. Ciblez netstandard2.0 ou .NET Framework et le même $"Hello {name}" s’abaisse vers string.Format("Hello {0}", name) à la place. Glass reconstruit $"..." à partir de ce motif classique aussi — en faisant correspondre les emplacements {0}, {1} de la chaîne de format aux éléments du tableau d’arguments — donc vous récupérez l’interpolation quel que soit l’abaissement utilisé par votre cible. Voir un handler plutôt qu’un appel Format est en réalité un signal du framework cible de l’assembly.

La limite honnête : les handlers personnalisés

Le seul cas où un décompilateur fidèle ne vous montrera pas $"..." est un handler de chaîne interpolée personnalisé — un type marqué [InterpolatedStringHandler], utilisé par des choses comme la journalisation haute performance et Debug.Assert. Là, tout l’intérêt est que l’interpolation a une sémantique différente : le handler peut décider de ne pas évaluer les trous du tout. Quand vous écrivez

logger.LogDebug($"expensive {ComputeDiagnostics()} detail");

contre une surcharge qui prend un handler d’interpolation de journalisation, le compilateur émet une construction de ce handler avec un drapeau out bool shouldAppend activé, et garde chaque AppendFormatted pour que ComputeDiagnostics() ne s’exécute que si le niveau de débogage est réellement activé. Cette évaluation conditionnelle n’est pas exprimable comme un simple $"..." — le rendre ainsi serait un mensonge sur le moment où votre code s’exécute. Un bon décompilateur montre la construction du handler et les appends gardés, ce qui est la vérité de ce qui s’exécute. Si vous voulez retrouver l’intention lisible, c’est un endroit où ajouter un commentaire pendant l’export de projet ; la forme mécanique est correcte, juste plus bas niveau que le sucre que vous avez tapé.

Pourquoi cela compte quand vous lisez un binaire

L’interpolation de chaînes est partout — messages de log, exceptions, chaînes d’UI, SQL (ne faites pas ça) — et ce sont exactement les chaînes que vous cherchez (grep) quand vous essayez de comprendre un assembly inconnu, de tracer où une valeur est formatée, ou d’auditer une dépendance. Un décompilateur qui déverse des séquences brutes d’AppendLiteral fait de chacune une petite corvée de décodage ; un qui reconstruit $"..." vous laisse lire le code tel que l’auteur l’a écrit. Glass.NET fait le second parce qu’il s’appuie sur ICSharpCode.Decompiler, le même moteur qu’ILSpy, qui suit de près les abaissements modernes. Pointez-le vers n’importe quelle DLL .NET, naviguez jusqu’à une méthode, et les chaînes interpolées se lisent comme du source — voir Prise en main et le guide de navigation pour l’essayer sur vos propres binaires.

Essayez Nebula.NET

Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.