Skip to content
← Alle Beiträge
· Delta1 Labs Glass.NETDekompilierung.NETTiefenanalyse

Operatorüberladungen dekompilieren: op_-Namen, checked-Operatoren und die Konvertierungen, die nur als Operatoren kompilieren

Ein benutzerdefinierter Operator ist eine statische Methode mit reserviertem Namen und dem specialname-Flag — op_Addition, op_Implicit, op_Equality — und ihn auf eine Operatordeklaration zurückzuführen sieht nach einem Tabellen-Lookup aus. Drei Dinge machen es schwerer als die Tabelle. Benutzerdefinierte Konvertierungen dürfen allein über den Rückgabetyp überladen werden, was legales IL und illegales C# ist: Zwei op_Explicit-Methoden mit identischen Parametern unterscheiden sich nur darin, was sie zurückgeben, sodass ein Decompiler, der sie als gewöhnliche Methoden ausgibt, Quelltext erzeugt, der nicht kompiliert. C# 11 fügte checked-Operatoren hinzu — op_CheckedAddition, op_CheckedExplicit —, die mit dem Schlüsselwort checked wiedergegeben und mit ihrem unchecked-Zwilling gepaart werden müssen, sonst dekompiliert jede Aufrufstelle checked(a + b) zu einem Methodenaufruf. Und op_True/op_False, op_Increment und die Instanz-Operatoren für zusammengesetzte Zuweisungen aus C# 14 kompilieren jeweils zu Aufrufmustern, die zurück in Syntax gefaltet statt als Aufrufe angezeigt werden müssen. Dieser Beitrag liest das IL für jeden Fall, zeigt die Aufrufstellen, die die Rekonstruktion steuern, und geht durch, wie Glass.NET sie wieder in die Operatoren verwandelt, die der Autor geschrieben hat.

Operatoren sehen nach dem einfachen Teil der Dekompilierung aus. Der C#-Compiler verwandelt a + b auf einem benutzerdefinierten Typ in einen Aufruf einer statischen Methode namens op_Addition; die Methode trägt das specialname-Flag; ein Decompiler hält eine Tabelle von reservierten Namen zu Operator-Tokens und gibt operator + aus. Fertig.

Die Tabelle ist notwendig, sie ist der Punkt, an dem die meisten Decompiler aufhören, und drei Sprachfeatures liegen direkt dahinter. Benutzerdefinierte Konvertierungen können rein über den Rückgabetyp überladen werden — legales IL, illegal für eine gewöhnliche C#-Methode, sodass die Ausgabe als Methoden Quelltext ergibt, der nicht kompiliert. Checked-Operatoren aus C# 11 sind eigenständige Methoden mit einem Checked-Infix, an der Aufrufstelle durch den Kontext ausgewählt, und müssen mit dem Schlüsselwort checked wiedergegeben und mit ihren unchecked-Zwillingen gepaart werden, sonst verschwindet die Überlaufsemantik des Autors. Und eine Handvoll Operatoren — true/false, ++ und die Instanz-Operatoren für zusammengesetzte Zuweisungen aus C# 14 — kompilieren zu Aufrufformen, die zurück in Ausdrücke gefaltet werden müssen, statt als Aufrufe angezeigt zu werden. Dieser Beitrag liest das IL für jeden Fall und zeigt, wie Glass.NET es umkehrt.

gewöhnlich — der Tabellenfalla + bcall specialname static Money::op_Additionstatic + specialname + reservierter Name → operator +checked — C# 11checked(a + b)call Money::op_CheckedAddition (add.ovf)an der Aufrufstelle gewählt; braucht den unchecked-ZwillingKonvertierungen — Überladung nur über Rückgabetypcall int32 Money::op_Explicit(Money)call int64 Money::op_Explicit(Money)gleicher Name, gleicher Parameter, anderer Rückgabetyp:legales IL — illegal als zwei C#-Methoden (CS0111)zusammengesetzte Zuweisung — Instanzform C# 14a += bcall instance void Money::op_AdditionAssignmentnicht static, gibt void zurück, [CompilerFeatureRequired]ein Nur-static-Detektor zeigt einen Aufruf

Der Tabellenfall: static, specialname, reservierter Name

Hier ist der Typ, an dem der Rest des Beitrags arbeitet — ein Geldbetrag in Cent mit den Operatoren, die ein echter hätte:

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 + kompiliert zu einer statischen Methode, deren Name durch die CLI-Spezifikation festgelegt ist und deren specialname-Flag Werkzeugen sagt: „Dies ist keine gewöhnliche Methode”:

.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
}

Und eine Verwendung, var total = price + tax;, kompiliert zu einem schlichten Aufruf:

ldloc.0                    // price
ldloc.1                    // tax
call  valuetype Money Money::op_Addition(valuetype Money, valuetype Money)
stloc.2                    // total

Drei Dinge identifizieren den Operator, und ein Decompiler braucht alle drei. Der Name op_Addition ist reserviert, aber ein C#-Autor kann eine gewöhnliche Methode mit diesem Namen deklarieren — der Compiler erlaubt es, sie kann nur nicht mit + aufgerufen werden. Das specialname-Flag ist das, was den echten Operator unterscheidet; eine Methode namens op_Addition ohne dieses Flag muss als Methode wiedergegeben werden, sonst ändert der dekompilierte Quelltext seine Bedeutung. Und die Stelligkeit zählt: op_Addition mit zwei Parametern ist das binäre +, während op_UnaryPlus mit einem das unäre + ist; op_Subtraction und op_UnaryNegation ebenso, sodass die Tabelle nach Name und Parameterzahl indiziert ist. Glass prüft alle drei und tut dann das, was die Ausgabe lesbar macht: An jedem call eines erkannten Operators faltet es den Aufruf zurück in einen Ausdruck, sodass die Lokale price + tax ist und nicht Money.op_Addition(price, tax).

Das Gleichheitspaar hat eine zusätzliche Regel: C# verlangt, dass == und != gemeinsam deklariert werden, und ein Decompiler, der das eine erkennt und das andere nicht, erzeugt Quelltext, der nicht kompiliert. Dasselbe gilt für </>, <=/>= und true/false. Glass gibt das Paar als Paar wieder.

Konvertierungen: die Überladung, die C# nicht als Methode ausdrücken kann

Fügen Sie die Konvertierungen hinzu, die ein Geldtyp haben möchte — verengend nach int und long, erweiternd von 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);

Alle drei kompilieren zu reservierten Namen: op_Explicit für die expliziten Konvertierungen, op_Implicit für die implizite. Lesen Sie die ersten beiden genau:

.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

Zwei Methoden, gleicher Name, gleicher einzelner Parameter, unterschiedlicher Rückgabetyp. In der CLI schließt die Signatur einer Methode ihren Rückgabetyp ein, also sind das zwei verschiedene Methoden, und die Metadaten halten beide ohne Beanstandung. In C# wird eine gewöhnliche Methode nur über Name und Parametertypen identifiziert; static int op_Explicit(Money m) und static long op_Explicit(Money m) nebeneinander zu deklarieren ist Fehler CS0111, ein doppeltes Member. Die einzige C#-Syntax, die zwei Membern erlaubt, sich nur im Rückgabetyp zu unterscheiden, ist der Konvertierungsoperator, bei dem der Zieltyp Teil dessen ist, was Sie deklarieren.

Für Konvertierungen ist die Operatordarstellung also keine Lesbarkeitsentscheidung — ein Decompiler, der op_Explicit nicht erkennt und die beiden Methoden wörtlich ausgibt, erzeugt Quelltext, der überhaupt nicht kompiliert, und der Fehler ist verwirrend, weil beide Methoden für sich genommen völlig normal aussehen.

Die Aufrufstellen sind der Ort, an dem der Rückgabetyp gewählt wird. (int)m und (long)m rufen beide eine Methode namens op_Explicit mit demselben Argument auf; der einzige Unterschied ist die Signatur im Aufruf-Token:

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 löst jedes Aufruf-Token zu seiner exakten Methode auf — Rückgabetyp eingeschlossen — und gibt den Cast mit dem richtigen Zieltyp aus. Die umgekehrte Richtung ist subtiler. Money m = 5; hat im Quelltext keinen Cast; die implizite Konvertierung ist unsichtbar. Im IL ist sie so sichtbar wie jeder Aufruf:

ldc.i4.5
call  valuetype Money Money::op_Implicit(int32)
stloc.0

Ein Decompiler kann das legal als Money m = (Money)5; wiedergeben — es kompiliert, da eine implizite Konvertierung immer explizit aufgerufen werden darf —, aber es ist nicht das, was der Autor geschrieben hat, und es verbirgt, dass die Konvertierung implizit ist. Glass lässt den Aufruf weg, wenn der Zieltyp die Konvertierung eindeutig macht, und liefert Money m = 5; zurück; einen expliziten Cast behält es nur, wenn sein Entfernen die Überladungsauflösung an dieser Stelle ändern würde.

Checked-Operatoren: derselbe Ausdruck, eine andere Methode

C# 11 erlaubte einem Typ zu sagen, was + tut, wenn der umgebende Kontext checked ist. Der Geldtyp bekommt einen überlaufgeprüften Zwilling für die Addition und für die verengende Konvertierung:

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

Jeder kompiliert zu einer separaten Methode mit Checked in den reservierten Namen eingefügt:

.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
}

Die vollständige Menge folgt demselben Muster: op_CheckedSubtraction, op_CheckedMultiply, op_CheckedDivision, op_CheckedUnaryNegation, op_CheckedIncrement, op_CheckedDecrement und op_CheckedExplicit. Die Sprachregel lautet, dass ein checked-Operator nur deklariert werden darf, wenn auch die unchecked-Form existiert — operator checked + verlangt operator + —, weil der Compiler in einem unchecked-Kontext immer eine Methode zum Aufrufen haben muss.

Welche Methode aufgerufen wird, entscheidet sich vollständig an der Verwendungsstelle, durch den Kontext:

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

Zwei Rekonstruktionsprobleme folgen daraus. Auf der Deklarationsseite sieht ein Decompiler, der nur die Namen vor C# 11 kennt, op_CheckedAddition als unbekannte specialname-Methode und gibt public static Money op_CheckedAddition(Money a, Money b) aus — was kompiliert, und falsch ist, weil der Typ stillschweigend sein checked-Verhalten verloren hat: Ein neu kompiliertes checked(a + b) ruft jetzt das überlaufende op_Addition auf. Auf der Verwendungsseite wird jedes checked(price + tax) des Originals in der Ausgabe zu Money.op_CheckedAddition(price, tax), ein Methodenaufruf, wo der Autor einen Ausdruck geschrieben hat.

Glass erkennt die Familie op_Checked* als Operatoren, gibt operator checked + und explicit operator checked int mit dem Schlüsselwort an Ort und Stelle aus und umhüllt an jeder Aufrufstelle einer checked-Methode den gefalteten Ausdruck mit checked(...) — oder, wenn eine Folge aufeinanderfolgender Anweisungen alle checked-Formen aufruft, mit einem checked { }-Block, der Form, die der Autor am wahrscheinlichsten geschrieben hat. Die eingebauten Typen geben derselben Disziplin ein vertrautes Gesicht: add.ovf auf zwei int dekompiliert zu checked(x + y), und der benutzerdefinierte Fall ist dieselbe Regel, angewendet auf eine benannte Methode statt einer Instruktion.

Der neueste arithmetische Operator, >>> aus C# 11, ist eine kleinere Version derselben Lektion: Für eingebaute Typen ist es die Instruktion shr.un, für benutzerdefinierte Typen ist es op_UnsignedRightShift, ein Name, den es in der Tabelle vor 2022 nicht gab und den ein älterer Decompiler als Aufruf wiedergibt.

true, false und die Kurzschluss-Expansion

operator true und operator false kompilieren zu op_True und op_False, und sie sind die einzigen Operatoren, die der Compiler in Ihrem Namen aufruft, ohne dass im Quelltext ein Operator steht. Ein Geldtyp, der null als falsch behandelt:

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

Ein if (balance) kompiliert zu einem Aufruf, den der Autor nie geschrieben hat:

ldloc.0
call  bool Money::op_True(valuetype Money)
brfalse.s  SKIP

Glass faltet den op_True-Aufruf zurück in die Bedingung — if (balance), nicht if (Money.op_True(balance)). Die Expansion von && ist die interessante, weil sie über false und & definiert ist: a && b auf einem Benutzertyp mit diesen Operatoren bedeutet „wenn a falsch ist, ist das Ergebnis a; sonst 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:

Ein Decompiler, der Operatoren wiedergibt, aber nicht dieses Muster, erzeugt etwas wie Money.op_False(a) ? a : (a & b), was korrekt und unlesbar ist. Glass erkennt die Form dup / op_False / brtrue / op_BitwiseAnd und gibt a && b aus. Die ||-Form ist symmetrisch über op_True und op_BitwiseOr.

Inkrement, und der Operator, der nicht statisch ist

operator ++ kompiliert zu op_Increment, einer statischen Methode, die einen neuen Wert zurückgibt; der Compiler speichert das Ergebnis zurück in die Variable. balance++ ist:

ldloc.0
call  valuetype Money Money::op_Increment(valuetype Money)
stloc.0

Wörtlich wiedergegeben ist das balance = Money.op_Increment(balance);. Die idiomatische Form ist balance++, und die Präfix/Postfix-Unterscheidung lässt sich daraus zurückgewinnen, ob der Wert vor dem Inkrement auf dem Stack behalten wird (ein dup vor dem Aufruf) — Glass liest das, um zwischen ++balance und balance++ zu wählen. Dasselbe gilt für zusammengesetzte Zuweisungen auf Typen, die nur den statischen Operator definieren: balance += tax ist im IL wörtlich balance = balance + tax — ein Aufruf von op_Addition und ein Store — und Glass schreibt die Form Laden-Aufrufen-Speichern-an-dieselbe-Stelle als += um.

C# 14 ändert diesen letzten Fall. Ein Typ kann die zusammengesetzte Form jetzt direkt deklarieren, als Instanzmethode, die den Empfänger an Ort und Stelle verändert:

public void operator +=(Money other) => _cents += other.Cents;   // C# 14 instance operator

In den Metadaten ist das anders als jeder Operator zuvor: Es ist eine Instanzmethode, sie gibt void zurück, und sie ist so markiert, dass ein Compiler, der sie nicht versteht, den Typ ablehnt, statt ihn falsch zu lesen:

.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')}
  ...
}

Ein balance += tax gegen diesen Typ kompiliert zu ldloca.s balance; ldloc tax; call instance void Money::op_AdditionAssignment(Money) — ohne Store, weil die Methode balance über den verwalteten Zeiger verändert hat. Jede Heuristik des alten Handbuchs übersieht das: Der Detektor, der static verlangt, sieht eine gewöhnliche Methode; der Umschreiber für zusammengesetzte Zuweisungen, der nach Laden-Aufrufen-Speichern sucht, findet keinen Store. Die Ausgabe ist balance.op_AdditionAssignment(tax);, was kompiliert und wie ein Fehler aussieht. Glass erkennt die Instanz-Familie op_*Assignment mit dem Marker CompilerFeatureRequired, gibt die Deklaration als public void operator +=(Money other) wieder und faltet die Aufrufstelle zurück zu balance += tax.

Als eine einzige Regel gelesen

Der rote Faden ist, dass Operatoren auf Metadatenebene eine Namenskonvention plus ein Flag sind, und alles daran, wie sie im Quelltext erscheinen — das Symbol, das Schlüsselwort checked, der implizite Cast, der nicht geschrieben wird, das &&, das sich in drei Aufrufe entfaltet, das +=, das jetzt eine mutierende Instanzmethode ist — wird aus der Form der Aufrufstelle rekonstruiert, nicht aus der Methode allein. Ein Decompiler, der bei der Namenstabelle stehen bleibt, liefert Ihnen kompilierenden, aber falschen Quelltext für checked-Operatoren, nicht kompilierbaren Quelltext für über den Rückgabetyp überladene Konvertierungen und Methodenaufrufe, wo der Autor Arithmetik geschrieben hat. Glass.NET liest Flag, Name, Stelligkeit, die vollständige Signatur des Aufruf-Tokens und das umgebende Instruktionsmuster zusammen, und das ist es, was nötig ist, um Ihnen operator checked +, explicit operator long, if (balance) und balance += tax so zurückzugeben, wie der Autor sie geschrieben hat.

Nebula.NET testen

Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.