Skip to content
← Tous les articles
· Delta1 Labs Glass.NETDécompilation.NETAnalyse approfondie

Décompiler les types valeur nullables : int? est un struct, un opérateur levé est une vérification de HasValue, et le box qui efface Nullable<T>

int? ressemble à un type avec un null dedans, et dans l'IL ce n'est ni l'un ni l'autre : c'est le struct générique System.Nullable<int>, deux champs et aucun null nulle part, et chaque opération que le langage vous laisse écrire dessus est réécrite par le compilateur en appels sur ce struct. a + b sur deux valeurs int? devient une conjonction de HasValue, deux appels à GetValueOrDefault, un add et un newobj — ou un initobj pour le résultat null. a == b devient une comparaison de valeurs combinée par ET à une comparaison de HasValue. a == null devient un seul appel à HasValue. a ?? 5 devient une conditionnelle sur HasValue. Et boxer un int? fait quelque chose qu'aucun autre struct ne fait : l'instruction box produit soit un int boxé soit une référence null, de sorte que Nullable<T> n'existe jamais sur le tas. Ce billet lit chacun de ces abaissements en IL et suit un décompilateur qui les replie vers les opérateurs que l'auteur a écrits — pourquoi le motif HasValue et GetValueOrDefault doit être reconnu comme une unité, pourquoi int? vient du blob de signature plutôt que d'un attribut (contrairement à string?), comment unbox.any sur une référence null produit discrètement un Nullable vide, et ce qu'un décompilateur rate quand il rend l'abaissement au lieu du langage.

Tout ce que le langage C# vous laisse faire avec int? est un mensonge que le compilateur raconte au nom d’un struct. Il n’y a pas de null dans un int?. Il n’y a pas de type spécial « entier nullable » que le CLR saurait additionner. Il y a System.Nullable<int> — deux champs, seize octets, un type valeur comme les autres — et un compilateur qui réécrit a + b, a == null, a ?? 5 et (object)a en appels sur ce struct si complètement que les opérateurs que vous avez tapés n’atteignent jamais l’IL. Lire un assembly obfusqué ou sans symboles qui utilise de l’arithmétique nullable, c’est lire ces réécritures, et un décompilateur qui les rend littéralement produit du code correct, compilable et illisible.

Ce billet suit chaque abaissement à travers l’IL et retour, et montre ce que Glass.NET doit reconnaître pour vous rendre la ligne que vous avez écrite.

int? est dans le blob de signature, pas dans un attribut

D’abord la partie facile, parce que c’est l’inverse d’un piège que les lecteurs des types référence nullables ont appris à attendre. string? laisse le type intact et enregistre la nullabilité dans des attributs [Nullable] — des annotations de compilation que le runtime ignore. int? est un type différent : le struct générique Nullable<T> instancié sur int, et il apparaît tel quel dans chaque signature :

.method public hidebysig static valuetype [System.Runtime]System.Nullable`1<int32>
        Add(valuetype [System.Runtime]System.Nullable`1<int32> a,
            valuetype [System.Runtime]System.Nullable`1<int32> b) cil managed

Un décompilateur affiche int? pour valuetype System.Nullable`1<int32> selon la même règle qu’il affiche int pour System.Int32 — l’abréviation du langage pour un type connu. Aucun attribut n’est consulté ; rien ne peut être retiré qui le ferait perdre. Le durcissement des métadonnées qui supprime les annotations [Nullable] laisse int? complètement intact, parce que l’information n’a jamais été une annotation.

Le struct lui-même, ce sont deux champs et une poignée de membres, et ces membres sont tout le vocabulaire de chaque abaissement qui suit :

public struct Nullable<T> where T : struct
{
    private readonly bool hasValue;
    internal T value;
    public Nullable(T value);              // the "has a value" constructor
    public bool HasValue { get; }
    public T Value { get; }                // throws InvalidOperationException when !hasValue
    public T GetValueOrDefault();          // returns value unconditionally — no check, no throw
}

default(Nullable<T>) — les deux champs à zéro — est ce que le langage appelle null.

Arithmétique levée : une conjonction, deux GetValueOrDefault et un newobj

Prenez la méthode dont la signature apparaît ci-dessus :

static int? Add(int? a, int? b) => a + b;

Le + sur deux valeurs int? est un opérateur levé : le résultat est null si l’un des opérandes est null, sinon la somme. Le compilateur émet cette règle directement :

  .locals init (valuetype Nullable`1<int32> V_0,
                valuetype Nullable`1<int32> V_1,
                valuetype Nullable`1<int32> V_2)
  IL_0000: ldarg.0
  IL_0001: stloc.0                                   // copy a to a temp (operands evaluated once)
  IL_0002: ldarg.1
  IL_0003: stloc.1                                   // copy b
  IL_0004: ldloca.s   V_0
  IL_0006: call       instance bool Nullable`1<int32>::get_HasValue()
  IL_000b: ldloca.s   V_1
  IL_000d: call       instance bool Nullable`1<int32>::get_HasValue()
  IL_0012: and                                       // both have a value?
  IL_0013: brtrue.s   IL_0020
  IL_0015: ldloca.s   V_2
  IL_0017: initobj    Nullable`1<int32>              // the null result: default(Nullable<int>)
  IL_001d: ldloc.2
  IL_001e: ret
  IL_0020: ldloca.s   V_0
  IL_0022: call       instance !0 Nullable`1<int32>::GetValueOrDefault()
  IL_0027: ldloca.s   V_1
  IL_0029: call       instance !0 Nullable`1<int32>::GetValueOrDefault()
  IL_002e: add
  IL_002f: newobj     instance void Nullable`1<int32>::.ctor(!0)
  IL_0034: ret

Trois détails méritent attention, parce que c’est sur eux qu’un décompilateur s’appuie :

  • Les opérandes sont d’abord copiés dans des temporaires, de sorte que chacun est évalué exactement une fois bien qu’il soit lu deux fois (une pour HasValue, une pour la valeur). Une version écrite à la main ne s’en donnerait généralement pas la peine.
  • La valeur est lue avec GetValueOrDefault, pas Value. HasValue vient d’être vérifié, donc la revérification dans Value serait redondante et son chemin de levée inatteignable ; GetValueOrDefault est une simple lecture de champ. Voir GetValueOrDefault immédiatement après une garde HasValue est le signal le plus fort que vous regardez une sortie de compilateur et non du code source.
  • Le résultat null est un initobj, pas un appel — il n’y a pas de « constructeur null » ; l’état null est le struct mis à zéro.

Rendez cela littéralement et vous obtenez :

// faithful to the IL, and not what anyone wrote:
int? num = a, num2 = b;
return (num.HasValue & num2.HasValue)
    ? new int?(num.GetValueOrDefault() + num2.GetValueOrDefault())
    : null;

Cela compile et est sémantiquement exact. Ce sont aussi trois lignes, deux temporaires et quatre appels pour un seul +. Glass reconnaît la forme entière — temporaires, conjonction de HasValue, une paire de GetValueOrDefault alimentant un opérateur binaire, newobj sur la branche vraie et initobj sur la fausse — et émet a + b. Le même gabarit couvre tous les opérateurs arithmétiques, bit à bit et de décalage levés ; seule l’instruction entre les deux appels à GetValueOrDefault change.

+ levé : abaissement et repliagesourceint? r = a + b;gardeV_0=a; V_1=bV_0.HasValue & V_1.HasValuebranche fausse → nullinitobj Nullablebranche vraie → valeurGetValueOrDefault ×2; add; newobjdécompilé (Glass)int? r = a + b;signaux d'un abaissementGetValueOrDefault lujuste après une gardeHasValue (jamais .Value)opérandes copiés dansdes temporaires d'abordinitobj — pas un appel —pour le résultat nullun opérateur par formeRendu littéral : (V_0.HasValue & V_1.HasValue) ? new int?(V_0.GetValueOrDefault() + V_1.GetValueOrDefault()) : null

L’égalité levée est une forme différente

La comparaison n’est pas abaissée de la même façon, parce que la règle est différente : deux null sont égaux, et un null est différent de toute valeur. Le compilateur produit une expression sans branchement pour cela :

static bool Same(int? a, int? b) => a == b;
  IL_0004: ldloca.s   V_0
  IL_0006: call       instance !0 Nullable`1<int32>::GetValueOrDefault()
  IL_000b: ldloca.s   V_1
  IL_000d: call       instance !0 Nullable`1<int32>::GetValueOrDefault()
  IL_0012: ceq                                       // values equal (both 0 when both null)
  IL_0014: ldloca.s   V_0
  IL_0016: call       instance bool Nullable`1<int32>::get_HasValue()
  IL_001b: ldloca.s   V_1
  IL_001d: call       instance bool Nullable`1<int32>::get_HasValue()
  IL_0022: ceq                                       // same has-value state
  IL_0024: and
  IL_0025: ret

C’est (a.GetValueOrDefault() == b.GetValueOrDefault()) & (a.HasValue == b.HasValue). Parcourez les cas : les deux null → 0 == 0 et false == false → vrai. Un null, un 5 → 0 == 5 est faux → faux. Les deux 5 → vrai. Notez que GetValueOrDefault est appelé ici sans garde HasValue préalable — il est utilisé précisément parce qu’il est sûr sur un null — donc le signal « GetValueOrDefault après garde » du cas arithmétique ne s’applique pas, et le décompilateur a besoin d’un second gabarit pour cette forme. <, >, <=, >= s’abaissent en la comparaison de valeurs combinée par ET à a.HasValue & b.HasValue (deux null ne sont pas inférieurs l’un à l’autre), ce qui est une troisième variante de la même idée.

== null, ??, et le motif is int v

Les formes plus simples ont des abaissements plus simples, chacun correspondant à un seul gabarit :

bool IsNull(int? a) => a == null;        // ldarga a; call get_HasValue; ldc.i4.0; ceq
int  OrFive(int? a) => a ?? 5;           // a.HasValue ? a.GetValueOrDefault() : 5
int  Force (int? a) => (int)a;           // ldarga a; call get_Value   — the only place Value appears
bool Test  (int? a) => a is int v && v > 0;   // a.HasValue && a.GetValueOrDefault() > 0

== null ne compare jamais une référence : il n’y a pas de référence. C’est !a.HasValue, et != null est a.HasValue. ?? devient une conditionnelle sur HasValue avec GetValueOrDefault sur la branche de valeur — là encore pas Value, parce que la garde a déjà été exécutée. La conversion explicite (int)a est le seul abaissement qui appelle get_Value, parce que c’est le seul dont le contrat est de lever quand il n’y a rien ; un décompilateur peut s’en servir comme indice. Et le motif de type a is int v sur un nullable est abaissé en HasValue plus GetValueOrDefault dans v, sans aucun isinst nulle part — le test de type à l’exécution auquel le motif ressemble n’a jamais lieu, parce que le compilateur sait déjà ce que contient le struct.

Le box qui efface Nullable<T>

Tout ce qui précède est le compilateur réécrivant du langage en appels. Le boxing est différent : ici le runtime fait quelque chose de spécial, et l’IL en dit moins qu’il n’y paraît.

object Box(int? a)   => a;              // box valuetype Nullable`1<int32>
int?   Unbox(object o) => (int?)o;      // unbox.any valuetype Nullable`1<int32>

Boxer un struct ordinaire le copie sur le tas derrière un en-tête d’objet. L’instruction box appliquée à Nullable<T> ne fait pas cela. Le runtime vérifie hasValue : s’il est false, le résultat est une référence null ; s’il est true, le résultat est un T boxé — un objet du tas de type System.Int32, identique octet pour octet au boxing d’un int ordinaire. Nullable<int> lui-même n’atteint jamais le tas. Les conséquences sont visibles depuis C# :

object o = (int?)5;
o.GetType()            // typeof(int) — not Nullable<int>
o is int               // true
o is int?              // true — same object satisfies both
object n = (int?)null; // n == null; there is no "boxed empty nullable"

unbox.any Nullable<int32> est l’image miroir : étant donné un int boxé, il construit un Nullable<int> avec une valeur ; étant donné une référence null, il produit un Nullable<int> vide au lieu de lever la NullReferenceException qu’unbox.any sur tout autre type valeur lèverait. Donc (int?)o est la seule conversion en C# qui transforme discrètement null en valeur par défaut d’un type valeur.

Pour un décompilateur, c’est un cas où il ne faut pas inventer de code. Les instructions box et unbox.any nomment Nullable<int32> dans leur opérande de type, et la tentation est de les rendre comme un appel de conversion ou un new int?(…). L’auteur a écrit une simple conversion implicite et une simple conversion explicite ; le comportement spécial vit dans l’implémentation par le runtime de deux instructions ordinaires, et la bonne sortie est object o = a; et (int?)o, rien de plus.

Ce qu’un décompilateur rate, et pourquoi c’est important

Trois modes de défaillance apparaissent en pratique, et ce sont tous des variantes du rendu de l’abaissement au lieu du langage :

  1. La danse HasValue/GetValueOrDefault imprimée en entier — correcte, verbeuse, et un signe fiable que l’outil n’a pas de gabarits d’opérateurs levés. Une méthode avec six additions nullables devient quarante lignes de temporaires.
  2. new int?(x) là où la source avait x — la conversion implicite d’int vers int? est un newobj dans l’IL, et imprimer l’appel au constructeur est fidèle mais bruyant. Glass émet la conversion implicitement partout où le type cible la rend non ambiguë.
  3. Un appel de conversion autour de box/unbox.any — inventer une méthode qui n’est pas dans l’IL pour expliquer un comportement qui est dans le runtime.

La raison pour laquelle cela compte au-delà de l’esthétique est que l’arithmétique nullable se concentre exactement dans le code qu’un lecteur essaie généralement de comprendre — configuration optionnelle, calculs financiers avec des états « non défini », logique métier riche en motifs — et l’abaissement multiplie sa longueur par trois ou quatre. Un décompilateur qui replie les gabarits vous donne les cinq lignes de l’auteur ; un qui ne le fait pas vous donne une page d’appels de struct qui se ressemblent tous.

La leçon sous-jacente est la même que celle qui régit ?. et ?? et foreach : le compilateur C# abaisse les fonctionnalités du langage en IL ordinaire sur des types ordinaires, et le travail du décompilateur est de connaître chacune de ces formes assez bien pour les exécuter à l’envers. Nullable<T> n’est inhabituel que par la complétude avec laquelle il disparaît — en un struct à l’entrée, et en un simple int boxé ou un null à la sortie.

Essayez Nebula.NET

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