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

Nullable Reference Types dekompilieren: wo string? tatsächlich in den Metadaten lebt

string und string? sind für die Runtime derselbe Typ — System.String, eine TypeRef, kein Unterschied in irgendeiner Signatur. Das Fragezeichen existiert nur zur Compilezeit, und der Compiler muss es irgendwo in die Assembly schmuggeln, damit die Konsumenten deiner Bibliothek die richtigen Warnungen bekommen. Er tut das mit zwei eingebetteten Attributen, NullableAttribute und NullableContextAttribute, einem Byte-Vokabular aus drei Werten, einer Preorder-Verflachung generischer Typbäume in Byte-Arrays und einem aggressiven Kompressionsschema, das weglässt, was dem umgebenden Standard entspricht. Dieser Beitrag liest diese Bytes direkt aus dem IL, zeigt die exakte Blob-Kodierung für die Ein-Byte- und die Array-Form, geht durch, wie ein Decompiler ?, notnull und class? daraus rekonstruiert, und erklärt, warum das Überspringen dieses Schritts den öffentlichen Vertrag einer Bibliothek still ändert.

Frag die Runtime nach dem Typ einer string?-Eigenschaft und sie sagt dir System.String. Frag sie nach einer string-Eigenschaft und sie sagt dasselbe. Es gibt eine TypeRef für System.String in der Assembly, beide Eigenschaften zeigen darauf, und die Signatur-Blobs ihrer Getter sind Byte für Byte identisch. Nullable Reference Types sind das gründlichste rein compilezeitliche Feature von C#: sie ändern Warnungen, nicht Code, und sie hinterlassen überhaupt keine Spur im Typsystem.

Sie hinterlassen doch irgendwo eine Spur, weil sie müssen. Wenn du eine mit #nullable enable kompilierte Bibliothek veröffentlichst, bekommt ein Konsument, der dagegen kompiliert, Warnungen danach, welche deiner Parameter null akzeptieren und welche deiner Rückgaben es erzeugen können. Diese Information überquert die Assembly-Grenze, also muss sie in den Metadaten stehen. Der Compiler speichert sie in benutzerdefinierten Attributen — zwei davon, privat in jede Assembly eingebettet, die das Feature nutzt, mit einer kompakten Byte-Kodierung und einem Kompressionsschema, das die Metadaten klein hält. Dieser Beitrag liest diese Bytes direkt und zeigt dann, wie ein Decompiler wie Glass.NET sie zurück in das ?, notnull und class? verwandelt, die der Autor schrieb.

Ein Typ, drei Annotationen, zwei Attribute

Hier eine kleine Klasse, die jeden Kodierungsfall durchexerziert:

#nullable enable
using System.Diagnostics.CodeAnalysis;

public sealed class Catalog<T> where T : notnull
{
    public string Name { get; }                                   // non-nullable
    public string? Description { get; set; }                      // nullable
    public Dictionary<string, List<string?>?> Aliases { get; }    // mixed, nested
    public int? LastIndex { get; set; }                           // value type: Nullable<int>

    public Catalog(string name) { Name = name; Aliases = new(); }

    public bool TryFind(string key, [MaybeNullWhen(false)] out T value) { ... }
}

Der Compiler kodiert jede Referenztyp-Position mit einem Byte:

ByteBedeutungSchreibweise in C#
0oblivious — keine AnnotationsinformationCode ohne #nullable enable kompiliert
1nicht annotiert — nicht-nullablestring
2annotiert — darf null seinstring?

Er trägt diese Bytes in zwei Attributen, die er innerhalb deiner Assembly definiert — nicht aus der BCL referenziert — als internal sealed-Typen in System.Runtime.CompilerServices, markiert mit [Microsoft.CodeAnalysis.Embedded] und [CompilerGenerated]:

.class private auto ansi sealed beforefieldinit System.Runtime.CompilerServices.NullableAttribute
       extends [System.Runtime]System.Attribute
{
  .custom instance void Microsoft.CodeAnalysis.EmbeddedAttribute::.ctor() = ( 01 00 00 00 )
  .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = ( 01 00 00 00 )
  .field public initonly uint8[] NullableFlags
  .method public hidebysig specialname rtspecialname instance void .ctor(uint8)   cil managed { ... }
  .method public hidebysig specialname rtspecialname instance void .ctor(uint8[]) cil managed { ... }
}

.class private auto ansi sealed beforefieldinit System.Runtime.CompilerServices.NullableContextAttribute
       extends [System.Runtime]System.Attribute
{
  .custom instance void Microsoft.CodeAnalysis.EmbeddedAttribute::.ctor() = ( 01 00 00 00 )
  .field public initonly uint8 Flag
  .method public hidebysig specialname rtspecialname instance void .ctor(uint8) cil managed { ... }
}

NullableAttribute geht auf ein Ding mit einem Typ — ein Feld, eine Eigenschaft, einen Parameter, einen Rückgabewert, einen generischen Typparameter, einen Basistyp — und beschreibt diesen Typ. NullableContextAttribute geht auf einen Gültigkeitsbereich — einen Typ oder eine Methode — und setzt das Standard-Byte für alles darin, das kein eigenes NullableAttribute hat. Die beiden arbeiten zusammen, um die Metadaten klein zu halten: der Compiler wählt das häufigste Byte eines Bereichs als dessen Kontext und emittiert NullableAttribute nur dort, wo ein Member abweicht.

Die Ein-Byte-Form lesen

Dumpe Catalog<T> und die Attribute erscheinen auf der Klasse selbst und auf Description:

.class public auto ansi sealed beforefieldinit Catalog`1<T>
       extends [System.Runtime]System.Object
{
  .param type T
    .custom instance void System.Runtime.CompilerServices.NullableAttribute::.ctor(uint8) = ( 01 00 01 00 00 )
  .custom instance void System.Runtime.CompilerServices.NullableContextAttribute::.ctor(uint8) = ( 01 00 01 00 00 )
  .custom instance void System.Runtime.CompilerServices.NullableAttribute::.ctor(uint8) = ( 01 00 00 00 00 )

  .property instance string Name()
  {
    .get instance string Catalog`1::get_Name()
  }

  .property instance string Description()
  {
    .custom instance void System.Runtime.CompilerServices.NullableAttribute::.ctor(uint8) = ( 01 00 02 00 00 )
    .get instance string Catalog`1::get_Description()
    .set instance void Catalog`1::set_Description(string)
  }
  ...

Das Blob-Format des benutzerdefinierten Attributs ist: ein Zwei-Byte-Prolog 01 00, die festen Konstruktorargumente der Reihe nach, dann ein Zwei-Byte-Zähler benannter Argumente (00 00 hier). Also ist ( 01 00 02 00 00 ) der Prolog, das Byte 02, keine benannten Argumente — [Nullable(2)], die annotierte Form.

Lies die Klasse von oben nach unten:

  • [NullableContext(1)] auf dem Typ: der Standard für jedes Member dieser Klasse ist 1, nicht-nullable. Deshalb trägt Name überhaupt kein Attribut; sein string erbt 1 vom Kontext. Der Compiler wählte 1, weil die meisten Referenzen dieser Klasse nicht-nullable sind; eine Klasse voller ? bekäme [NullableContext(2)], und die nicht-nullable Member wären jene mit expliziten Attributen.
  • [Nullable(0)] auf dem Typ: das beschreibt die eigenen Typen der Klassendeklaration — ihren Basistyp und ihre Interfaces. System.Object ist hier oblivious, also 0. Dieses verwirrt die Leute; es sagt nicht „diese Klasse ist oblivious”, es sagt „die Basistyp-Referenz dieser Klasse hat keine Annotation”.
  • [Nullable(1)] auf dem generischen Parameter T via .param type T: das ist where T : notnull. Eine notnull-Constraint hat keine Laufzeit-Repräsentation — es gibt keine Constraint-Zeile dafür in der GenericParamConstraint-Tabelle — also existiert sie nur als dieses Attribut. Entferne das Attribut und die Constraint ist weg.
  • [Nullable(2)] auf Description: das einzige Member, das vom Kontext abweicht. string?.
  • LastIndex hat nichts und braucht nichts: int? ist Nullable<int>, was im Signatur-Blob ein anderer Typ ist. Reference-Nullability-Attribute haben nichts darüber zu sagen.

Eigenschafts-Accessoren bekommen ihren eigenen NullableContext, wenn sie vom Kontext des Typs abweichen: get_Description und set_Description sind mit [NullableContext(2)] getaggt, damit ihr Rückgabewert und ihr Parameter, die keine eigenen Attribute haben, zu 2 auflösen. Das Eigenschaftsattribut und die Accessor-Kontexte stimmen stets überein; ein Decompiler kann beide lesen.

Die Array-Form lesen: einen generischen Typ verflachen

Aliases ist dort, wo das einzelne Byte nicht mehr reicht. Dictionary<string, List<string?>?> hat vier Referenztyp-Positionen mit unterschiedlichen Annotationen, und der Compiler muss sie alle beschreiben. Er tut das, indem er den Typbaum in Preorder durchläuft — den Typ selbst, dann jedes Typargument rekursiv, von links nach rechts — und ein Byte pro Knoten in ein Array emittiert:

Preorder-Durchlauf: Knoten, dann Typargumente von links nach rechtsDictionary<,>1string1List<>?2string?2verflachte Bytes[ 1, 1, 2, 2 ]Attribut-Blob, uint8[]-ctor01 00 Prolog04 00 00 00 Länge = 401 01 02 02 die Flags00 00 keine benannten Args
  .property instance class [System.Collections]System.Collections.Generic.Dictionary`2<string, class [System.Collections]System.Collections.Generic.List`1<string>> Aliases()
  {
    .custom instance void System.Runtime.CompilerServices.NullableAttribute::.ctor(uint8[]) = ( 01 00 04 00 00 00 01 01 02 02 00 00 )
    .get instance class ... Catalog`1::get_Aliases()
  }

Sieh dir den deklarierten Typ der Eigenschaft im IL an: Dictionary2<string, List1<string>>, nirgends Fragezeichen, weil es in einem Signatur-Blob nirgends gibt, eines hinzusetzen. Alle vier Annotationen leben im Attribut: [1, 1, 2, 2], gelesen in derselben Preorder wie die Abbildung — Dictionary ist 1, string ist 1, List<...>? ist 2, string? ist 2.

Das Blob des uint8[]-Konstruktors ist der Prolog, ein Little-Endian-int32-Elementzähler (04 00 00 00), die Elemente und der Zähler benannter Argumente. Wäre jedes Byte im verflachten Array identisch, kollabiert der Compiler es zum Ein-Byte-Konstruktor — List<string> unter einem Kontext von 1 braucht überhaupt kein Attribut, und List<string?>? wird [Nullable(2)], nicht [Nullable(new byte[] { 2, 2 })]. Ein Decompiler muss beide Konstruktoren handhaben und das einzelne Byte als „dieser Wert für jede Position” behandeln.

Werttypen nehmen einen Slot, halten aber immer 0. Dictionary<int, string?> verflacht zu [1, 0, 2]; Dictionary<int, int?> wäre [1, 0, 0, 0] — Dictionary 1, int 0, Nullable<> 0, das innere int 0 — was, da die vier nicht identisch sind, zur Array-Form kollabiert. Arrays sind ein Knoten mit einem Kind: string?[] ist [1, 2] (nicht-null Array nullable Strings) und string[]? ist [2, 1]. Tupel verflachen über ValueTuple<...> genauso, weshalb (string, string?) [0, 1, 2] erzeugt.

Out-Parameter und die Analyse-Attribute

TryFind zeigt die andere Hälfte der Geschichte:

  .method public hidebysig instance bool TryFind(string key, [out] !T& 'value') cil managed
  {
    .param [2]
      .custom instance void [System.Runtime]System.Diagnostics.CodeAnalysis.MaybeNullWhenAttribute::.ctor(bool) = ( 01 00 00 00 00 )
    ...
  }

Nichts hier ist eingebettet. MaybeNullWhenAttribute ist ein öffentlicher Typ in der BCL; es beschreibt Fluss — „wenn die Methode false zurückgibt, vertraue value nicht” — nicht die deklarierte Nullability des Typs. Der Decompiler zeigt es wörtlich, genau wie er jedes andere Attribut zeigt. key trägt kein NullableAttribute, weil die Methode den Kontext von 1 der Klasse erbt, und !T& hat nichts, weil T auf Klassenebene notnull eingeschränkt ist. Die Trennung ist sauber: deklarierte Nullability (das ? und die Constraints) steckt in den eingebetteten Attributen und muss rekonstruiert werden; Fluss-Attribute sind gewöhnliche Metadaten und dürfen einfach nicht versteckt werden.

Was der Decompiler tun muss

Die Bytes treu zu rendern ist ein kleiner Algorithmus mit mehreren Stellen, an denen man falschliegen kann:

  1. Finde die eingebetteten Attribute über den Namen, nicht über die Identität. Jede Assembly definiert ihr eigenes System.Runtime.CompilerServices.NullableAttribute; es gibt keinen gemeinsamen Typ zum Vergleichen. Vergleiche über den vollen Namen, bestätige den [Embedded]-Marker, und behandle die Definitionen selbst als Compiler-Klempnerei, die aus der Ausgabe zu verstecken ist.
  2. Löse den effektiven Kontext jedes Members auf. Der eigene NullableContext einer Methode schlägt den ihres deklarierenden Typs; der eines verschachtelten Typs schlägt den seines äußeren Typs; der eines Eigenschafts-Accessors schlägt den des Eigenschaftstyps. Ohne Kontext irgendwo in der Kette ist der Standard 0 — oblivious — wie jede Assembly vor C# 8 aussieht.
  3. Durchlaufe jeden Signaturtyp in Preorder und verbrauche Bytes. Nimm das NullableAttribute des Members, falls vorhanden — ein einzelnes Byte gilt für jede Position; ein Array liefert ein Byte pro Knoten. Sonst nimmt jede Position das Kontext-Byte. Werttypen verbrauchen einen Slot und werden ignoriert. Arrays, Pointer, Byrefs und generische Instanziierungen tragen je ihren Knoten bei und rekursieren dann.
  4. Buchstabiere das Ergebnis. Eine 2 auf einem Referenztyp oder Typparameter hängt ? an; eine 1 hängt nichts an; eine 0 bedeutet, das Member muss innerhalb einer #nullable disable-Region gerendert werden, oder die ganze Datei ohne #nullable enable belassen, falls alles 0 ist. Eine 1 auf einem generischen Parameter ohne andere Referenz-Constraint wird where T : notnull; eine 2 auf einer class-Constraint wird class?; eine 2 auf der Verwendung eines uneingeschränkten Parameters wird T?.
  5. Beachte [NullablePublicOnly]. Wenn das Modul diesen Marker trägt, hat der Compiler die Annotationen von nicht-öffentlichen Membern entfernt; ein Decompiler muss diese als oblivious rendern, statt 1 aus einem Kontext abzuleiten, der konstruktionsbedingt nie für sie gedacht war.
  6. Emittiere die Direktive. Eine Datei mit irgendeiner 1- oder 2-Annotation braucht #nullable enable ganz oben, sonst warnt die neu kompilierte Ausgabe an all den falschen Stellen.

Glass tut alle sechs. Öffne Catalog<T> und die Ausgabe ist die Klasse, mit der du angefangen hast: string? Description, Dictionary<string, List<string?>?> Aliases, where T : notnull, [MaybeNullWhen(false)] out T value, und ein #nullable enable ganz oben in der Datei. Die zwei eingebetteten Attributtypen erscheinen nicht im Typbaum — du erreichst sie noch, indem du zur IL-Ansicht wechselst, wo das Blob ( 01 00 04 00 00 00 01 01 02 02 00 00 ) genau das ist, was die Abbildung oben zeigt — und das [Nullable(0)] auf der Klassendeklaration, das einen obliviousen object-Basistyp beschreibt, erzeugt korrekt gar nichts.

Warum es mehr zählt, als es aussieht

Für die meisten vom Compiler erzeugten Metadaten erzeugt ein Decompiler, der sie ignoriert, eine Ausgabe, die bloß hässlicher ist. Nullability ist anders, denn sie zu ignorieren erzeugt eine Ausgabe, die auf eine Weise falsch ist, die kompiliert. Entferne die Attribute und string? Description wird string Description; where T : notnull verdampft; #nullable enable fehlt, also ist die neu gebaute Bibliothek durchweg oblivious. Ein Konsument, der gegen diese neu gebaute Bibliothek neu kompiliert, verliert jede Warnung, die der ursprüngliche Autor dort setzte, und in die andere Richtung ist ein TryFind, dessen out-Parameter ehrlich [MaybeNullWhen(false)] ist, neben einem Rückgabetyp, der nun nicht-null behauptet, ein Vertrag, der nie veröffentlicht wurde.

Das ist der wahre Test eines Decompilers bei diesem Feature: nicht, ob die Ausgabe plausibel aussieht, sondern ob eine daraus neu gebaute Bibliothek ihren Konsumenten denselben Nullability-Vertrag präsentiert wie das Original. Die Information ist alle da in den Metadaten — drei Byte-Werte, eine Preorder-Verflachung, ein Kontext-Standard und ein Paar eingebetteter Attributtypen — und sie zurückzulesen ist, was System.String in das string? verwandelt, das tatsächlich geschrieben wurde.

Nebula.NET testen

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