Skip to content
← Todas las publicaciones
· Delta1 Labs Glass.NETDecompilación.NETAnálisis a fondo

Decompilar a?.b?.c: cómo el operador condicional nulo se convierte en ramas y una única etiqueta de salto compartida

El operador condicional nulo se lee como un único símbolo pulcro —order?.Customer?.Name—, pero no existe ninguna instrucción ?. en IL. El compilador de C# lo rebaja a comprobaciones de nulo y ramas ordinarias, y el detalle que hace que una cadena se decompile limpiamente es que cada eslabón cortocircuita hacia el mismo destino compartido, no hacia un nido propio. Esta entrada lee el IL real de una cadena de dos eslabones, muestra por qué el primer receptor se comprueba con un brtrue a secas mientras que un resultado intermedio se duplica con dup y se descarta con pop, recorre el lifting a Nullable que convierte s?.Length en un int? con un initobj en el lado nulo y un Nullable..ctor en el otro, cubre las rebajas de ?? y ??= que comparten la misma forma de cortocircuito, y explica cómo Glass.NET reconoce el patrón y pliega las ramas de vuelta a ?. y ?? en lugar de dejarte un laberinto de etiquetas.

El operador condicional nulo es una de las cosas más compactas de C#. order?.Customer?.Name dice “recorre esta cadena y, en el instante en que cualquier eslabón sea nulo, detente y devuélveme null”, en una sola línea, sin llaves y sin temporales. Esa compacidad es por completo una ilusión del lenguaje fuente. No hay ninguna instrucción ?. en IL, ninguna primitiva de “navegación segura” en el runtime. Cuando el compilador termina, el operador se ha convertido exactamente en las cargas, llamadas y ramas condicionales que habrías escrito si hubieras deletreado cada comprobación de nulo a mano. Decompilarlo bien significa reconocer esa forma y volver a poner el ?..

Un eslabón

Empieza por el caso más simple, un único ?. sobre tipos de referencia:

string? name = customer?.Name;   // customer is Customer?, Name is string

El IL rebajado es una comprobación de nulo y una rama:

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

Nada exótico: evaluar el receptor, probarlo con brtrue y, si es nulo, apilar ldnull y saltar al final; si no es nulo, llamar al getter. El resultado de ambas rutas acaba en la misma ranura. Un decompilador que solo hubiera visto este caso podría emparejarlo directamente con un patrón. La razón por la que ?. es interesante de decompilar es lo que ocurre cuando lo encadenas.

Una cadena comparte una única etiqueta de salto

string? name = order?.Customer?.Name;

La especificación de C# exige que toda la cadena cortocircuite como una unidad: si order es nulo, Customer nunca se toca y Name nunca se toca, y el resultado es un único null. Una implementación ingenua podría anidar una comprobación de nulo dentro de otra. El compilador hace algo más limpio y más revelador: da a toda la cadena un único destino de salto compartido, y cada eslabón ramifica hacia él.

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

Dos detalles de este IL cuentan toda la historia. Primero, a la etiqueta Null: se llega desde ambos fallos de eslabón: el fallo del brtrue sobre order cae en ella, y la comprobación de nulo de Customer salta de vuelta a ella con br.s Null. Hay una única salida nula para toda la cadena, no una por eslabón. Ese destino único compartido es la huella de una única cadena ?., y es exactamente lo que permite a un decompilador aplanar el IL de vuelta a order?.Customer?.Name en lugar de a algún order?.(Customer?.Name) anidado con comprobaciones redundantes.

Segundo, fíjate en la asimetría entre las dos comprobaciones de nulo. order es un local, así que el compilador simplemente lo recarga (ldloc.0) y lo prueba con brtrue: recargar un local es gratis, así que no hace falta guardar una copia. Pero Customer es un valor calculado devuelto por una llamada; no puedes recalcularlo barato, así que el compilador hace dup del resultado, prueba la copia y la descarta con pop en la ruta nula. Ese trío dup … brtrue … pop alrededor de un miembro intermedio es la señal de que aquí vive un eslabón interior de una cadena ?..

probar orderbrtrue Link1get_Customerdup · brtrue Link2get_Namecae a → DoneNull:ldnull → DoneDone: stloc nameorder nuloCustomer nuloruta de valor

Tipos de valor: el lifting a Nullable

La cadena anterior producía un string, un tipo de referencia, así que null es simplemente ldnull. Cuando el miembro tras ?. devuelve un tipo de valor no anulable, el tipo del resultado de toda la expresión es su forma anulable —s?.Length es int?, no int— porque tiene que poder ser nulo. El compilador lo implementa con un local Nullable<int> y dos maneras distintas de rellenarlo:

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?)

La ruta nula hace initobj sobre el local, lo que lo pone a cero, y un Nullable<int> a cero es HasValue == false, es decir, default(int?). La ruta de valor llama a get_Length y envuelve el int crudo con newobj Nullable<int>..ctor. Ambas convergen en el local. Este par initobj-en-nulo / Nullable..ctor-en-valor es la firma de un condicional nulo que cruzó de un tipo de valor a su lifting anulable, y un decompilador que lo reconoce muestra s?.Length en lugar de exponer la fontanería de Nullable como código escrito a mano.

La misma primitiva: ?? y ??=

Una vez que ves ?. como “evaluar una vez, comprobar nulo, saltar por encima”, los operadores de fusión son obviamente la misma primitiva con cosas distintas a cada lado de la rama. 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 se duplica con dup para que la copia no nula pueda quedarse en la pila como resultado; si es nulo se descarta con pop y se evalúa b en su lugar. La asignación compuesta a ??= b es la misma prueba envuelta alrededor de un almacenamiento: “si a es nulo, asígnale 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:

Los tres operadores se construyen con evaluar-una-vez, comprobar-nulo, saltar-por-encima. Esa forma compartida es la razón por la que un decompilador los trata como una familia: busca el modismo dup/brtrue/pop y pregunta qué hay en el lado de respaldo: un acceso a miembro significa ?., un valor plano significa ??, un almacenamiento de vuelta en el origen significa ??=.

Qué hace Glass con ello

Glass.NET decompila esto reconociendo el patrón, no transcribiendo las ramas. Cuando ve un receptor evaluado una vez, comprobado contra nulo y cortocircuitado hacia un destino que produce null o default, reconstruye ?.; cuando varios eslabones de ese tipo comparten un único destino de salto, los pliega en una única cadena plana —order?.Customer?.Name— en lugar de en un nido de condicionales. Reconoce la convergencia initobj/Nullable..ctor como un lifting a anulable y escribe s?.Length con el tipo int? correcto, y colapsa el modismo dup/brtrue/pop en ?? o ??= según el respaldo. Los bordes son donde puede degradar a código fuente equivalente pero distinto: un ?. cuyo resultado se descarta en una sentencia (logger?.Flush();) se convierte en un if (logger is not null) porque no hay ningún valor que transportar, y una cadena mezclada con comprobaciones if explícitas que escribiste a mano puede leerse de vuelta como cualquiera de las dos, ya que el IL ya no las distingue. El comportamiento es idéntico; solo la sintaxis superficial puede diferir de lo que tecleaste.

Ese es el hilo conductor de decompilar operadores rebajados: el compilador borra la sintaxis conveniente hasta dejar ramas, y un buen decompilador la recupera conociendo la forma que el compilador siempre emite. Consulta decompilar la máquina de estados del iterador y switch sobre cadenas para ver otros dos operadores que se desvanecen en el IL y solo vuelven si conoces el patrón. Glass.NET es un decompilador .NET gratuito; apúntalo a un ensamblado que se apoye en ?. y observa cómo las ramas se pliegan de vuelta al operador que escribiste.

Prueba Nebula.NET

Endurece tu código .NET en minutos — empieza con la edición gratuita.