C#-Records dekompilieren: die generierten Member, die sie verraten
Ein Record ist eine Klasse plus ein präziser Satz compilergenerierter Member — Wertgleichheit, eine Klonmethode, ein Deconstruct, ein PrintMembers/ToString, init-only-Setter. Ein Decompiler erkennt diesen Fingerabdruck und baut das Schlüsselwort record wieder auf. Hier steht genau, was der Compiler für einen Record ausgibt und wie das Muster zurückgelesen wird.
C#-Records sehen aus wie ein Sprachfeature, aber im kompilierten Assembly gibt es keinen „record”-Marker — ein Record ist eine gewöhnliche Klasse mit einem sehr spezifischen Satz generierter Member, die darangeschraubt sind. Dieser Satz ist markant genug, um ein Fingerabdruck zu sein, und ihn zu erkennen ist die Art, wie ein Decompiler einen Haufen synthetisierter Methoden zurück in das eine Wort record verwandelt. Durchzugehen, was der Compiler tatsächlich ausgibt, erklärt sowohl die Erkennung als auch das Verhalten, das Sie beim Deklarieren eines Records gratis bekommen.
Wozu sich ein positioneller Record expandiert
Nehmen Sie den kanonischen Einzeiler:
public record Point(int X, int Y);
Das kompiliert zu einer vollständigen Klasse. Ohne Record-Erkennung dekompiliert, ist es ungefähr:
public class Point : IEquatable<Point>
{
public int X { get; init; } // init-only, from the primary ctor
public int Y { get; init; }
public Point(int X, int Y) { this.X = X; this.Y = Y; }
protected virtual Type EqualityContract => typeof(Point);
public override bool Equals(object obj) => Equals(obj as Point);
public virtual bool Equals(Point other) =>
other is not null && EqualityContract == other.EqualityContract
&& EqualityComparer<int>.Default.Equals(X, other.X)
&& EqualityComparer<int>.Default.Equals(Y, other.Y);
public override int GetHashCode() => /* combine EqualityContract, X, Y */;
public static bool operator ==(Point a, Point b) => /* ... */;
public static bool operator !=(Point a, Point b) => !(a == b);
protected Point(Point original) { X = original.X; Y = original.Y; } // copy ctor
public virtual Point <Clone>$() => new Point(this); // clone
public override string ToString() { /* uses PrintMembers */ }
protected virtual bool PrintMembers(StringBuilder builder) { /* X = .., Y = .. */ }
public void Deconstruct(out int X, out int Y) { X = this.X; Y = this.Y; }
}
Eine Zeile Quellcode, ein Dutzend generierter Member. Jeder davon ist Verhalten, das Sie sonst von Hand schreiben würden, und jeder ist ein Hinweis.
Der Fingerabdruck, auf den ein Decompiler sich stützt
Ein Decompiler rät nicht aus „diese Klasse hat ein Equals”. Er sucht den spezifischen, gemeinsam auftretenden Satz, den nur ein Record erzeugt:
EqualityContract— eineprotected virtual Type-Eigenschaft, die den Typ zurückgibt. Nichts außer einem Record gibt das aus, also ist es das stärkste einzelne Signal. (In einem versiegelten oder abgeleiteten Record ist es entsprechendprotectedoderprivate protected, was dem Decompiler auch etwas über die Vererbung des Records sagt.)<Clone>$— eine Klonmethode mit einem Namen, der in C# unaussprechlich ist (das$kann in einem Quellcode-Bezeichner nicht vorkommen). Sie existiert einzig, um denwith-Ausdruck zu implementieren. Ihre Anwesenheit ist eindeutig.- Wertgleichheit —
IEquatable<T>.Equals(T), ein überschriebenesEquals(object),GetHashCodeund die Operatoren==/!=, alle compilergeneriert und alle untereinander konsistent. PrintMembers+ToString— einprotected virtual bool PrintMembers(StringBuilder), gepaart mit einemToString, das es aufruft. Der Name und die Signatur vonPrintMemberssind eine record-spezifische Konvention.Deconstruct— vorhanden, wenn der Record positionell ist (mit einer Parameterliste deklariert). Seine out-Parameter decken sich mit den Primärkonstruktor-Parametern, was die Art ist, wie der Decompiler weiß,record Point(int X, int Y)zu schreiben statt eines Records mit getrennt deklarierten Eigenschaften.
Wenn der ganze Satz vorhanden ist und die Compilergeneriert-Marker trägt, klappt der Decompiler ihn zu einer Zeile zusammen. Wenn nur ein Teil davon da ist — sagen wir, jemand hat ein Equals von Hand geschrieben und eine <Clone>$-förmige Methode fehlt — tut er nicht so; er zeigt eine Klasse. Diese Ehrlichkeit zählt: Das Schlüsselwort record ist eine Behauptung über generierte Semantik, und ein Decompiler sollte die Behauptung nur aufstellen, wenn die Beweislage vollständig ist.
Init-only-Eigenschaften und der modreq-Trick
Die Eigenschaften X und Y oben sind init, nicht set, und auch das lebt im IL in einer erkennbaren Form. Ein init-only-Setter ist ein gewöhnlicher Setter, dessen Signatur einen erforderlichen Modifikator (modreq) trägt, der System.Runtime.CompilerServices.IsExternalInit referenziert:
.method public hidebysig specialname instance void
set_X(int32 'value') ...
// the return type is: void modreq(IsExternalInit)
Dieser modreq ist der ganze Mechanismus: Der Compiler weigert sich, den Setter außerhalb eines Objektinitialisierers aufzurufen, weil Aufrufer den Modifikator verstehen müssen, und ältere Compiler, die das nicht tun, weisen ihn ab. Ein Decompiler liest den modreq und gibt init; aus. Es ist auch ein bestätigendes Signal für die Record-Erkennung — Parameter eines positionellen Records werden zu init-only-Eigenschaften, also verstärkt ein Bündel von init-Settern neben dem EqualityContract/Deconstruct-Muster, dass dies ein positioneller Record ist statt einer Klasse, die zufällig Wertgleichheit hat.
with, record struct und Vererbung
Drei weitere Details tauchen in der dekompilierten Form auf. Der with-Ausdruck kompiliert zu einem Aufruf von <Clone>$, gefolgt von init-only-Eigenschaftszuweisungen auf dem Klon — wenn Sie also <Clone>$ aufgerufen und dann ein paar set_/init-Aufrufe sehen, ist das ein with im Original. record struct folgt demselben Drehbuch abzüglich der Klonmethode und der Vererbungsmaschinerie (Structs erben nicht), also ist der Fingerabdruck EqualityContract-frei, hat aber weiterhin den generierten Wertgleichheits- und PrintMembers-Satz, und ein Decompiler unterscheidet ihn von einem nackten struct an genau diesen Membern. Record-Vererbung hinterlässt ihre eigene Spur: Das Equals eines abgeleiteten Records vergleicht EqualityContract, sein PrintMembers ruft base.PrintMembers auf, und die Klonmethode ist kovariant — all das lässt den Decompiler die : BaseRecord-Beziehung rekonstruieren, statt sie einzuebnen.
Warum man es überhaupt zurücklesen will
Fast immer wollen Sie das Schlüsselwort record, nicht das Dutzend expandierter Member — es ist, was Sie geschrieben haben und meinen, und Glass.NET rekonstruiert es standardmäßig. Aber die Expansion darunter erklärt Verhalten, das Leute überrascht. Wertgleichheit ist strukturell über die deklarierten Member, weshalb zwei Records mit gleichen Feldern Equals sind, obwohl sie unterschiedliche Objekte sind — und weshalb das Hinzufügen einer veränderlichen Sammlungs-Eigenschaft Ihnen eine Gleichheit gibt, die den Inhalt der Sammlung ignoriert, sofort sichtbar, sobald Sie sehen, dass das generierte Equals nur die Member-Referenzen vergleicht. Der with-Ausdruck ist ein flacher Klon, sichtbar als der <Clone>$-Aufruf, der Referenzen kopiert, keine tiefe Struktur. Und dass EqualityContract Teil der Gleichheit ist, ist genau der Grund, warum ein Basis-Record und ein abgeleiteter Record nie gleich sind, selbst mit identischen Feldern — die Vertragstypen unterscheiden sich. Die generierten Member zu lesen ist die Art, wie diese Verhaltensweisen aufhören, Folklore zu sein, und zu etwas werden, das Sie sehen können. Ein Record ist Syntax; seine Semantik ist ein spezifischer Satz von Methoden, und sobald Sie den Satz erkennen können, erzählen die dekompilierte Ansicht und die Quellansicht dieselbe Geschichte.
Nebula.NET testen
Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.