Decompilar tipos de valor anulables: int? es un struct, un operador elevado es una comprobación de HasValue, y el box que borra Nullable<T>
int? parece un tipo con un null dentro, y en el IL no es ninguna de las dos cosas: es el struct genérico System.Nullable<int>, dos campos y ningún null en ninguna parte, y toda operación que el lenguaje te deja escribir sobre él la reescribe el compilador en llamadas a ese struct. a + b sobre dos valores int? se convierte en una conjunción de HasValue, dos llamadas a GetValueOrDefault, un add y un newobj — o un initobj para el resultado nulo. a == b se convierte en una comparación de valores con AND de una comparación de HasValue. a == null se convierte en una sola llamada a HasValue. a ?? 5 se convierte en un condicional sobre HasValue. Y hacer box de un int? hace algo que ningún otro struct hace: la instrucción box produce o bien un int boxeado o bien una referencia nula, así que Nullable<T> nunca existe en el heap. Esta entrada lee cada una de esas rebajas en IL y sigue a un decompilador mientras las pliega de vuelta a los operadores que el autor escribió — por qué el patrón HasValue y GetValueOrDefault tiene que reconocerse como unidad, por qué int? sale del blob de firma y no de ningún atributo (a diferencia de string?), cómo unbox.any sobre una referencia nula produce silenciosamente un Nullable vacío, y qué hace mal un decompilador cuando renderiza la rebaja en lugar del lenguaje.
Todo lo que el lenguaje C# te deja hacer con int? es una mentira que el compilador cuenta en nombre de un struct. No hay ningún null en un int?. No hay ningún tipo especial de «entero anulable» que el CLR sepa sumar. Hay System.Nullable<int> — dos campos, dieciséis bytes, un tipo de valor como cualquier otro — y un compilador que reescribe a + b, a == null, a ?? 5 y (object)a en llamadas a ese struct tan a fondo que los operadores que tecleaste nunca llegan al IL. Leer un ensamblado ofuscado o sin símbolos que usa aritmética anulable significa leer esas reescrituras, y un decompilador que las renderiza literalmente produce código correcto, compilable e ilegible.
Esta entrada sigue cada rebaja a través del IL y de vuelta, y muestra qué tiene que reconocer Glass.NET para devolverte la línea que escribiste.
int? está en el blob de firma, no en un atributo
Primero la parte fácil, porque es lo contrario de una trampa que los lectores de tipos de referencia anulables han aprendido a esperar. string? deja el tipo intacto y registra la anulabilidad en atributos [Nullable] — anotaciones de compilación que el runtime ignora. int? es un tipo distinto: el struct genérico Nullable<T> instanciado sobre int, y aparece como tal en toda firma:
.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 decompilador imprime int? para valuetype System.Nullable`1<int32> por la misma regla por la que imprime int para System.Int32 — la abreviatura del lenguaje para un tipo conocido. No se consulta ningún atributo; no se puede eliminar nada que lo pierda. El endurecimiento de metadatos que elimina las anotaciones [Nullable] deja int? completamente intacto, porque la información nunca fue una anotación.
El struct en sí son dos campos y un puñado de miembros, y esos miembros son todo el vocabulario de cada rebaja que sigue:
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>) — ambos campos a cero — es lo que el lenguaje llama null.
Aritmética elevada: una conjunción, dos GetValueOrDefault y un newobj
Toma el método cuya firma aparece arriba:
static int? Add(int? a, int? b) => a + b;
El + sobre dos valores int? es un operador elevado: el resultado es null si cualquiera de los operandos es null, y la suma en caso contrario. El compilador emite esa regla directamente:
.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
Merece la pena fijarse en tres detalles, porque son en lo que se apoya un decompilador:
- Los operandos se copian primero a temporales, de modo que cada uno se evalúa exactamente una vez aunque se lea dos veces (una para
HasValue, otra para el valor). Una versión escrita a mano normalmente no se molestaría. - El valor se lee con
GetValueOrDefault, no conValue.HasValueacaba de comprobarse, así que la segunda comprobación dentro deValuesería redundante y su ruta de lanzamiento inalcanzable;GetValueOrDefaultes una simple lectura de campo. VerGetValueOrDefaultinmediatamente después de una guardaHasValuees la señal más fuerte de que estás mirando salida del compilador y no código fuente. - El resultado nulo es
initobj, no una llamada — no hay «constructor nulo»; el estado nulo es el struct puesto a cero.
Renderiza eso literalmente y obtienes:
// 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;
Eso compila y es semánticamente exacto. También son tres líneas, dos temporales y cuatro llamadas para un solo +. Glass reconoce la forma completa — temporales, conjunción de HasValue, un par de GetValueOrDefault alimentando un operador binario, newobj en la rama verdadera e initobj en la falsa — y emite a + b. La misma plantilla cubre todos los operadores aritméticos, a nivel de bits y de desplazamiento elevados; solo cambia la instrucción entre las dos llamadas a GetValueOrDefault.
La igualdad elevada es una forma distinta
La comparación no se rebaja de la misma manera, porque la regla es distinta: dos null son iguales, y un null es distinto de cualquier valor. El compilador produce una expresión sin saltos para ello:
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
Eso es (a.GetValueOrDefault() == b.GetValueOrDefault()) & (a.HasValue == b.HasValue). Recorre los casos: ambos null → 0 == 0 y false == false → verdadero. Uno null, otro 5 → 0 == 5 es falso → falso. Ambos 5 → verdadero. Fíjate en que aquí GetValueOrDefault se llama sin una guarda HasValue previa — se usa precisamente porque es seguro sobre un null — así que la señal «GetValueOrDefault tras guarda» del caso aritmético no aplica, y el decompilador necesita una segunda plantilla para esta forma. <, >, <=, >= se rebajan a la comparación de valores con AND de a.HasValue & b.HasValue (dos null no son menores entre sí), que es una tercera variante de la misma idea.
== null, ??, y el patrón is int v
Las formas más simples tienen rebajas más simples, cada una de las cuales es una coincidencia de una sola plantilla:
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 nunca compara una referencia: no hay referencia. Es !a.HasValue, y != null es a.HasValue. ?? se convierte en un condicional sobre HasValue con GetValueOrDefault en la rama del valor — de nuevo no Value, porque la guarda ya se ejecutó. La conversión explícita (int)a es la única rebaja que llama a get_Value, porque es la única cuyo contrato es lanzar cuando no hay nada; un decompilador puede usar eso como pista. Y el patrón de tipo a is int v sobre un anulable se rebaja a HasValue más GetValueOrDefault en v, sin ningún isinst por ninguna parte — la comprobación de tipo en tiempo de ejecución que el patrón aparenta nunca ocurre, porque el compilador ya sabe qué contiene el struct.
El box que borra Nullable<T>
Todo lo anterior es el compilador reescribiendo lenguaje en llamadas. El boxing es distinto: aquí el runtime hace algo especial, y el IL dice menos de lo que parece.
object Box(int? a) => a; // box valuetype Nullable`1<int32>
int? Unbox(object o) => (int?)o; // unbox.any valuetype Nullable`1<int32>
Hacer box de un struct ordinario lo copia al heap tras una cabecera de objeto. La instrucción box aplicada a Nullable<T> no hace eso. El runtime comprueba hasValue: si es false, el resultado es una referencia nula; si es true, el resultado es un T boxeado — un objeto del heap de tipo System.Int32, idéntico byte a byte a boxear un int normal. Nullable<int> en sí nunca llega al heap. Las consecuencias son visibles desde 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> es la imagen especular: dado un int boxeado construye un Nullable<int> con valor; dada una referencia nula produce un Nullable<int> vacío en lugar de lanzar la NullReferenceException que unbox.any sobre cualquier otro tipo de valor lanzaría. Así que (int?)o es la única conversión en C# que convierte silenciosamente null en el valor por defecto de un tipo de valor.
Para un decompilador esto es un caso de no inventar código. Las instrucciones box y unbox.any nombran Nullable<int32> en su operando de tipo, y la tentación es renderizarlas como una llamada de conversión o un new int?(…). El autor escribió una conversión implícita simple y una conversión explícita simple; el comportamiento especial vive dentro de la implementación en el runtime de dos instrucciones ordinarias, y la salida correcta es object o = a; y (int?)o, nada más.
Qué hace mal un decompilador, y por qué importa
Tres modos de fallo aparecen en la práctica, y todos son versiones de renderizar la rebaja en lugar del lenguaje:
- El baile de
HasValue/GetValueOrDefaultimpreso completo — correcto, verboso, y una señal fiable de que la herramienta no tiene plantillas de operadores elevados. Un método con seis sumas anulables se convierte en cuarenta líneas de temporales. new int?(x)donde el fuente teníax— la conversión implícita deintaint?es unnewobjen el IL, e imprimir la llamada al constructor es fiel pero ruidoso. Glass emite la conversión implícitamente allí donde el tipo destino la hace inequívoca.- Una llamada de conversión alrededor de
box/unbox.any— inventar un método que no está en el IL para explicar un comportamiento que está en el runtime.
La razón por la que esto importa más allá de la estética es que la aritmética anulable se concentra exactamente en el código que un lector suele intentar entender — configuración opcional, cálculos financieros con estados «sin definir», lógica de dominio llena de patrones — y la rebaja multiplica su longitud por tres o cuatro. Un decompilador que pliega las plantillas de vuelta te da las cinco líneas del autor; uno que no lo hace te da una página de llamadas a struct que parecen todas iguales.
La lección de fondo es la misma que rige ?. y ?? y foreach: el compilador de C# rebaja las características del lenguaje a IL ordinario sobre tipos ordinarios, y el trabajo del decompilador es conocer cada una de esas formas lo bastante bien como para ejecutarlas hacia atrás. Nullable<T> solo es inusual por lo completamente que desaparece — en un struct a la entrada, y en un int boxeado simple o un null a la salida.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.