Decompilar sobrecargas de operadores: nombres op_, operadores checked y las conversiones que solo compilan como operadores
Un operador definido por el usuario es un método estático con un nombre reservado y la marca specialname —op_Addition, op_Implicit, op_Equality— y mapearlo de vuelta a una declaración de operador parece una búsqueda en una tabla. Tres cosas lo hacen más difícil que la tabla. Las conversiones definidas por el usuario pueden sobrecargarse solo por tipo de retorno, lo que es IL legal y C# ilegal: dos métodos op_Explicit con parámetros idénticos difieren solo en lo que devuelven, así que un decompilador que los emite como métodos ordinarios produce código fuente que no puede compilar. C# 11 añadió operadores checked —op_CheckedAddition, op_CheckedExplicit— que deben renderizarse con la palabra clave checked y emparejarse con su gemelo unchecked, o cada sitio de llamada checked(a + b) se decompila como una llamada a método. Y op_True/op_False, op_Increment y los operadores de asignación compuesta de instancia de C# 14 compilan cada uno a patrones de llamada que hay que plegar de vuelta a sintaxis en lugar de mostrarlos como invocaciones. Esta entrada lee el IL de cada uno, muestra los sitios de llamada que guían la reconstrucción, y recorre cómo Glass.NET los convierte de nuevo en los operadores que el autor escribió.
Los operadores parecen la parte fácil de la decompilación. El compilador de C# convierte a + b sobre un tipo definido por el usuario en una llamada a un método estático llamado op_Addition; el método lleva la marca specialname; un decompilador mantiene una tabla de nombres reservados a tokens de operador y emite operator +. Listo.
La tabla es necesaria, y es donde la mayoría de los decompiladores se detienen, y tres características del lenguaje están justo después de ella. Las conversiones definidas por el usuario pueden sobrecargarse puramente por tipo de retorno — IL legal, ilegal para un método ordinario de C#, así que emitirlas como métodos produce código fuente que no compilará. Los operadores checked de C# 11 son métodos distintos con un infijo Checked, seleccionados en el sitio de llamada por el contexto, y hay que renderizarlos con la palabra clave checked y emparejarlos con sus gemelos unchecked o la semántica de desbordamiento del autor desaparece. Y un puñado de operadores —true/false, ++ y las asignaciones compuestas de instancia de C# 14— compilan a formas de llamada que deben plegarse de vuelta a expresiones, no mostrarse como invocaciones. Esta entrada lee el IL de cada uno y cómo Glass.NET lo invierte.
El caso de tabla: static, specialname, nombre reservado
Este es el tipo sobre el que trabaja el resto de la entrada — un importe monetario en céntimos con los operadores que tendría uno real:
public readonly struct Money : IEquatable<Money>
{
public long Cents { get; }
public Money(long cents) => Cents = cents;
public static Money operator +(Money a, Money b) => new(unchecked(a.Cents + b.Cents));
public static bool operator ==(Money a, Money b) => a.Cents == b.Cents;
public static bool operator !=(Money a, Money b) => a.Cents != b.Cents;
public bool Equals(Money other) => Cents == other.Cents;
public override bool Equals(object? o) => o is Money m && Equals(m);
public override int GetHashCode() => Cents.GetHashCode();
}
operator + compila a un método estático cuyo nombre fija la especificación de la CLI y cuya marca specialname dice a las herramientas “esto no es un método ordinario”:
.method public hidebysig specialname static
valuetype Money op_Addition(valuetype Money a, valuetype Money b) cil managed
{
ldarga.s a
call instance int64 Money::get_Cents()
ldarga.s b
call instance int64 Money::get_Cents()
add // unchecked: wraps on overflow
newobj instance void Money::.ctor(int64)
ret
}
Y un uso del mismo, var total = price + tax;, compila a una llamada normal:
ldloc.0 // price
ldloc.1 // tax
call valuetype Money Money::op_Addition(valuetype Money, valuetype Money)
stloc.2 // total
Tres cosas identifican el operador, y un decompilador necesita las tres. El nombre op_Addition está reservado, pero un autor de C# puede declarar un método ordinario con ese nombre — el compilador lo permite, simplemente no puede invocarse con +. La marca specialname es lo que distingue al operador real; un método llamado op_Addition sin ella debe renderizarse como método, o el código decompilado cambia de significado. Y la aridad importa: op_Addition con dos parámetros es el + binario, mientras que op_UnaryPlus con uno es el + unario; op_Subtraction y op_UnaryNegation igualmente, así que la tabla se indexa por nombre y número de parámetros. Glass comprueba las tres cosas y después hace lo que hace legible la salida: en cada call a un operador reconocido pliega la llamada de vuelta a una expresión, de modo que la local es price + tax y no Money.op_Addition(price, tax).
El par de igualdad tiene una regla extra: C# exige que == y != se declaren juntos, y un decompilador que reconoce uno y no el otro emite código fuente que no compila. Lo mismo ocurre con </>, <=/>= y true/false. Glass renderiza el par como par.
Conversiones: la sobrecarga que C# no puede expresar como método
Añade las conversiones que quiere un tipo monetario — estrechamiento a int y long, ensanchamiento desde int:
public static explicit operator int(Money m) => (int)m.Cents;
public static explicit operator long(Money m) => m.Cents;
public static implicit operator Money(int cents) => new(cents);
Las tres compilan a nombres reservados: op_Explicit para las conversiones explícitas, op_Implicit para la implícita. Lee las dos primeras con atención:
.method public hidebysig specialname static int32 op_Explicit(valuetype Money m) cil managed
.method public hidebysig specialname static int64 op_Explicit(valuetype Money m) cil managed
.method public hidebysig specialname static valuetype Money op_Implicit(int32 cents) cil managed
Dos métodos, mismo nombre, mismo parámetro único, distinto tipo de retorno. En la CLI la firma de un método incluye su tipo de retorno, así que son dos métodos distintos y los metadatos contienen ambos sin queja. En C#, un método ordinario se identifica solo por nombre y tipos de parámetros; declarar static int op_Explicit(Money m) y static long op_Explicit(Money m) uno junto al otro es el error CS0111, un miembro duplicado. La única sintaxis de C# que permite que dos miembros difieran solo por tipo de retorno es el operador de conversión, donde el tipo destino forma parte de lo que declaras.
Así que para las conversiones la renderización como operador no es una elección de legibilidad — un decompilador que no reconoce op_Explicit y emite los dos métodos literalmente produce código que no puede compilar en absoluto, y el error es confuso porque ambos métodos parecen perfectamente normales por separado.
Los sitios de llamada son donde se elige el tipo de retorno. (int)m y (long)m llaman ambos a un método llamado op_Explicit con el mismo argumento; la única diferencia es la firma del token de llamada:
ldloc.0
call int32 Money::op_Explicit(valuetype Money) // (int)m — resolves to the int32-returning one
...
ldloc.0
call int64 Money::op_Explicit(valuetype Money) // (long)m — resolves to the int64-returning one
Glass resuelve cada token de llamada a su método exacto —tipo de retorno incluido— y emite la conversión con el tipo destino correcto. La dirección inversa es más sutil. Money m = 5; no tiene ninguna conversión en el código fuente; la conversión implícita es invisible. En IL es tan visible como cualquier llamada:
ldc.i4.5
call valuetype Money Money::op_Implicit(int32)
stloc.0
Un decompilador puede legalmente renderizar esto como Money m = (Money)5; — compila, ya que una conversión implícita siempre puede invocarse explícitamente — pero no es lo que el autor escribió, y oculta el hecho de que la conversión es implícita. Glass elide la llamada cuando el tipo destino hace la conversión inequívoca, devolviendo Money m = 5;, y mantiene una conversión explícita solo cuando quitarla cambiaría la resolución de sobrecargas en ese sitio.
Operadores checked: la misma expresión, un método distinto
C# 11 permitió a un tipo decir qué hace + cuando el contexto circundante es checked. El tipo monetario gana un gemelo con comprobación de desbordamiento para la suma y para la conversión de estrechamiento:
public static Money operator checked +(Money a, Money b) => new(checked(a.Cents + b.Cents));
public static explicit operator checked int(Money m) => checked((int)m.Cents);
Cada uno compila a un método separado con Checked insertado en el nombre reservado:
.method public hidebysig specialname static
valuetype Money op_CheckedAddition(valuetype Money a, valuetype Money b) cil managed
{
ldarga.s a
call instance int64 Money::get_Cents()
ldarga.s b
call instance int64 Money::get_Cents()
add.ovf // the one instruction that differs from op_Addition
newobj instance void Money::.ctor(int64)
ret
}
.method public hidebysig specialname static int32 op_CheckedExplicit(valuetype Money m) cil managed
{
ldarga.s m
call instance int64 Money::get_Cents()
conv.ovf.i4 // vs conv.i4 in the unchecked op_Explicit
ret
}
El conjunto completo sigue el mismo patrón: op_CheckedSubtraction, op_CheckedMultiply, op_CheckedDivision, op_CheckedUnaryNegation, op_CheckedIncrement, op_CheckedDecrement y op_CheckedExplicit. La regla del lenguaje es que un operador checked solo puede declararse si la forma unchecked también existe — operator checked + exige operator + — porque el compilador siempre debe tener un método al que llamar en un contexto unchecked.
Qué método se llama se decide enteramente en el sitio de uso, por contexto:
var a = price + tax; // unchecked context (the default)
var b = checked(price + tax); // checked expression
checked { var c = price + tax; } // checked block
call valuetype Money Money::op_Addition(valuetype Money, valuetype Money) // a
call valuetype Money Money::op_CheckedAddition(valuetype Money, valuetype Money) // b
call valuetype Money Money::op_CheckedAddition(valuetype Money, valuetype Money) // c
De aquí se derivan dos problemas de reconstrucción. En el lado de la declaración, un decompilador que solo conoce los nombres anteriores a C# 11 ve op_CheckedAddition como un método specialname no reconocido y emite public static Money op_CheckedAddition(Money a, Money b) — que compila, y está mal, porque el tipo ha perdido en silencio su comportamiento checked: un checked(a + b) recompilado llama ahora al op_Addition que desborda sin aviso. En el lado del uso, cada checked(price + tax) del original se convierte en Money.op_CheckedAddition(price, tax) en la salida, una llamada a método donde el autor escribió una expresión.
Glass reconoce la familia op_Checked* como operadores, emite operator checked + y explicit operator checked int con la palabra clave en su sitio, y en cada sitio de llamada a un método checked envuelve la expresión plegada en checked(...) — o, cuando una serie de sentencias consecutivas llaman todas a formas checked, en un bloque checked { }, que es la forma que más probablemente escribió el autor. Los tipos integrados dan a la misma disciplina una cara familiar: add.ovf sobre dos int se decompila a checked(x + y), y el caso definido por el usuario es la misma regla aplicada a un método con nombre en lugar de a una instrucción.
El operador aritmético más reciente, >>> de C# 11, es una versión menor de la misma lección: para los tipos integrados es la instrucción shr.un, y para los tipos definidos por el usuario es op_UnsignedRightShift, un nombre que no existía en la tabla antes de 2022 y que un decompilador antiguo renderiza como una llamada.
true, false y la expansión del cortocircuito
operator true y operator false compilan a op_True y op_False, y son los únicos operadores que el compilador llama en tu nombre sin un operador en el código fuente. Un tipo monetario que trata cero como falso:
public static bool operator true(Money m) => m.Cents != 0;
public static bool operator false(Money m) => m.Cents == 0;
public static Money operator &(Money a, Money b) => new(Math.Min(a.Cents, b.Cents));
Un if (balance) compila a una llamada que el autor nunca escribió:
ldloc.0
call bool Money::op_True(valuetype Money)
brfalse.s SKIP
Glass pliega la llamada a op_True de vuelta a la condición — if (balance), no if (Money.op_True(balance)). La expansión de && es la interesante, porque se define en términos de false y &: a && b sobre un tipo de usuario con estos operadores significa “si a es falso, el resultado es a; si no, a & b”:
ldloc.0 // a
dup
call bool Money::op_False(valuetype Money)
brtrue.s RESULT_IS_A // a is "false" → short-circuit, leave a on the stack
ldloc.1 // b
call valuetype Money Money::op_BitwiseAnd(valuetype Money, valuetype Money)
RESULT_IS_A:
Un decompilador que renderiza operadores pero no este patrón produce algo como Money.op_False(a) ? a : (a & b), que es correcto e ilegible. Glass reconoce la forma dup / op_False / brtrue / op_BitwiseAnd y emite a && b. La forma || es simétrica sobre op_True y op_BitwiseOr.
Incremento, y el operador que no es estático
operator ++ compila a op_Increment, un método estático que devuelve un valor nuevo; el compilador almacena el resultado de vuelta en la variable. balance++ es:
ldloc.0
call valuetype Money Money::op_Increment(valuetype Money)
stloc.0
Renderizado literalmente, eso es balance = Money.op_Increment(balance);. La forma idiomática es balance++, y la distinción prefijo/sufijo es recuperable a partir de si el valor previo al incremento se mantiene en la pila (un dup antes de la llamada) — Glass lo lee para elegir ++balance frente a balance++. Lo mismo aplica a la asignación compuesta en tipos que solo definen el operador estático: balance += tax es literalmente balance = balance + tax en IL —una llamada a op_Addition y un almacenamiento— y Glass reescribe la forma carga-llamada-almacenamiento-en-la-misma-ubicación como +=.
C# 14 cambia este último caso. Un tipo puede ahora declarar la forma compuesta directamente, como un método de instancia que muta el receptor en su sitio:
public void operator +=(Money other) => _cents += other.Cents; // C# 14 instance operator
En los metadatos esto es distinto de todos los operadores anteriores: es un método de instancia, devuelve void, y está marcado para que un compilador que no lo entienda rechace el tipo en lugar de malinterpretarlo:
.method public hidebysig specialname instance void op_AdditionAssignment(valuetype Money other) cil managed
{
.custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerFeatureRequiredAttribute::.ctor(string)
= {string('UserDefinedCompoundAssignmentOperators')}
...
}
Un balance += tax contra este tipo compila a ldloca.s balance; ldloc tax; call instance void Money::op_AdditionAssignment(Money) — sin almacenamiento, porque el método mutó balance a través del puntero gestionado. Todas las heurísticas del manual antiguo fallan aquí: el detector que exige static ve un método ordinario; el reescritor de asignación compuesta que busca carga-llamada-almacenamiento no encuentra almacenamiento. La salida es balance.op_AdditionAssignment(tax);, que compila y se lee como un error. Glass reconoce la familia op_*Assignment de instancia con el marcador CompilerFeatureRequired, renderiza la declaración como public void operator +=(Money other), y pliega el sitio de llamada de vuelta a balance += tax.
Leerlo como una sola regla
El hilo conductor es que los operadores son una convención de nombres más una marca a nivel de metadatos, y todo lo relativo a cómo aparecen en el código fuente —el símbolo, la palabra clave checked, la conversión implícita que no se escribe, el && que se expande en tres llamadas, el += que ahora es un método de instancia mutador— se reconstruye a partir de la forma del sitio de llamada, no solo del método. Un decompilador que se detiene en la tabla de nombres te entrega código que compila pero está mal para los operadores checked, código que no compila para las conversiones sobrecargadas por tipo de retorno, y llamadas a método donde el autor escribió aritmética. Glass.NET lee la marca, el nombre, la aridad, la firma completa del token de llamada y el patrón de instrucciones circundante en conjunto, que es lo que hace falta para devolverte operator checked +, explicit operator long, if (balance) y balance += tax tal como el autor los escribió.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.