Décompiler l'interpolation de chaînes C# et les gestionnaires de chaînes interpolées
Une chaîne interpolée n'a pas d'IL propre : le compilateur choisit l'un de trois abaissements complètement différents selon votre framework cible, les types des trous et selon qu'une méthode a opté pour un gestionnaire. Voici exactement ce qu'émet `$"..."` sous forme de string.Concat, de string.Format et de la danse de DefaultInterpolatedStringHandler, ainsi que le cas du gestionnaire personnalisé où la chaîne n'est jamais construite, et où un décompilateur peut replier chaque forme vers `$"..."`.
Une chaîne interpolée est l”un des bouts de C# à l”air le plus innocent que vous puissiez écrire — $"Bonjour {name}, vous avez {count} messages" — et l”un des moins prévisibles une fois parvenu à l”IL. Il n”y a pas d”instruction d”interpolation. Le compilateur réécrit chaque $"..." en appels de méthodes ordinaires, et il a trois façons complètement différentes de le faire : un string.Concat, un string.Format avec un gabarit positionnel ou — sur les cibles modernes — une séquence de construction-et-ajout contre DefaultInterpolatedStringHandler. Laquelle il choisit dépend de votre framework cible, des types dans les trous et de si la méthode réceptrice a opté pour un gestionnaire à elle. Parcourir chaque abaissement explique ce qu”un décompilateur comme Glass.NET peut replier en $"...", et où il ne peut que vous montrer la mécanique que le compilateur a réellement émise.
Trois abaissements, une syntaxe
Le fait le plus utile à propos de $"..." est que la syntaxe ne vous dit presque rien de l”IL. Le compilateur regarde où va la chaîne et ce qu”il y a dans les trous et choisit :
Le reste de cet article, ce sont ces quatre cases, dans l”ordre, avec l”IL que chacune produit et l”empreinte qu”un décompilateur utilise pour la reconnaître.
Le raccourci du concat
Si chaque trou de la chaîne est déjà une string, et qu”aucun trou n”a d”alignement ni de spécificateur de format, le compilateur fait la chose la moins coûteuse possible : il appelle String.Concat. Étant donné
string Greet(string first, string last) => $"{first} {last}";
le corps est simplement un concat à trois arguments — l”espace littéral n”est qu”un opérande de plus :
ldarg.1 // first
ldstr " "
ldarg.2 // last
call string System.String::Concat(string, string, string)
ret
C”est octet pour octet identique à ce que compile first + " " + last, et identique à un string.Concat(first, " ", last) écrit à la main. Aucun marqueur ne dit « ceci était de l”interpolation ». Un décompilateur est libre de le rendre en $"{first} {last}", en first + " " + last ou en concat explicite : les trois sont des lectures fidèles du même IL. L”interpolation, en tant qu”idée distincte, n”a pas survécu à la compilation.
Le gabarit de format
Dès qu”un trou n”est pas une chaîne — un nombre, un DateTime, tout ce qui a besoin d”un formatage — l”abaissement classique est String.Format avec un gabarit positionnel. C”est ce que vous obtenez sur netstandard2.0, sur .NET Framework et sur toute cible antérieure au type gestionnaire. Pour
string Status(int count, double rate) => $"{count} items at {rate:P1}";
le compilateur construit la chaîne gabarit, encadre les arguments de type valeur dans un object[] et appelle Format :
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
Deux choses méritent d”être remarquées ici, car ce sont exactement celles que vous voyez dans la sortie décompilée. D”abord, votre spécificateur de format :P1 est conservé littéralement dans le gabarit sous la forme {1:P1} — le décompilateur peut le lire directement depuis le ldstr. Ensuite, les types valeur sont encadrés ; l”object[] est le coût d”allocation que porte cet abaissement, et c”est la raison d”être de la forme plus récente du gestionnaire.
Comme il s”agit d”un appel littéral à String.Format, il est indiscernable d”un appel que vous auriez écrit à la main. Un décompilateur montrera l”une des deux formes. Beaucoup impriment l”appel string.Format("{0} items at {1:P1}", count, rate) tel quel, parce que c”est la lecture de plus bas niveau et la plus portable ; un décompilateur conscient de C# peut le re-sucrer en $"{count} items at {rate:P1}" en analysant le gabarit et en remappant {0},{1} sur l”ordre des arguments. Les deux sont correctes.
La danse du gestionnaire
Sur .NET 6 et plus, une simple affectation d”une chaîne interpolée à une string ne passe plus par String.Format. Le compilateur l”abaisse contre DefaultInterpolatedStringHandler, un ref struct de System.Runtime.CompilerServices qui accumule le résultat dans un tampon interne — pas d”analyse de gabarit, pas d”object[], pas d”encadrement des arguments dans le tableau. La même méthode Status sur une cible moderne se décompile en cette forme :
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();
}
Lisez les arguments du constructeur, car ils sont la première moitié de l”empreinte. Le gestionnaire est créé avec (int literalLength, int formattedCount) — le nombre total de caractères littéraux (" items at " en fait neuf, plus toute autre plage de littéraux) et le nombre de trous. Le gestionnaire s”en sert pour dimensionner son tampon d”emblée. Le corps est ensuite un entrelacement strict : un AppendLiteral par plage de texte littéral, un AppendFormatted par trou, dans l”ordre source, achevé par exactement un ToStringAndClear() qui produit la string et rend le tampon. En IL, c”est une suite de call/callvirt contre une variable locale du type gestionnaire :
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
Ce constructeur longueur-des-littéraux-et-compte, les appels alternés AppendLiteral/AppendFormatted<T> sur une unique variable locale du gestionnaire et le ToStringAndClear qui l”achève sont une silhouette que rien d”autre ne produit. Un décompilateur moderne la reconnaît et replie tout le bloc en $"{count} items at {rate:P1}". Un décompilateur qui ne le fait pas — ou qui lit une sortie où l”optimiseur a hissé ou réordonné certains ajouts — montre les appels du gestionnaire littéralement. Ce n”est pas un échec : c”est la lecture honnête, et elle est souvent plus informative que le sucre, car elle vous montre exactement où ont lieu l”allocation du tampon et chaque instanciation générique d”AppendFormatted<T>.
Alignement, format et le piège de la culture
La famille AppendFormatted a plusieurs surcharges, et celle que le compilateur sélectionne vous dit ce qu”il y avait entre les accolades. Un trou nu {x} devient AppendFormatted<T>(T value). Un spécificateur de format {x:N2} devient AppendFormatted<T>(T value, string? format) avec le format en argument string littéral. Un alignement {x,10} devient AppendFormatted<T>(T value, int alignment), et les deux ensemble {x,10:N2} deviennent AppendFormatted<T>(T value, int alignment, string? format). Vous pouvez donc reconstruire le contenu exact des accolades à partir de la surcharge et de ses arguments constants — le texte de format est là, dans l”appel.
Il y a une subtilité de comportement que la vue décompilée rend visible et qui mord quiconque refactorise entre les formes. DefaultInterpolatedStringHandler formate en utilisant la culture courante par défaut, exactement comme string.Format. Si vous avez besoin de l”invariant ou d”une culture précise, la chaîne interpolée doit être passée à une API qui fait transiter un IFormatProvider jusqu”au constructeur du gestionnaire — le compilateur émet alors une surcharge de constructeur prenant le fournisseur. Si vous voyez un DefaultInterpolatedStringHandler construit sans argument fournisseur, le résultat est sensible à la culture ; c”est un vrai détail sémantique caché dans l”IL abaissé, pas du bruit.
Gestionnaires personnalisés : quand la chaîne n”est jamais construite
Le motif du gestionnaire ne se limite pas à construire une string. Toute méthode peut accepter une chaîne interpolée directement en prenant un paramètre dont le type est marqué [InterpolatedStringHandler]. Le compilateur construit alors ce gestionnaire et ajoute dedans, de sorte que la chaîne interpolée est formatée directement dans la destination sans aucune string intermédiaire. L”exemple le plus clair est StringBuilder.Append :
sb.Append($"id={id} name={name}");
ne construit pas de chaîne pour l”ajouter. Cela s”abaisse en la construction d”un StringBuilder.AppendInterpolatedStringHandler qui écrit directement dans sb :
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);
L”indice dans le code décompilé est que le constructeur du gestionnaire prend un troisième argument — le StringBuilder lui-même — et que l”appel final est Append(ref handler) plutôt que Append(string). Cet argument de constructeur supplémentaire provient d”un attribut [InterpolatedStringHandlerArgument("sb")] sur le paramètre, qui dit au compilateur de transmettre le récepteur au gestionnaire.
Le cas le plus frappant est un gestionnaire qui peut décider de ne pas construire la chaîne du tout. Debug.Assert(condition, $"expensive {Describe()} message") ne doit pas payer le message quand l”assertion passe. Le constructeur de son gestionnaire a un paramètre out bool final :
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);
Quand condition vaut true, le constructeur met shouldAppend = false, tout le bloc if est sauté et Describe() n”est jamais appelé. C”est le comportement d”évaluation conditionnelle dont on dépend sans s”en rendre compte, et il n”est visible que dans la forme abaissée — il n”y a aucun moyen d”exprimer « saute l”interpolation » dans la syntaxe $"..." elle-même. Un décompilateur qui replie cela en Debug.Assert(condition, $"...") a raison sur le résultat, mais masque la garde shouldAppend ; passer à la vue IL ou C# abaissé dans Glass.NET est la façon de confirmer que le message est bel et bien sauté sur le chemin chaud.
L”interpolation constante disparaît entièrement
Le dernier cas est celui où il n”y a rien à récupérer. Si chaque partie d”une chaîne interpolée est une constante à la compilation, l”ensemble est replié à la compilation :
const string Version = "2";
const string Banner = $"Acme v{Version}"; // becomes the constant "Acme v2"
Banner est émis comme le littéral de chaîne "Acme v2". Pas de gestionnaire, pas de Format, pas de Concat — les métadonnées contiennent une constante, et toute référence à Banner n”est qu”un ldstr "Acme v2". Un décompilateur montre une simple constante de chaîne, car c”est tout ce qui existe. Il en va de même pour une chaîne interpolée sans trou comme $"ready", qui n”est que le littéral "ready". L”interpolation que le compilateur a pu résoudre statiquement ne laisse aucune trace d”avoir été de l”interpolation.
Ce qu”il faut retenir de la vue décompilée
L”interpolation de chaînes est l”illustration la plus nette d”un thème qui traverse toute cette série : la syntaxe de surface est une requête, et le compilateur y répond différemment selon le contexte. La même $"..." peut être un String.Concat, un String.Format avec un object[] encadré, une séquence de DefaultInterpolatedStringHandler, un ajout direct dans un StringBuilder ou — quand les entrées sont constantes — rien d”autre qu”un littéral cuit dans le code. Seules certaines portent une empreinte assez forte pour reconstruire le $"..." : la danse du gestionnaire et les formes de gestionnaire personnalisé sont reconnaissables ; les formes concat et format sont indiscernables d”appels écrits à la main ; la forme constante a déjà disparu. Un bon décompilateur replie ce qu”il peut prouver et montre les appels abaissés là où il ne peut pas — et les appels abaissés sont souvent l”essentiel, car c”est là que vous voyez l”encadrement, l”allocation du tampon, la dépendance à la culture et la garde shouldAppend que le sucre cache discrètement. Ouvrez l”assembly dans Glass.NET, basculez une méthode en IL et regardez une $"..." d”une ligne se déplier en les appels que le runtime effectue réellement. Pour le flux de travail complet, voyez comment décompiler une DLL .NET en C#.
Essayez Nebula.NET
Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.