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

init-only-Setter und required-Mitglieder dekompilieren: der modreq und die Attribute, die sie machen

init und required sind zwei der kleinsten aussehenden Schlüsselwörter in C# und zwei der am leichtesten für einen Decompiler zu verlierenden. Ein init-Accessor ist nur ein set-Accessor, dessen Signatur einen required-Modifikator trägt — modreq(IsExternalInit) — und ein Decompiler, der den Modifikator nicht betrachtet, zeigt ihn als gewöhnliches set, und reicht dir stillschweigend einen Typ, dessen Eigenschaften nach der Konstruktion neu zugewiesen werden können. required ist schlimmer: es sind drei getrennte Attribute, die zusammenarbeiten — RequiredMemberAttribute an jedem Mitglied, RequiredMemberAttribute am Typ und CompilerFeatureRequired am Typ — plus ein SetsRequiredMembers an jedem Konstruktor, der sie erfüllt, und ein Decompiler, der sie fallen lässt, produziert Code, dessen Aufrufer nicht mehr gezwungen sind, irgendetwas zuzuweisen. Dieser Beitrag liest das IL und die Metadaten beider Features, zeigt die exakte Byte-Ebenen-Form des modreq und der Attribut-Blobs, geht durch, wie Glass.NET init, required und [SetsRequiredMembers] rekonstruiert, und erklärt, warum das Überspringen des Schritts den öffentlichen Vertrag eines Typs stillschweigend ändert.

init und required sind zwei der kürzesten Schlüsselwörter im modernen C# und zwei der am leichtesten für einen Decompiler wegzuwerfenden, ohne es zu bemerken.

Beide sind reine Compilezeit-Features. Die Runtime erzwingt keines von beiden — ein Setter per Reflexion weist eine init-Eigenschaft bereitwillig neu zu, und nichts hindert dich daran, ein Objekt zu konstruieren, das ein required-Mitglied überspringt, wenn du am Compiler vorbeigehst. Beide existieren vollständig, damit der Compiler Aufrufer an einen Vertrag binden kann: Setze dies einmal, während der Konstruktion; du musst dies setzen, bevor du ein gültiges Objekt hast. Dieser Vertrag ist in Metadaten kodiert, die der Compiler liest und alle anderen ignorieren, was bedeutet, dass ein Decompiler dieselben Metadaten lesen muss, sonst reicht er dir eine Quelle, deren Vertrag stillschweigend schwächer ist als das Original.

Die Signale sind klein. init ist ein benutzerdefinierter Modifikator an einem Setter. required sind drei Attribute plus, am erfüllenden Konstruktor, ein viertes. Verpasse sie und der Code kompiliert immer noch — genau das macht es gefährlich.

init — ein Modifikator.method set_Name(string value) returns void modreq(IsExternalInit)modreq vorhanden → als init dekompilieren;modreq abwesend → als set dekompilierenrequired — drei (+ ein) Attribute[RequiredMember] an Name[RequiredMember] am Typ[CompilerFeatureRequired("RequiredMembers")][SetsRequiredMembers] am erfüllenden .ctorwas ein Decompiler emittiert, der sie ignoriertpublic string Name { get; set; } ← init verloren: jetzt neu zuweisbarpublic string Email { get; set; } ← required verloren: Aufrufer nicht mehr gezwungenpublic Person() { } ← [SetsRequiredMembers] verloren: .ctor erfüllt nicht mehrdie Ausgabe kompiliert — deshalb rutscht der geschwächte Vertrag durch

init ist ein modreq am Setter

Nimm das kleinstmögliche Beispiel und sieh dir an, was der Compiler emittiert:

public class Person
{
    public string Name { get; init; }
}

Die Eigenschaft kompiliert zu einem Getter und einem Setter wie jede andere, aber die Signatur des Setters ist nicht ganz gewöhnlich. Im IL:

.property instance string Name()
{
    .get instance string Person::get_Name()
    .set instance void modreq([System.Runtime]System.Runtime.CompilerServices.IsExternalInit) Person::set_Name(string)
}

.method public hidebysig specialname instance void
    set_Name(string 'value') cil managed
{
    // ... ordinary field store ...
}

Der ganze Unterschied zwischen set und init ist der modreq([System.Runtime]System.Runtime.CompilerServices.IsExternalInit) am Rückgabetyp von set_Name. Ein benutzerdefinierter Modifikator ist Teil der Signatur — der CLR behandelt eine Signatur mit einem modreq als verschieden von einer ohne — und der Compiler nutzt genau diesen als Wächter. Wenn er einen Aufruf an einen Setter auflöst und IsExternalInit in der Signatur sieht, erlaubt er den Aufruf nur aus einem Initialisierungskontext: dem Konstruktor des deklarierenden Typs, einem Objektinitialisierer oder einem with-Ausdruck. Überall sonst ist es ein Compilefehler. Die Runtime erzwingt nichts; IsExternalInit ist nur ein Typ, den der Compiler als Flag zu behandeln vereinbart hat.

Das macht die Arbeit des Decompilers präzise. Lies die Signatur des Setters; wenn der Rückgabetyp modreq(IsExternalInit) trägt, rendere init; sonst rendere set. Ein Decompiler, der nur den Methodenrumpf und das get/set-Paar der Eigenschaft durchläuft, ohne die benutzerdefinierten Modifikatoren an der Setter-Signatur zu inspizieren, verliert die Unterscheidung und emittiert set. Die Ausgabe kompiliert — das ist die Falle — aber der regenerierte Person lässt nun jeden Aufrufer p.Name = "..." lange nach der Konstruktion schreiben, eine Mutation, die das init des Autors gezielt verbieten sollte.

Glass.NET liest den modreq an der Setter-Signatur und rekonstruiert das Schlüsselwort:

// Decompiled by Glass.NET — the init is recovered from modreq(IsExternalInit)
public class Person
{
    public string Name { get; init; }
}

required sind drei Attribute, die zusammen reisen müssen

Füge nun das andere Schlüsselwort hinzu:

public class Person
{
    public required string Name { get; init; }
    public required string Email { get; init; }
}

Es gibt kein neues IL für die Eigenschaftsrümpfe — required erzeugt überhaupt keinen Code. Es erzeugt Attribute, und drei verschiedene, denn drei verschiedene Dinge müssen markiert werden. Aus den Metadaten dekompiliert trägt der Typ:

.class public auto ansi beforefieldinit Person
{
    .custom instance void [System.Runtime]System.Runtime.CompilerServices.RequiredMemberAttribute::.ctor() = ( 01 00 00 00 )
    .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerFeatureRequiredAttribute::.ctor(string) = ( 01 00 0F 52 65 71 75 69 72 65 64 4D 65 6D 62 65 72 73 00 00 )
    //                                                                                              "RequiredMembers"
    // and on each required property:
    .property instance string Name()
    {
        .custom instance void [System.Runtime]System.Runtime.CompilerServices.RequiredMemberAttribute::.ctor() = ( 01 00 00 00 )
        // ...
    }
}

Der Attribut-Blob an CompilerFeatureRequired ist es wert, dekodiert zu werden, denn es ist der Geschlossen-scheitern-Wächter. 01 00 ist der Prolog; 0F ist das Längenpräfix (15) der folgenden UTF-8-Zeichenfolge, 52 65 71 75 69 72 65 64 4D 65 6D 62 65 72 73 = "RequiredMembers"; 00 00 ist die (leere) Anzahl benannter Argumente. Die drei Attribute erledigen drei Aufgaben:

  • RequiredMemberAttribute an jedem Mitglied markiert, welche Mitglieder required sind. Das sind die, die ein konsumierender Compiler Aufrufer zuzuweisen zwingt.
  • RequiredMemberAttribute am Typ ist ein schneller Marker, dass der Typ required-Mitglieder hat, damit ein Konsument nicht jedes Mitglied scannen muss, um es herauszufinden.
  • CompilerFeatureRequiredAttribute("RequiredMembers") am Typ ist der Wächter. Es sagt einem Compiler, der required-Mitglieder nicht versteht, den Typ abzulehnen, statt die Anforderung zu ignorieren — das Äquivalent, für ein Sprachfeature, zum geschlossen Scheitern. Ohne es würde ein alter Compiler Aufrufer bereitwillig Mitglieder überspringen lassen, die der Autor verlangte.

Ein Decompiler, der diese als „vom Compiler erzeugtes Rauschen” filtert — ein vernünftig klingender Standard, da man nicht [Nullable] oder [IsReadOnly] die Ausgabe vollstopfen lassen will — entfernt den gesamten required-Vertrag. Der regenerierte Typ hat gewöhnliche optionale Eigenschaften, und jeder Aufrufer, den das Original zwang, new Person { Name = ..., Email = ... } zu schreiben, ist nun frei, new Person { } zu schreiben. Der Vertrag schwächte sich nicht lautstark; er verschwand einfach.

Glass.NET erkennt die Paarung RequiredMemberAttribute/CompilerFeatureRequired als die Kodierung von required, konsumiert die Attribute und emittiert das Schlüsselwort wieder an genau den Mitgliedern, die das Attribut auf Mitgliedsebene trugen:

public class Person
{
    public required string Name { get; init; }
    public required string Email { get; init; }
}

[SetsRequiredMembers] ist tragend an Konstruktoren

Es gibt ein viertes Attribut, und es lebt an Konstruktoren. Betrachte einen Typ, der einen Konstruktor anbietet, der alles Required zuweist:

public class Person
{
    public required string Name { get; init; }
    public required string Email { get; init; }

    [SetsRequiredMembers]
    public Person(string name, string email)
    {
        Name = name;
        Email = email;
    }
}

[SetsRequiredMembers] sagt dem Compiler, dass dieser Konstruktor bereits jedes required-Mitglied erfüllt, sodass Aufrufer, die ihn verwenden, nicht gezwungen sind, diese Mitglieder in einem Objektinitialisierer erneut zu setzen. Ohne es würde new Person("a", "b") immer noch { Name = ..., Email = ... } verlangen, was absurd wäre. Der Compiler emittiert es als System.Diagnostics.CodeAnalysis.SetsRequiredMembersAttribute am Konstruktor, und es wird automatisch hinter dem generierten Kopierkonstruktor eines record emittiert, damit with-Ausdrücke funktionieren.

Dieses ist tragend im stärksten Sinne: Lass es fallen und der regenerierte Code kompiliert nicht einmal auf dieselbe Weise. Ein Decompiler, der [SetsRequiredMembers] auslässt, produziert einen Konstruktor, von dem der Compiler dann urteilt, dass er die required-Mitglieder nicht erfüllt, sodass jeder Objektinitialisierer-Aufrufer plötzlich gezwungen ist, Eigenschaften zu setzen, die das ursprüngliche Design ihm auszulassen erlaubte — oder, wenn der Konstruktor der einzige Weg ist, wird der Typ genau auf die Weise unhandlich zu verwenden, die das Attribut zu verhindern existierte. Glass.NET bringt es am Konstruktor unverändert zum Vorschein, denn der Typ macht ohne es keinen Rundlauf.

Beide Signale lesen, unabhängig

Der Grund, warum beide Features Sorgfalt brauchen, ist, dass sie orthogonal sind, und ihre vier Kombinationen sind vier verschiedene Verträge:

public string A { get; set; }           // mutable, optional
public string B { get; init; }          // set once, optional
public required string C { get; set; }  // must be set, then mutable
public required string D { get; init; } // must be set, construction only

In den Metadaten ist das die unabhängige Kombination zweier Signale: der IsExternalInit-modreq am Setter (vorhanden → init, abwesend → set) und das RequiredMemberAttribute am Mitglied (vorhanden → required, abwesend → nein). Ein Decompiler muss sie getrennt lesen und kombinieren, denn jedes für sich zusammengefallen produziert eine andere falsche Antwort: Verliere den modreq und D wird neu zuweisbar; verliere das Attribut und D wird optional; verliere beide und es ist eine einfache veränderliche Eigenschaft ohne jede der Garantien, die der Autor schrieb.

Das ist der rote Faden dieser ganzen Familie von C#-Features. Das Schlüsselwort, das du getippt hast, hinterließ fast keine Spur im ausführbaren Code — kein neues IL in den Rümpfen, nur einen Modifikator an einer Signatur und eine Handvoll Attribute an Deklarationen. Der Compiler legte den Vertrag in die Metadaten und vertraute darauf, dass der nächste Compiler ihn liest. Ein Decompiler, der dir die Quelle zurückgeben will, die der Autor tatsächlich schrieb, muss dieser nächste Leser sein: die benutzerdefinierten Modifikatoren inspizieren, die Attribute behalten, die Sprachfeatures kodieren, statt sie als Rauschen zu filtern, und init, required und [SetsRequiredMembers] präzise rekonstruieren, damit der Typ, den du zurückbekommst, genau das erzwingt, was das Original erzwang.

Nebula.NET testen

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