Décompiler les arguments optionnels et nommés : les valeurs par défaut dans la table Constant et les temporaires que le compilateur émet pour le réordonnancement
Un paramètre optionnel n'a aucun coût à l'exécution au site d'appel que vous avez écrit — la valeur par défaut n'est pas stockée dans la méthode, elle est substituée par le compilateur dans chaque appelant. Donc en IL il n'y a aucun signe d'un argument par défaut, sauf à l'appel : un littéral poussé là où vous n'aviez rien écrit. La valeur par défaut elle-même vit loin de là, dans la table de métadonnées Constant attachée au paramètre, à côté d'un indicateur Optional ; un décompilateur qui lit la méthode doit aller l'y chercher pour afficher = 0 ou = null. Les arguments nommés sont plus étranges encore : ils disparaissent entièrement lorsqu'ils sont dans l'ordre, et lorsqu'ils sont dans le désordre le compilateur les réécrit en poussées positionnelles dans l'ordre — à moins que les évaluer dans le désordre ait des effets de bord, auquel cas il déverse d'abord chaque argument dans un temporaire pour préserver l'évaluation de gauche à droite, laissant un bloc de stockages qui ressemblent à du code que vous auriez écrit. Cet article lit l'IL et les métadonnées des deux, montre les lignes de la table Constant derrière une valeur par défaut, parcourt les temporaires de réordonnancement, et explique comment Glass.NET récupère les valeurs par défaut optionnelles et décide quand un argument nommé vaut la peine d'être affiché.
La plupart des fonctionnalités C# laissent en IL une forme que l’on peut relire. Les arguments optionnels et nommés sont inhabituels parce que la partie intéressante est l’absence : un argument optionnel que l’on omet, un nom que l’on écrit. Ni l’un ni l’autre ne survit en tant que tel. La valeur par défaut est classée dans une table de métadonnées que le corps de la méthode ne mentionne jamais, et le nom disparaît dès que le compilateur l’a associé à un paramètre. Ce qu’un décompilateur récupère, et ce qu’il admet discrètement ne pas pouvoir récupérer, fait de ces deux fonctionnalités une bonne leçon sur l’endroit où la compilation perd de l’information.
Cet article lit les métadonnées et l’IL des deux, et montre comment Glass.NET gère chacune.
Un paramètre optionnel : la valeur par défaut vit dans la table Constant
Voici une méthode avec deux paramètres optionnels et un appel qui les omet :
public static string Format(string value, int width = 0, char pad = ' ')
=> value.PadLeft(width, pad);
static void Use() => Format("hi"); // width and pad omitted
Les valeurs par défaut ne sont nulle part dans le corps de Format — Format utilise simplement width et pad comme paramètres. Elles vivent dans les métadonnées sur les paramètres eux-mêmes. Dans le rendu d’ILDasm :
.method public hidebysig static string Format(string value,
[opt] int32 width,
[opt] char pad) cil managed
{
.param [2] = int32(0x00000000)
.param [3] = char(0x0020)
...
}
[opt] est l’indicateur Optional sur chaque paramètre, et .param [2] = int32(0x00000000) est la façon dont ILDasm affiche une ligne de la table de métadonnées Constant. Cette table est une structure à trois colonnes — un tag de type, un parent (ici la ligne Param), et un blob des octets du littéral — et c’est la même table qui soutient les champs const et les membres d’énumération. La ligne du paramètre porte aussi l’indicateur HasDefault, qui est le pointeur qui dit « va chercher dans la table Constant la ligne de mon parent ». Trois pièces encodent donc une valeur par défaut : Optional sur le paramètre, HasDefault sur le paramètre, et la valeur dans la table Constant.
De façon cruciale, width = 0 et pad = ' ' apparaissent dans les métadonnées de signature de Format, non dans Use. Donc pour décompiler Format et afficher sa signature, un décompilateur doit lire les lignes de la table Constant pour les paramètres 2 et 3, décoder int32(0) et char(0x0020), et rendre int width = 0, char pad = ' '. C’est une lecture de métadonnées, non une inférence, et cela fonctionne sans PDB, car la table Constant fait partie de l’assembly sur lequel le runtime lui-même s’appuie.
Le site d’appel : les valeurs par défaut sont substituées, non référencées
Passons maintenant à la moitié subtile. Regardez ce en quoi Use s’est réellement compilé :
.method private hidebysig static void Use() cil managed
{
IL_0000: ldstr "hi"
IL_0005: ldc.i4.0 // width — the default, baked in
IL_0006: ldc.i4.s 32 // pad — ' ' is 0x20 = 32, baked in
IL_0008: call string Format(string, int32, char)
IL_000d: pop
IL_000e: ret
}
Vous avez écrit Format("hi"). Le compilateur a poussé trois arguments. Les valeurs par défaut n’ont pas été récupérées dans la table Constant à l’exécution — le compilateur les a lues à la compilation et a émis ldc.i4.0 et ldc.i4.s 32 directement dans Use. L’appel est un appel à trois arguments parfaitement ordinaire ; rien ne distingue les deux littéraux d’arguments qu’un programmeur aurait tapés.
Cela a une conséquence réelle qu’il vaut la peine de décompiler pour la voir : parce que la valeur par défaut a été copiée dans l’appelant, changer la valeur par défaut de Format de 0 à 1 et livrer une nouvelle bibliothèque ne change pas le comportement de Use tant que Use n’est pas recompilé — il pousse toujours ldc.i4.0. Un décompilateur qui lit uniquement l’ancien appelant ne peut même pas dire que la valeur par défaut a changé. C’est la règle classique « les paramètres optionnels sont une fonctionnalité de compatibilité de source, non de compatibilité binaire », et elle est visible ici même dans le littéral figé.
Pour le décompilateur, cela crée une ambiguïté qu’il ne peut pas résoudre entièrement : ldc.i4.0 à l’emplacement de width est compatible à la fois avec Format("hi") et avec Format("hi", 0). L’IL est identique. Glass.NET résout l’appelé, voit que les arguments littéraux de queue égalent les valeurs par défaut des paramètres, et préfère la forme plus courte Format("hi") comme source la plus probable — mais il conserve un argument lorsque le supprimer changerait la résolution de surcharge, et il affiche tous les arguments lorsque l’appelé est dans un assembly qu’il ne peut pas résoudre pour en lire les valeurs par défaut. Il affiche l’appel qui est une source valide pour l’IL, tout en étant honnête sur le fait que omis-contre-écrit est l’une des choses que la compilation efface véritablement.
Arguments nommés, dans l’ordre : rien du tout
Les arguments nommés sont un dispositif d’association à la compilation. Lorsqu’ils sont déjà dans l’ordre de déclaration, ils ne laissent absolument aucune trace :
Format(value: "hi", width: 4, pad: '.');
se compile exactement en les mêmes ldstr / ldc.i4.4 / ldc.i4.s 46 / call que la forme positionnelle. Les noms n’ont servi qu’à lier chaque expression à un paramètre ; une fois liés, le compilateur émet des poussées positionnelles. Il n’y a rien dans l’assembly qui dise qu’un nom a été écrit, de sorte qu’un décompilateur ne peut pas — et ne devrait pas prétendre — reconstruire width: et pad:. Glass.NET affiche l’appel positionnel, qui est comportementalement identique.
Arguments nommés, dans le désordre : réordonnancement, et parfois déversement
Le seul cas où les arguments nommés touchent à la génération de code est lorsqu’ils sont écrits hors de l’ordre des paramètres, car la pile doit tout de même être construite dans l’ordre des paramètres. Il y a deux sous-cas, et ils se présentent différemment en IL.
Si chaque argument dans le désordre est sans effet de bord — une constante, un local, une lecture de champ que le compilateur juge sûre — le compilateur se contente de les pousser dans l’ordre des paramètres :
Format(width: 4, value: "hi"); // 'pad' omitted → default
IL_0000: ldstr "hi" // value pushed first — parameter order
IL_0005: ldc.i4.4 // width
IL_0006: ldc.i4.s 32 // pad default
IL_0008: call string Format(string, int32, char)
Remarquez que le ldstr "hi" vient en premier même si width: 4 a été écrit en premier : l’ordre observable n’importe pas pour une constante et un littéral, donc le compilateur réordonne discrètement vers l’ordre des paramètres et la forme source est irrécupérable.
Mais lorsque évaluer dans le désordre d’écriture serait observable — un effet de bord, un accesseur, une incrémentation — la garantie d’évaluation de gauche à droite de C# force le compilateur à déverser dans des temporaires :
int n = 0;
Format(width: Next(ref n), value: Read(ref n)); // both have side effects, written out of order
.locals init ([0] int32 n, [1] int32 V_1, [2] string V_2)
IL_...: ldloca.s n
IL_...: call int32 Next(int32&)
IL_...: stloc.1 // V_1 = Next(ref n) — evaluated FIRST (written first)
IL_...: ldloca.s n
IL_...: call string Read(int32&)
IL_...: stloc.2 // V_2 = Read(ref n) — evaluated SECOND
IL_...: ldloc.2 // now push in PARAMETER order: value …
IL_...: ldloc.1 // … then width
IL_...: ldc.i4.s 32 // pad default
IL_...: call string Format(string, int32, char)
Les deux temporaires V_1 et V_2 sont l’empreinte. Ils sont créés dans l’ordre d’écriture (Next en premier, car width: a été écrit en premier) et consommés dans l’ordre des paramètres (value en premier sur la pile). Ce croisement — évaluer dans un ordre, pousser dans un autre — est la seule preuve que des arguments nommés ont été réordonnés. Et ce n’est pas suffisant pour reconstruire les noms : exactement le même motif de déversement en temporaires est ce que le compilateur émet pour l’évaluation ordinaire de gauche à droite d’arguments positionnels qui se trouvent avoir besoin d’un séquencement, et un programmeur aurait pu écrire ces temporaires à la main. Donc Glass.NET rend fidèlement le déversement sous forme de temporaires et d’un appel positionnel, plutôt que de deviner une syntaxe width:/value: que l’IL ne fixe pas réellement.
Ce que Glass.NET affiche, et ce qu’il n’inventera pas
En mettant les deux fonctionnalités ensemble, la récupération se divise nettement selon la ligne de ce que les métadonnées contiennent réellement :
- Les valeurs par défaut optionnelles sur une signature sont récupérées exactement, parce qu’elles sont dans la table Constant : Glass.NET lit les indicateurs
HasDefault/Optionalet la ligne Constant de chaque paramètre et afficheint width = 0, char pad = ' 'avec le littéral décodé par son tag de type —char(0x0020)comme' ', une valeur par défaut d’énumération comme le membre nommé, un structdefaultcommedefault. - Les arguments omis à un appel sont récupérés en résolvant l’appelé et en comparant les littéraux de queue aux valeurs par défaut, puis en préférant la forme la plus courte qui se lie encore à la même surcharge — et en se repliant sur l’affichage de chaque argument lorsque l’appelé ne peut pas être lu ou lorsqu’une omission changerait la résolution de surcharge.
- Les noms des arguments nommés ne sont pas inventés. Lorsque l’ordre de poussée correspond à l’ordre des paramètres, l’appel est affiché positionnellement. Lorsqu’un déversement en temporaires montre un croisement ordre-d’écriture/ordre-des-paramètres, les temporaires sont affichés pour ce qu’ils sont, et l’appel reste positionnel. Les noms ont été effacés à la compilation, et un décompilateur qui fabriquerait
width:/value:habillerait une supposition en fait.
Ce dernier point est le thème que les deux fonctionnalités partagent. La décompilation récupère ce que le compilateur a stocké — et pour les arguments optionnels et nommés, le compilateur a stocké la valeur par défaut (dans une table que l’on peut lire) mais a jeté le nom et le vous-avez-omis-ceci (en substituant un littéral que l’on ne peut pas distinguer). Un bon décompilateur trace cette ligne ouvertement : il affiche la valeur par défaut parce qu’elle est réellement là, et il refuse de reconstruire le nom parce qu’il n’y est réellement pas.
Essayez Nebula.NET
Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.