Skip to content
← Alle Beiträge
· Delta1 Labs DecompilationGlass

ref-readonly-Parameter dekompilieren: nur ein Attribut von in entfernt

ref readonly und in kompilieren zu byteweise identischen Parameter-Metadaten: gleiches [in]-Flag, gleiches modreq(InAttribute). Der einzige Unterschied ist ein benutzerdefiniertes Attribut. Das liest Glass.NET.

ref readonly-Parameter, hinzugekommen in C# 12, sehen aus, als müssten sie in den Metadaten eine große Sache sein. Sie sind eine neue Richtung, Argumente zu übergeben, eine neue Regel dafür, was man übergeben darf, und ein viertes Mitglied der Referenzfamilie neben ref, out und in. Es ist daher ein wenig überraschend, dass ein ref readonly-Parameter in der kompilierten Assembly byteweise identisch zu einem in-Parameter ist — bis auf ein einziges benutzerdefiniertes Attribut. Dieses eine Attribut ist die gesamte Arbeit, die ein Dekompilierer hier leisten muss, und es ist zugleich genau das Stück, das ein Dekompilierer übersieht, der nur Signaturen liest — und so jedes ref readonly still wieder in in verwandelt. Dieser Beitrag geht die Metadaten beider durch und zeigt, was Glass.NET rekonstruiert.

Zwei Schlüsselwörter, eine binäre Form

Beginnen wir mit zwei Methoden, die bis auf die Parameterrichtung bewusst gleich sind:

public static class Geometry
{
    public static double LengthIn(in Vector3 v) =>
        Math.Sqrt(v.X * v.X + v.Y * v.Y + v.Z * v.Z);

    public static double LengthReq(ref readonly Vector3 v) =>
        Math.Sqrt(v.X * v.X + v.Y * v.Y + v.Z * v.Z);
}

in gibt es seit C# 7.2; ref readonly ist der neuere, strengere Verwandte. Der praktische Unterschied im Quellcode betrifft, was der Aufrufer übergeben darf. Ein in-Parameter akzeptiert alles still — eine Variable, ein Feld, sogar ein Literal — und der Compiler zaubert für die Fälle, die noch kein Speicherort sind, einen versteckten temporären Wert herbei. Ein ref readonly-Parameter will einen echten Speicherort und warnt, wenn er keinen bekommt, was genau der Punkt ist: Er ist für APIs gedacht, die eine Referenz entgegennehmen, um das Kopieren einer großen Struktur zu vermeiden, wo das Übergeben eines Wegwerf-Temporärs fast immer ein Fehler ist, den der Autor markiert haben möchte. Das ist eine Regel zur Kompilierzeit, vom Compiler durchgesetzt und als Warnung gemeldet, und — entscheidend — sie hinterlässt keinerlei Spur in der Form des Parameters.

Die Parameter-Metadaten lesen

Hier hören die beiden Methoden auf, verschieden auszusehen. Dekompiliere LengthIn nach IL, und der Parameter ist eine verwaltete Referenz mit einem Richtungs-Flag und einem erforderlichen Modifikator:

.method public hidebysig static float64 LengthIn (
    [in] valuetype Vector3& modreq([System.Runtime]System.Runtime.InteropServices.InAttribute) v
) cil managed
{
  .param [1]
    .custom instance void [System.Runtime]System.Runtime.CompilerServices.IsReadOnlyAttribute::.ctor() = ( 01 00 00 00 )
  // ...
}

Drei Dinge kodieren das in. Das [in]-Flag ist die Richtung des Parameters (ParamAttributes.In). Das modreq([System.Runtime]System.Runtime.InteropServices.InAttribute) ist ein erforderlicher benutzerdefinierter Modifikator, in der Signatur an den Typ Vector3& geschweißt — modreq, nicht modopt, also Teil der Signatur, sodass ein Konsument, der ihn nicht versteht, die Bindung verweigern muss. Und der Block .param [1] trägt ein System.Runtime.CompilerServices.IsReadOnlyAttribute, die Markierung, die sagt: „diese Referenz ist schreibgeschützt”.

Nun LengthReq, die ref readonly-Variante:

.method public hidebysig static float64 LengthReq (
    [in] valuetype Vector3& modreq([System.Runtime]System.Runtime.InteropServices.InAttribute) v
) cil managed
{
  .param [1]
    .custom instance void [System.Runtime]System.Runtime.CompilerServices.RequiresLocationAttribute::.ctor() = ( 01 00 00 00 )
  // ...
}

Lies die beiden Signaturen nebeneinander. Der Rückgabetyp ist gleich, das [in]-Flag ist gleich, und das modreq(InAttribute) ist derselbe erforderliche Modifikator. Der einzige Unterschied in der ganzen Methode ist der Name des benutzerdefinierten Attributs im .param-Block: IsReadOnlyAttribute für in, RequiresLocationAttribute für ref readonly. Das ist die gesamte Unterscheidung, und es ist ein gewöhnliches benutzerdefiniertes Attribut — kein Modifikator, kein Flag, nichts, was am Signaturabgleich teilnimmt.

Wie sich in und ref readonly in den Parameter-Metadaten unterscheidenin vs ref readonly: identische Bytes, ein Attribut Unterschiedin int xref readonly int x[in] (ParamAttributes.In)[in] (ParamAttributes.In)int32& modreq(InAttribute)int32& modreq(InAttribute).custom IsReadOnlyAttribute.custom RequiresLocationAttributegleichgleichin Vector3 vref readonly Vector3 vWarum die Form geteilt wirdEin modreq, den ein alter Compiler nicht parsen kann, macht die Methode unaufrufbar — also übernimmt ref readonlydie Form von in für binäre Kompatibilität und markiert sich mit einem benutzerdefinierten Attribut, das alte Compiler ignorieren.Das Schlüsselwort wird aus diesem Attribut rekonstruiert, nie aus dem modreq, denn der modreq ist für beide identisch.

Warum die Form bewusst geteilt wird

Das ist kein Zufall der Implementierung; es ist die Randbedingung, die das Feature definiert. Das Sprachteam wollte zweierlei zugleich. Erstens darf der Wechsel eines bestehenden in-Parameters in einer veröffentlichten Bibliothek zu ref readonly keinen Aufrufer brechen, der gegen die alte Signatur kompiliert wurde — es soll eine sichere, quell- und binärkompatible Verschärfung sein. Zweitens soll ein Compiler, der ref readonly versteht, die strengere „Speicherort übergeben”-Regel durchsetzen.

Ein erforderlicher Modifikator kann das erste Ziel nicht erfüllen. Die ganze Bedeutung von modreq ist „verweigere die Bindung, wenn du dies nicht verstehst”, also würde ein eigenes modreq(RequiresLocation) jeden Aufrufer vor C# 12 am Kompilieren hindern — das genaue Gegenteil von kompatibel. Deshalb übernimmt ref readonly die bestehende binäre Kodierung von in, [in] plus modreq(InAttribute), die alte Compiler bereits als gewöhnlichen in-Parameter behandeln, und legt die neue Bedeutung über ein benutzerdefiniertes Attribut darauf — RequiresLocationAttribute —, das alte Compiler schlicht ignorieren, so wie sie jedes Attribut ignorieren, das sie nicht kennen. Der modreq dient der Kompatibilität; das Attribut dient der neuen Semantik. Diese Teilung ist der Grund, weshalb das korrekte Schlüsselwort nur aus dem Attribut zurückgewonnen werden kann.

Woher RequiresLocationAttribute kommt

Wenn du nach System.Runtime.CompilerServices.RequiresLocationAttribute suchst und es nicht in dem Framework liegt, auf das du gezielt hast, ist das zu erwarten — der Compiler erzeugt es. Definiert das Zielframework den Typ nicht, bettet Roslyn eine private Kopie direkt in die gerade gebaute Assembly ein, markiert mit Microsoft.CodeAnalysis.EmbeddedAttribute und CompilerGenerated, genau wie bei IsReadOnlyAttribute, IsExternalInit und den Nullable-Kontext-Attributen, wenn diese fehlen. Glass.NET erkennt eingebettete Attributtypen als das, was sie sind — Compiler-Infrastruktur — und nutzt RequiresLocationAttribute, um ref readonly zu rekonstruieren, ohne den eingebetteten Typ selbst als von dir geschriebenen Code anzuzeigen.

Auch die Aufrufstelle ist identisch

Es ist verlockend zu hoffen, dass der Aufruf den Parameter eindeutig macht, aber das tut er nicht. Sowohl in als auch ref readonly werden per schreibgeschützter Referenz übergeben, also nimmt der Compiler eine Adresse und ruft auf:

    // Geometry.LengthReq(in v);   — v ist eine lokale Variable
    ldloca.s   v
    call       float64 Geometry::LengthReq(valuetype Vector3& modreq([System.Runtime]System.Runtime.InteropServices.InAttribute))

Das ist dasselbe ldloca + call, das ein in-Aufruf erzeugt, und der Parametertyp im Aufruf-Token ist dasselbe Vector3& modreq(InAttribute). Übergibst du der ref readonly-Methode ein rvalue — was der Compiler mit einer Warnung wie CS9193 („Argument sollte eine Variable sein”) akzeptiert —, legt er den Wert zunächst einfach in einem schreibgeschützten temporären Wert ab und nimmt dessen Adresse; immer noch eine Adresse, immer noch dieselbe Aufrufform. Nichts an der Aufrufstelle hält fest, ob die aufgerufene Methode in oder ref readonly deklarierte. Glass.NET stellt das Schlüsselwort jedes Arguments daher nach dem Parameter dar, an den es gebunden ist, in der Definition der aufgerufenen Methode — nicht aus etwas, das auf dem Aufruf aufgeprägt wäre.

Was ein Dekompilierer falsch macht, der nur die Signatur ansieht

Ein Dekompilierer, der Parameter allein aus der Signatur rekonstruiert — dem [in]-Flag und dem modreq(InAttribute) —, hat keine Möglichkeit, die beiden zu unterscheiden, denn sie sind identisch. Die ehrliche Ausgabe dieses Ansatzes ist, alle solchen Parameter als in darzustellen, was für jedes ref readonly der Assembly falsch ist. Es ist eine stille Art von Fehler: Der Code kompiliert noch und läuft noch, also meldet nichts ihn, doch die zurückgewonnene API behauptet nun einen lockereren Vertrag, als der Autor schrieb, und wer sie liest, um die Absicht der Bibliothek zu verstehen — warum nimmt diese Methode eine Referenz entgegen, und stört sie ein temporärer Wert? —, wird in die Irre geführt.

Glass.NET liest die benutzerdefinierten .param-Attribute und leitet die Richtung daraus ab: RequiresLocationAttribute ergibt ref readonly, IsReadOnlyAttribute ergibt in, und ein nacktes modreq(InAttribute) ohne eines der beiden Attribute — was handgeschriebenes IL oder eine andere Sprache erzeugen kann — fällt auf in zurück, die native Bedeutung der Form. Das Ergebnis ist der Quellcode, von dem du ausgegangen bist:

public static double LengthIn(in Vector3 v) => /* ... */;
public static double LengthReq(ref readonly Vector3 v) => /* ... */;

Du kannst es in Glass auf beide Weisen bestätigen: Die dekompilierte C#-Ansicht zeigt ref readonly am Parameter, und die IL-Ansicht daneben zeigt das RequiresLocationAttribute im Block .param [1], das es erzeugt hat, mit dem gemeinsamen modreq(InAttribute) direkt dort an der Signatur, damit du siehst, dass das Schlüsselwort tatsächlich aus dem Attribut kam und nirgendwo sonst.

Das Fazit

ref readonly ist eines jener Features, bei denen die interessante Metadaten-Geschichte ist, wie wenig Metadaten es gibt. Der Parameter wird als gewöhnliches in kodiert — [in]-Flag, modreq(System.Runtime.InteropServices.InAttribute) — gerade damit ältere Compiler sich weiterhin daran binden, und die gesamte Unterscheidung reitet auf einem einzigen benutzerdefinierten Attribut, RequiresLocationAttribute, das der Compiler oft selbst in die Assembly einbetten muss. Lies das Attribut, und du gewinnst ref readonly zurück; lies nur die Signatur, und du bekommst überall in — korrekt aussehend und subtil falsch. Glass.NET liest das Attribut, fällt nur dann auf in zurück, wenn es wirklich nichts anderes gibt, worauf es sich stützen könnte, und zeigt dir das IL daneben, damit du siehst, aus welcher Hälfte der Metadaten das Schlüsselwort kam.

Nebula.NET testen

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