Décompiler a?.b?.c : comment l'opérateur conditionnel null devient des branchements et une seule étiquette de saut partagée
L'opérateur conditionnel null se lit comme un seul symbole bien net — order?.Customer?.Name — mais il n'existe aucune instruction ?. en IL. Le compilateur C# l'abaisse en contrôles de nullité et branchements ordinaires, et le détail qui fait qu'une chaîne se décompile proprement est que chaque maillon court-circuite vers la même cible partagée, et non vers un nid de cibles propres. Cet article lit le vrai IL d'une chaîne à deux maillons, montre pourquoi le premier récepteur est contrôlé par un simple brtrue tandis qu'un résultat intermédiaire est dupliqué par dup puis dépilé par pop, détaille l'élévation Nullable qui transforme s?.Length en int? avec un initobj du côté null et un Nullable..ctor de l'autre, couvre les abaissements de ?? et ??= qui partagent la même forme de court-circuit, et explique comment Glass.NET reconnaît le motif et replie les branchements en ?. et ?? au lieu de vous laisser un labyrinthe d'étiquettes.
L’opérateur conditionnel null est l’une des choses les plus compactes de C#. order?.Customer?.Name dit « parcours cette chaîne, et dès qu’un maillon est null, arrête-toi et donne-moi null » — en une seule ligne, sans accolades ni temporaires. Cette compacité est entièrement une illusion du langage source. Il n’y a pas d’instruction ?. en IL, pas de primitive de « navigation sûre » dans le runtime. Quand le compilateur a terminé, l’opérateur est devenu exactement les chargements, appels et branchements conditionnels que vous auriez écrits si vous aviez explicité chaque contrôle de nullité à la main. Bien le décompiler, c’est reconnaître cette forme et remettre le ?. en place.
Un seul maillon
Commençons par le cas le plus simple, un seul ?. sur des types référence :
string? name = customer?.Name; // customer is Customer?, Name is string
L’IL abaissé est un contrôle de nullité et un branchement :
ldarg.1 // customer
brtrue.s HasValue
ldnull // customer was null -> the whole expression is null
br.s Done
HasValue:
ldarg.1
callvirt instance string Customer::get_Name()
Done:
stloc.0 // name
Rien d’exotique : évaluer le récepteur, le tester avec brtrue, et sur null pousser ldnull et sauter à la fin ; sur non null, appeler le getter. Le résultat des deux chemins atterrit dans le même emplacement. Un décompilateur qui n’aurait jamais vu que ce seul cas pourrait le reconnaître directement par motif. Ce qui rend ?. intéressant à décompiler, c’est ce qui se passe quand vous le chaînez.
Une chaîne partage une seule étiquette de saut
string? name = order?.Customer?.Name;
La spécification C# exige que la chaîne entière court-circuite comme une unité — si order est null, Customer n’est jamais touché et Name n’est jamais touché, et le résultat est un seul null. Une implémentation naïve pourrait imbriquer un contrôle de nullité dans un autre. Le compilateur fait quelque chose de plus propre et de plus révélateur : il donne à la chaîne entière une seule cible de saut partagée, et chaque maillon s’y branche.
ldloc.0 // order
brtrue.s Link1
Null: // <-- the single shared skip target for the whole chain
ldnull
br.s Done
Link1:
ldloc.0
callvirt instance Customer Order::get_Customer()
dup // intermediate result is a computed value, not a local
brtrue.s Link2
pop // discard the null Customer we just dup-ed
br.s Null // reuse the shared target
Link2:
callvirt instance string Customer::get_Name()
Done:
stloc.1 // name
Deux détails de cet IL portent toute l’histoire. Premièrement, l’étiquette Null: est atteinte depuis les deux échecs de maillon — le brtrue manqué sur order y tombe en continuation, et le contrôle de nullité de Customer y revient par br.s Null. Il y a une seule sortie null pour la chaîne entière, pas une par maillon. Cette unique cible partagée est l’empreinte d’une seule chaîne ?., et c’est exactement ce qui permet à un décompilateur d’aplatir l’IL en order?.Customer?.Name au lieu d’un order?.(Customer?.Name) imbriqué avec des contrôles redondants.
Deuxièmement, remarquez l’asymétrie entre les deux contrôles de nullité. order est une variable locale, le compilateur la recharge donc simplement (ldloc.0) et la teste avec brtrue — recharger une locale est gratuit, inutile d’en garder une copie. Mais Customer est une valeur calculée, renvoyée par un appel ; on ne peut pas la recalculer à bon compte, le compilateur fait donc dup sur le résultat, teste la copie et la pop sur le chemin null. Ce trio dup … brtrue … pop autour d’un membre intermédiaire est le signe qu’un maillon intérieur d’une chaîne ?. vit ici.
Types valeur : l’élévation Nullable
La chaîne ci-dessus produisait une string, un type référence, donc null est simplement ldnull. Lorsque le membre après ?. renvoie un type valeur non nullable, le type de résultat de l’expression entière est sa forme nullable — s?.Length est int?, pas int, car il doit pouvoir être null. Le compilateur implémente cela avec une locale Nullable<int> et deux façons différentes de la remplir :
int? length = s?.Length; // s is string?, Length is int -> result is int?
ldarg.1 // s
brtrue.s HasValue
ldloca.s V_0 // address of the int? local
initobj valuetype [System.Runtime]System.Nullable`1<int32>
ldloc.0 // the default(int?) we just built: HasValue == false
br.s Done
HasValue:
ldarg.1
callvirt instance int32 System.String::get_Length()
newobj instance void valuetype Nullable`1<int32>::.ctor(!0) // wrap the int
Done:
stloc.0 // length (an int?)
Le chemin null fait initobj sur la locale, ce qui la met à zéro — et un Nullable<int> mis à zéro a HasValue == false, c’est-à-dire default(int?). Le chemin valeur appelle get_Length et enveloppe l’int brut avec newobj Nullable<int>..ctor. Les deux convergent sur la locale. Cette paire initobj-côté-null / Nullable..ctor-côté-valeur est la signature d’un conditionnel null qui est passé d’un type valeur à son élévation nullable, et un décompilateur qui la reconnaît rend s?.Length au lieu de faire remonter la plomberie Nullable comme du code écrit à la main.
La même primitive : ?? et ??=
Une fois que vous voyez ?. comme « évaluer une fois, tester la nullité, sauter par-dessus », les opérateurs de coalescence sont évidemment la même primitive avec des choses différentes de chaque côté du branchement. a ?? b :
ldloc.0 // a
dup
brtrue.s Use // a is non-null: keep it, skip b
pop // drop the null
ldloc.1 // b
Use:
a est dupliqué par dup pour que la copie non nulle puisse rester sur la pile comme résultat ; s’il est null, il est dépilé par pop et b est évalué à la place. L’affectation composée a ??= b est le même test enroulé autour d’un stockage — « si a est null, affecte-lui b » :
ldsfld Config::_cache
dup
brtrue.s Keep // already set: keep it as the value
pop
call Config::Build()
dup
stsfld Config::_cache // assign, and leave the value on the stack
Keep:
Les trois opérateurs sont construits à partir d’évaluer-une-fois, tester-la-nullité, sauter-par-dessus. Cette forme partagée est la raison pour laquelle un décompilateur les traite comme une famille : il cherche l’idiome dup/brtrue/pop et regarde ce qui se trouve du côté du repli — un accès au membre signifie ?., une valeur simple signifie ??, un stockage vers la source signifie ??=.
Ce que Glass en fait
Glass.NET décompile ces constructions en reconnaissant le motif, pas en transcrivant les branchements. Lorsqu’il voit un récepteur évalué une fois, testé pour la nullité et court-circuité vers une cible qui produit null ou default, il reconstruit ?. ; lorsque plusieurs maillons de ce type partagent une seule cible de saut, il les replie en une seule chaîne à plat — order?.Customer?.Name — plutôt qu’en un nid de conditionnelles. Il reconnaît la convergence initobj/Nullable..ctor comme une élévation nullable et écrit s?.Length avec le bon type int?, et il réduit l’idiome dup/brtrue/pop en ?? ou ??= selon le repli. Les cas limites sont ceux où il peut dégrader vers un source équivalent mais différent : un ?. dont le résultat est ignoré dans une instruction (logger?.Flush();) devient un if (logger is not null) car il n’y a aucune valeur à transporter, et une chaîne mêlée à des contrôles if explicites que vous avez écrits à la main peut se relire sous l’une ou l’autre forme, puisque l’IL ne les distingue plus. Le comportement est identique ; seule la syntaxe de surface peut différer de ce que vous avez tapé.
C’est le fil conducteur de la décompilation des opérateurs abaissés : le compilateur efface la syntaxe commode jusqu’aux branchements, et un bon décompilateur la regagne en connaissant la forme que le compilateur émet toujours. Voir décompiler la machine à états d’itérateur et switch sur chaînes pour deux autres opérateurs qui disparaissent dans l’IL et ne reviennent que si vous connaissez le motif. Glass.NET est un décompilateur .NET gratuit ; pointez-le sur un assembly qui s’appuie sur ?. et regardez les branchements se replier en l’opérateur que vous avez écrit.
Essayez Nebula.NET
Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.