Décompiler [CallerArgumentExpression] : le texte source que le compilateur incruste dans vos appels, et comment Glass restaure l'appel propre
Quand vous appelez ArgumentNullException.ThrowIfNull(x) ou un Guard.Against(x) maison, vous passez un argument. L'IL compilé en empile deux : votre valeur et un littéral de chaîne contenant le texte source exact de votre premier argument, car le paramètre porte [CallerArgumentExpression]. L'attribut vit dans les métadonnées du paramètre ; le texte capturé vit comme un simple ldstr à chaque appel. Un décompilateur naïf vous renvoie le second argument redondant. Cet article lit l'IL et le blob de l'attribut, explique pourquoi le littéral est là, et détaille comment Glass.NET détecte l'attribut, confronte le littéral à l'expression reconstruite, supprime l'argument synthétisé et reconstruit la déclaration de méthode avec l'attribut intact.
La plupart des fonctionnalités de compilateur qui touchent un site d’appel laissent derrière elles une forme lisible en retour : une machine à états, une classe d’affichage, un temporaire caché. [CallerArgumentExpression] est inhabituel car ce qu’il laisse derrière lui est une chaîne de votre propre code source, figée dans les arguments de la méthode appelée au moment où vous avez compilé. Décompilez un projet qui utilise ArgumentNullException.ThrowIfNull ou un Guard artisanal et, sans le bon traitement, vous récupérez les noms de vos propres arguments renvoyés sous forme de chaînes littérales. Cet article lit exactement ce qu’émet le compilateur et montre comment Glass.NET détecte l’attribut, confronte le littéral et restaure l’appel que vous avez réellement écrit.
Ici, la décompilation est du genre ordinaire et légitime : lire l’IL pour comprendre, déboguer ou auditer du code que vous avez écrit ou que vous êtes en droit d’inspecter.
La fonctionnalité et ce en quoi elle se compile
[CallerArgumentExpression] permet à un paramètre de recevoir, sous forme de chaîne, l’expression textuelle qu’un appelant a passée pour un autre argument. L’usage canonique est dans la BCL :
public static void ThrowIfNull(
[NotNull] object? argument,
[CallerArgumentExpression(nameof(argument))] string? paramName = null)
{
if (argument is null)
throw new ArgumentNullException(paramName);
}
Appelez-la avec un seul argument :
static void Use(Order order) => ArgumentNullException.ThrowIfNull(order.Customer);
Vous avez passé un argument. Le compilateur en émet deux. Le second est le texte source du premier, capturé mot pour mot :
.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" // <- la caller-expression incrustée
IL_000b: call void [System.Runtime]System.ArgumentNullException::ThrowIfNull(object, string)
IL_0010: ret
}
Ce ldstr "order.Customer" est toute la fonctionnalité au site d’appel. Il n’est pas calculé à l’exécution, ce n’est une référence à aucun symbole : c’est un instantané figé des caractères que vous avez tapés entre les parenthèses, produit par le compilateur lisant son propre arbre syntaxique et restituant en texte l’argument référencé. Changez l’espacement ou le chemin du membre dans le source, recompilez, et le littéral change avec lui. Publiez un nouvel appelant sans recompiler et l’ancien littéral persiste, car le texte a été copié dans l’appelant, non référencé depuis la méthode appelée.
Où vit l’attribut dans les métadonnées
L’appel ne raconte que la moitié de l’histoire. L’indication qu’il s’agit d’un argument d’expression de l’appelant — plutôt que d’une chaîne ordinaire que l’auteur a passée — vit sur le paramètre de la méthode appelée, dans les métadonnées :
.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
...
}
Trois faits sont encodés sur le paramètre 2. L’indicateur [opt] le marque Optional. Le .param [2] = nullref est sa valeur par défaut dans la table Constant : un paramètre d’expression de l’appelant est toujours optionnel avec une valeur par défaut, de sorte que les appelants qui ne veulent pas la capture (ou qui ciblent un compilateur plus ancien) se lient quand même. Et la ligne .custom est le CallerArgumentExpressionAttribute lui-même. Décodez son blob et vous obtenez l’argument du constructeur : 01 00 est le prologue de l’attribut, 08 est la longueur de la chaîne UTF-8 qui suit, 61 72 67 75 6D 65 6E 74 épelle argument, et le 00 00 final est le compte (zéro) de propriétés nommées. L’attribut dit donc, en substance : « quand un appelant m’omet, remplis-moi avec le texte source du paramètre nommé argument. »
C’est le pointeur qu’un décompilateur doit suivre. Le littéral au site d’appel ne signifie rien en soi : seul l’attribut du paramètre 2 vous dit que la chaîne est une capture synthétisée de l’argument 1, et donc candidate à la suppression.
Ce qu’imprime un décompilateur naïf
Un décompilateur qui parcourt l’appel et ne lit que les opérandes voit une invocation à deux arguments parfaitement ordinaire. Il n’a aucune raison de traiter le second opérande comme spécial, alors il imprime les deux :
// Sortie naïve — techniquement fidèle, mais bruyante
ArgumentNullException.ThrowIfNull(order.Customer, "order.Customer");
Ce n’est pas faux — le recompiler produit un IL identique, car un argument explicite l’emporte toujours sur une valeur par défaut d’expression de l’appelant. Mais ce n’est ce que personne n’a écrit, et c’est activement trompeur : cela suggère que l’auteur tenait à cette chaîne, alors qu’en réalité le compilateur l’a synthétisée. Multipliez cela sur une base de code qui s’appuie sur ThrowIfNull, ArgumentException.ThrowIfNullOrEmpty et une bibliothèque de gardes maison, et chaque clause de garde porte un écho redondant de son premier argument. La sortie se lit comme du code écrit par quelqu’un qui ne faisait pas confiance à la fonctionnalité.
Comment Glass détecte et supprime l’argument synthétisé
Glass.NET traite le site d’appel et les métadonnées de la méthode appelée comme deux moitiés d’un même fait. La récupération se déroule en quatre étapes, présentées ci-dessous.
D’abord, résoudre la méthode appelée et lire les métadonnées de ses paramètres. Glass découvre que le paramètre 2 de ThrowIfNull porte CallerArgumentExpressionAttribute("argument"), donc la chaîne en position 2 est un candidat argument synthétisé lié à la position 1.
Ensuite, reconstruire l’expression référencée. Glass dispose déjà d’un arbre syntaxique récupéré pour l’appel : il sait que l’argument 1 est order.Customer. Il restitue cet argument en texte C# canonique exactement comme l’aurait fait le compilateur : accès aux membres, indexeurs, conversions et littéraux reviennent tous à l’orthographe même qu’a utilisée le compilateur en créant la chaîne incrustée.
Troisièmement, normaliser et comparer. Le compilateur capture le texte source y compris les espaces insignifiants employés par l’auteur (ThrowIfNull( order . Customer ) incrusterait "order . Customer"), tandis que le texte régénéré par Glass est canonique. Le comparateur normalise donc les deux côtés pour les espaces insignifiants avant de comparer. Lorsque les chaînes normalisées sont égales, le littéral est l’œuvre du compilateur lui-même.
Quatrièmement, supprimer ou conserver. En cas de correspondance, Glass retire l’argument et imprime l’appel propre :
// Sortie de Glass.NET
ArgumentNullException.ThrowIfNull(order.Customer);
En cas de non-correspondance — l’auteur a écrit ThrowIfNull(order.Customer, "the customer"), remplaçant délibérément la capture, ou le premier argument est quelque chose dont Glass ne peut pas fixer le texte — le littéral est conservé, car le retirer changerait le sens. La règle est conservatrice par conception : Glass n’élimine l’argument que lorsqu’il peut prouver que le littéral est exactement ce que la fonctionnalité régénérerait.
Reconstruire la déclaration
Supprimer l’appel n’est que la moitié d’une décompilation fidèle ; la déclaration de méthode doit reporter l’attribut, sinon recompiler la sortie ne reproduirait pas la capture. Glass lit la ligne .custom du paramètre 2, décode la chaîne de constructeur préfixée par sa longueur (argument), lit l’indicateur Optional et la valeur par défaut nullref de la table Constant, et réémet les trois :
public static void ThrowIfNull(
object? argument,
[CallerArgumentExpression(nameof(argument))] string? paramName = null)
Glass préfère nameof(argument) à une chaîne "argument" nue lorsqu’un paramètre portant ce nom exact est dans la portée, pour coller au source idiomatique ; il se rabat sur la chaîne littérale si un tel paramètre n’existe pas (par exemple si le nom référencé ne se résout pas, ce que le compilateur C# autorise). La valeur par défaut = null et l’annotation nullable string? proviennent directement de la table Constant et du NullableAttribute respectivement, si bien que la signature reconstruite est recompilable telle quelle.
L’essayer avec la CLI
La CLI glass rend l’avant/après visible. Pointez-la vers un assembly et un type, et elle imprime du C# propre par défaut :
glass decompile MyApp.dll --type MyApp.Services.OrderValidator
Pour voir la forme brute, non nettoyée — chaque argument synthétisé laissé en place — désactivez la passe de simplification :
glass decompile MyApp.dll --type MyApp.Services.OrderValidator --no-sugar
Lancer les deux contre un validateur plein de clauses de garde est le moyen le plus rapide de voir ce que la fonctionnalité coûte réellement au site d’appel : avec --no-sugar chaque garde montre son littéral répété ; avec la passe par défaut, chacune se replie vers l’appel à un seul argument que vous avez écrit. Et décompiler le type de garde lui-même montre l’attribut restauré sur le paramètre, de sorte que vous pouvez lire le contrat de capture directement.
À retenir
[CallerArgumentExpression] illustre nettement un principe général des décompilateurs : l’IL est fidèle mais sous-spécifié. Le littéral au site d’appel est réel et recompilable, mais ce n’est pas du source que quiconque a écrit : c’est une copie, au moment de la compilation, du source, et seules les métadonnées du paramètre de la méthode appelée le révèlent. Un décompilateur qui lit l’un sans l’autre vous donne du bruit ; un qui les joint vous rend l’appel que vous avez écrit. Glass.NET trace la ligne avec précision : il ne supprime l’argument synthétisé que lorsqu’il peut prouver que le littéral correspond à l’expression qu’il régénérerait, et il reconstruit l’attribut de la déclaration à partir des métadonnées pour que le source récupéré se compile vers le même IL dont il provient.
Essayez Nebula.NET
Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.