Statische abstrakte Interface-Member dekompilieren: wie generische Mathematik kompiliert wird
C# 11 ließ Interfaces statische abstrakte Member deklarieren, und die BCL nutzte das, um generische Mathematik zu bauen – schreiben Sie Sum<T>(span) einmal, und es läuft über int, double oder Ihren eigenen Typ. Doch das Feature stützt sich auf eine Ecke der ECMA-Spezifikation, die die meisten Dekompiler nie behandeln mussten: einen constrained-Aufruf an eine *statische* Interface-Methode, zur Laufzeit zum Typargument aufgelöst. Macht man es falsch, gibt der Dekompiler einen Aufruf an eine Methode aus, die keinen Empfänger zu haben scheint, oder erfindet einen Cast, der nicht da ist. Hier ist genau, was der Compiler für T.Zero und a + b über eine generische Einschränkung ausgibt, warum das Präfix constrained. auf einem nicht-virtuellen statischen Aufruf der ganze Trick ist, und wie Glass.NET es zurück in die Operatoren und statischen Member liest, die Sie tatsächlich geschrieben haben.
C# 11 fügte ein bescheiden klingendes Feature mit großen Folgen hinzu: Interfaces können static abstract-Member deklarieren. Das machte die generische Mathematik möglich – Sie können nun Sum<T>(ReadOnlySpan<T>) einmal schreiben, T auf INumber<T> einschränken und es über int, double, decimal oder einen selbst definierten Typ laufen lassen, wobei sich der Operator + zur jeweils eigenen Implementierung jedes Typs auflöst. Es liest sich wie gewöhnliche Generics. Darunter hängt es von einem Dispositionsmechanismus ab, den es zuvor nicht gab: eine statische Interface-Methode über einen Typparameter aufzurufen, zur Laufzeit zum Typargument aufgelöst.
Dieser Mechanismus ist, wo Dekompiler ihr Geld wert sind. Das IL dafür verwendet ein altes Präfix auf neue Weise wieder, und ein Werkzeug, das die alte Regel gelernt hat – „constrained. geht callvirt voran” –, liest die neue Form falsch. Dieser Artikel durchläuft das genaue IL, das der Compiler für T.Zero und a + b über eine generische Einschränkung ausgibt, erklärt, warum das Präfix constrained. auf einem schlichten call sitzt, und zeigt, wie Glass.NET es zurück in die Operatoren und statischen Member verwandelt, die Sie geschrieben haben.
Der Quellcode und was ihn heikel macht
Hier ist die kanonische Methode der generischen Mathematik. Es wird kein konkreter numerischer Typ erwähnt; alles fließt durch die Einschränkung:
using System.Numerics;
public static T Sum<T>(ReadOnlySpan<T> values) where T : INumber<T>
{
T total = T.Zero; // static abstract property on the interface
foreach (T v in values)
total += v; // static abstract operator op_Addition
return total;
}
T.Zero und total += v sehen wie ein Member-Zugriff und ein Operator aus. Doch T ist ein Typparameter – zur Kompilierzeit gibt es keinen konkreten Typ, an den der Aufruf gebunden werden kann. Die Laufzeit muss Zero und op_Addition zu dem auflösen, was T sich als sein herausstellt. Diese Auflösung ist die Aufgabe des Präfixes constrained..
Die zwei Zeilen sind der Kern. Das Präfix ist identisch, aber was es dekoriert – und was es bedeutet – ist es nicht.
Das IL, das der Compiler ausgibt
Kompilieren Sie im Release-Modus, und der Körper von Sum<T> reduziert sich auf dies (annotiert). Beachten Sie, dass es weder für den Operator noch für die Eigenschaft ein callvirt gibt; beide sind schlichte calls mit einem constrained.-Präfix, das !!T trägt, den eigenen Typparameter der Methode:
.method public hidebysig static !!T Sum<(class System.Numerics.INumber`1<!!T>) T>(
valuetype System.ReadOnlySpan`1<!!T> values) cil managed
{
// T total = T.Zero;
constrained. !!T
call !!T System.Numerics.INumberBase`1<!!T>::get_Zero()
stloc.0 // total
// foreach (T v in values) total += v;
... // span enumeration elided
ldloc.0 // total
ldloc.2 // v
constrained. !!T
call !!T System.Numerics.IAdditionOperators`3<!!T,!!T,!!T>::op_Addition(!0, !1)
stloc.0 // total = total + v
...
ldloc.0
ret
}
Zweierlei lässt sich daraus ablesen. Erstens ist T.Zero ein Aufruf an get_Zero – den Getter einer auf INumberBase<T> deklarierten statischen Eigenschaft –, präfixt mit constrained. !!T. Zweitens ist total += v ein Aufruf an op_Addition, die sondernamige Operatormethode auf IAdditionOperators<T,T,T>, wiederum präfixt mit constrained. !!T. Das Präfix sagt der Laufzeit: Suche keine statische Methode auf dem Interface selbst; löse sie gegen den an !!T gebundenen konkreten Typ bei dieser Instanziierung auf. Entfernt man das Präfix gedanklich, ist die Instruktion Unsinn – ein call an eine abstrakte Interface-Methode hat keinen Körper zum Ausführen. Das Präfix ist der ganze Mechanismus.
Ein Dekompiler, der die alte Annahme mit sich trägt – dass constrained. nur ein callvirt dekoriert, wie seit C# 2 für Werttyp-Instanzempfänger –, stößt auf dieses call und hat keine Regel dafür. Die typischen Fehlerweisen sind ein Phantom-Empfänger (er versucht, den ersten Stack-Operanden als das Aufgerufene zu rendern) oder ein erfundener Cast zum Interface (er „erklärt” das Präfix als eine Konvertierung). In beiden Fällen stimmt die Ausgabe nicht mehr mit dem Quellcode überein und kompiliert nicht mehr.
Es zurücklesen
Glass behandelt die Form generisch. Wenn es constrained. !!X gefolgt von einem call an eine Methode sieht, die auf einem Interface static abstract (oder static virtual) ist, hält es fest, dass der effektive Empfängertyp der Typparameter !!X ist und die Disposition statisch. Von dort löst es die Schreibweise aus den Metadaten der Methode auf:
get_Zero/get_Onetragen das Flagspecialnameund die Eigenschaftsaccessor-Form, sodass sie als die EigenschaftZero/Onezurückgelesen werden:T.Zero.op_Addition,op_Subtraction,op_Equalityund der Rest tragenspecialname staticund einen reservierten Operatornamen, sodass sie als der Operator zurückgelesen werden:total + v.
Wendet man beides an, ist der wiederhergestellte Quellcode die Methode, mit der Sie begonnen haben:
public static T Sum<T>(ReadOnlySpan<T> values) where T : INumber<T>
{
T total = T.Zero;
foreach (T v in values)
total += v;
return total;
}
Die Einschränkung selbst stammt direkt aus den Metadaten des generischen Parameters: (class System.Numerics.INumber1<!!T>) Tin der.method-Signatur wird zu where T : INumber. Das foreachrekonstruiert sich aus dem Span-Enumerationsmuster (ein eigenes Dekompilierungsproblem, separat behandelt), aber der Kern der generischen Mathematik –T.Zeroundtotal += v` – hängt vollständig am korrekten Lesen des constrained-statischen Aufrufs.
Es ist nicht nur die BCL
Generische Mathematik ist der berühmte Abnehmer, aber static abstract-Member sind ein allgemeines Feature, und dieselbe IL-Form taucht überall auf, wo Sie einen nutzen. Eine Fabrikeinschränkung etwa:
public interface IFactory<TSelf> where TSelf : IFactory<TSelf>
{
static abstract TSelf Create();
}
public static TSelf MakeOne<TSelf>() where TSelf : IFactory<TSelf>
=> TSelf.Create(); // constrained. !!TSelf · call !!TSelf IFactory::Create()
TSelf.Create() kompiliert zum identischen Muster – constrained. !!TSelf auf einem call an das statische abstrakte Create. Weil Glass sich auf die Instruktionsform und die Member-Flags stützt, nicht auf eine feste Liste numerischer Interfaces, dekompilieren Ihre eigenen static abstract-Member genau so wie die des Frameworks: ein statischer Member-Zugriff auf den Typparameter, so geschrieben, wie Sie ihn geschrieben haben. Ein Werkzeug, das INumber<T> sonderbehandelte, um seine eigene Testsuite zu bestehen, würde diesen hier falsch rendern; eines, das den ECMA-Mechanismus behandelt, behandelt sie alle.
Was Sie mitnehmen sollten
Statische abstrakte Interface-Member machten generische Mathematik möglich, und sie taten es, indem sie das Präfix constrained. auf einem schlichten call wiederverwendeten, um ein Typargument in die statische Disposition zu tragen – eine Form, die es vor C# 11 nicht gab. Die naive Regel „constrained. geht callvirt voran” liest sie falsch und erzeugt Phantom-Empfänger oder erfundene Casts, also muss ein korrekter Dekompiler den constrained-statischen Aufruf als eigenes Muster erkennen: Der Empfängertyp ist der Typparameter, die Disposition ist statisch, und die Schreibweise stammt aus dem Sondernamen des Members – get_Zero zu T.Zero, op_Addition zu +. Tun Sie das generisch, aus der Instruktion und den Metadaten-Flags statt aus einer Liste bekannter Interfaces, und sowohl der numerische Stapel der BCL als auch Ihre eigenen static abstract-Member im Fabrik- oder Parserstil lesen sich zurück in den Quellcode, den Sie geschrieben haben. Für ein verwandtes Dispositionsrätsel – das Wiederherstellen von Aufrufen, die gar kein Ziel-Token tragen – siehe Funktionszeiger und calli dekompilieren; für die Metadatentabellen, in denen diese Flags leben, die .NET-Metadatentabellen erklärt.
Nebula.NET testen
Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.