Start Debugging

Was ist ref-safe-context in C#, und wie legt der Compiler sie für ref-Locals fest?

ref-safe-context ist der Gültigkeitsbereich, in dem ein Verweis auf eine Variable gültig bleibt. Hier sind die vier Contexts, die Regel, die den Context eines ref-Locals bei seinem Initialisierer festlegt, der Grund, warum eine ref-Neuzuweisung ihn nie erweitern oder einengen kann, und die daraus folgenden Fehler CS8157, CS8168, CS8374 und CS9079, mit Compiler-Ausgabe von C# 14 auf .NET 10 und .NET 11 RC1.

Kurze Antwort: ref-safe-context (Gültigkeitsbereich für Referenzen) ist der Codebereich, in dem ein Verweis (ein ref-Local, ein ref-Rückgabewert, ein ref-Feld) auf eine bestimmte Variable garantiert auf lebenden Speicher zeigt. Der C#-Compiler weist jeder Variable einen von vier Contexts zu, vom engsten zum weitesten: declaration-block, function-member, return-only und caller-context. Ein ref-Local bekommt keinen eigenen Context. Es übernimmt die ref-safe-context des Ausdrucks, mit dem es initialisiert wurde, dieser Context bleibt für seine gesamte Lebensdauer fest, und jedes spätere r = ref other; muss auf etwas zeigen, dessen Context mindestens so weit ist. Diese eine Regel erklärt, warum ein ref-Local, das aus einer Stack-Variablen initialisiert wurde, nie zurückgegeben werden kann, selbst wenn Sie es später auf ein Array-Element umbiegen.

Jedes Ergebnis in diesem Beitrag stammt aus echten Builds: dem .NET 10 SDK 10.0.302 mit <LangVersion>14</LangVersion> und denselben Dateien, neu kompiliert mit dem .NET 11 RC1 SDK (11.0.100-rc.1.26425.128). Die Diagnosen sind auf beiden identisch und stammen direkt aus der Compiler-Ausgabe, nicht aus einer Paraphrase.

Warum der Compiler überhaupt einen “Context” verfolgt

Ein ref in C# ist ein verwalteter Zeiger. Er kann in den Heap zeigen (ein Array-Element, ein Feld einer Klasseninstanz), in statischen Speicher oder in einen Stack-Frame (ein Local, ein Parameter). Heap und statischer Speicher überdauern jeden Methodenaufruf. Ein Stack-Frame endet, wenn seine Methode zurückkehrt. Könnte eine Methode ein ref auf eines ihrer eigenen Locals herausgeben, hielte der Aufrufer einen Zeiger in einen toten Frame, genau der Use-after-free-Fehler, den ref gerade nicht haben sollte.

C# löst das nicht mit einer Laufzeitprüfung, sondern statisch: Jede Variable erhält eine ref-safe-context, die beschreibt, wie weit ein Verweis auf sie wandern darf, und jede Stelle, die einen Verweis speichert oder zurückgibt, prüft, dass der Context des Referenten mindestens so weit ist wie der des Verweises selbst. Der C#-Standard fasst es in einem Satz zusammen, in Abschnitt 9.7.2: “The referent of a reference variable shall have a ref-safe-context that is at least as wide as the ref-safe-context of the reference variable itself.”

Daneben gibt es ein verwandtes Konzept, die safe-context, die für Werte von ref struct-Typen wie Span<T> gilt. Die ref-safe-context beantwortet “Wie lange ist ein Verweis auf diese Variable gültig?” Die safe-context beantwortet “Wie lange ist der in dieser Variable gespeicherte Wert (der selbst Verweise enthält) gültig?” Der Großteil dieses Beitrags handelt von der ersten, aber beide treffen sich bei den Indexern von Span<T>, was weiter unten behandelt wird.

Die vier Contexts, vom engsten zum weitesten

ContextWas ihn hatPer ref zurückgebbar?
declaration-blockGewöhnliche Locals, beschränkt auf den Block, in dem sie deklariert sindNein
function-memberWertparameter, out-Parameter, this in einer Struct-Methode, scoped ref-Locals und -ParameterNein
return-onlyref- und in-ParameterJa, nur über return ref
caller-contextArray-Elemente, Felder von Klasseninstanzen, statische Felder, Felder, die über ref-Parameter erreicht werdenJa

Zwei Details gehen leicht unter. Erstens gilt declaration-block wirklich pro Block: Ein Local, das im Rumpf eines if deklariert wird, hat einen engeren Context als eines am Anfang der Methode. Zweitens existiert return-only, damit ein ref-Parameter zurückgegeben werden kann (return ref p; ist erlaubt), aber nicht an einer Stelle gespeichert werden kann, die den Aufruf überdauert, etwa in einem ref-Feld eines vom Aufrufer übergebenen ref struct. Diese Stufe kam in C# 11 als Teil der Low-Level-Struct-Verbesserungen hinzu.

Hier die Ausgangslage im Code, jede Methode kompiliert unter C# 14:

// .NET 10 SDK 10.0.302, C# 14 (same result on .NET 11 RC1)
class Contexts
{
    int[] _arr = new int[10];
    static int s_counter;

    // declaration-block: error CS8168
    static ref int FromLocal() { int v = 5; return ref v; }

    // function-member (value parameter): error CS8157 once aliased
    static ref int FromValueParam(int p) { ref int r = ref p; return ref r; }

    // return-only (ref parameter): OK
    static ref int FromRefParam(ref int p) { ref int r = ref p; return ref r; }

    // caller-context (array element): OK
    ref int FromArray() { ref int r = ref _arr[3]; return ref r; }

    // caller-context (static field): OK
    static ref int FromStatic() { ref int r = ref s_counter; return ref r; }
}

Die zwei Fehler:

error CS8168: Cannot return local 'v' by reference because it is not a ref local
error CS8157: Cannot return 'r' by reference because it was initialized to a value that cannot be returned by reference

Lesen Sie die Formulierung von CS8157 genau: “because it was initialized to”. Der Compiler schaut nicht darauf, worauf r beim return zeigt. Er schaut darauf, womit r initialisiert wurde.

Wie ein ref-Local seinen Context bekommt

Die Regel des Standards für Locals (Abschnitt 9.7.2.2) umfasst zwei Zeilen:

Der Compiler legt den Context eines ref-Locals also an genau einer Stelle fest: bei der Deklaration. Der Initialisierungsausdruck wird mit diesen Regeln auf seinen Context hin ausgewertet:

  1. Ein Local oder ein Wertparameter: sein eigener Context (declaration-block oder function-member).
  2. Ein ref- oder in-Parameter: return-only.
  3. Ein Feld e.F einer Klasseninstanz, ein Array-Element, ein statisches Feld: caller-context.
  4. Ein Feld e.F eines Structs: der Context von e. Ein Feld eines Struct-Locals ist declaration-block; ein Feld von this in einer Struct-Methode ist function-member.
  5. Ein bedingter Ausdruck c ? ref a : ref b: der engere von a und b.
  6. Der Aufruf einer ref-zurückgebenden Methode, Eigenschaft oder eines Indexers: der engste Wert aus caller-context und der ref-safe-context jedes ref-Arguments (plus der safe-context jedes Arguments, den Empfänger eingeschlossen), wobei Argumente übersprungen werden, die an scoped- und out-Parameter gebunden sind.

Regel 6 überrascht die Leute am meisten, denn sie bedeutet, dass ein völlig harmloser Helfer ein ref-Local vergiften kann:

// .NET 10 SDK 10.0.302, C# 14
static ref int Pick(ref int a) => ref a;

static ref int ThroughHelper()
{
    int v = 5;
    ref int r = ref Pick(ref v); // r inherits v's declaration-block context
    return ref r;                // error CS8157
}

Pick selbst ist in Ordnung, es gibt seinen eigenen ref-Parameter zurück. Aber der Compiler schaut nicht in Pick hinein. Er sieht nur die Signatur: “gibt ein ref zurück, das jedes meiner ref-Argumente sein kann”. Der Context des Ergebnisses ist also der des engsten Arguments, und dieses Argument ist ein Local. So verhindert die Spezifikation, dass eine Stack-Referenz über einen Methodenaufruf gewaschen wird.

Der Ausweg, wenn der Helfer dieses Argument tatsächlich nie zurückgibt, ist, das mit scoped zu sagen:

// .NET 10 SDK 10.0.302, C# 14
static ref int PickSecond(scoped ref int a, ref int b) => ref b;

static ref int Fine(ref int b)
{
    int v = 5;
    ref int r = ref PickSecond(ref v, ref b); // only b contributes: return-only
    return ref r;                             // OK
}

Ein scoped ref-Parameter kann vom Aufgerufenen weder zurückgegeben noch erfasst werden und trägt daher nicht mehr zum Context des Ergebnisses bei. Der Bedingungsoperator hat keinen solchen Ausweg:

// .NET 10 SDK 10.0.302, C# 14
ref int Either(bool f)
{
    int v = 1;
    ref int r = ref f ? ref _arr[0] : ref v; // narrowest wins: declaration-block
    return ref r;                            // error CS8157
}

Eine ref-Neuzuweisung kann den Context weder erweitern noch einengen

Seit C# 7.3 kann ein ref-Local mit r = ref other; auf etwas anderes gerichtet werden. Die Regel aus Abschnitt 9.7.2.9 lautet: “For a ref reassignment e1 = ref e2, the ref-safe-context of e2 shall be at least as wide a context as the ref-safe-context of e1.” Der Context des Locals ändert sich dadurch nicht. Das hat Folgen in beide Richtungen.

Einengen ist ein Fehler. Initialisieren Sie aus einem Array-Element und versuchen Sie dann, auf ein Local zu zeigen:

// .NET 10 SDK 10.0.302, C# 14
ref int Narrowing()
{
    ref int r = ref _arr[0]; // caller-context
    int v = 1;
    r = ref v;               // error CS8374
    return ref r;
}
error CS8374: Cannot ref-assign 'v' to 'r' because 'v' has a narrower escape scope than 'r'.

Dasselbe passiert zwischen verschachtelten Blöcken, und das ist die Variante, auf die man in echten Schleifen stößt:

// .NET 10 SDK 10.0.302, C# 14
static void NestedBlock()
{
    int outer = 1;
    ref int r = ref outer;   // declaration-block of the method body
    {
        int inner = 2;
        r = ref inner;       // error CS8374: inner's block is narrower
    }
}

Erweitern ist erlaubt, hilft aber nicht. Initialisieren Sie aus einem Local und zeigen Sie dann auf ein Array-Element. Die Neuzuweisung kompiliert. Die Rückgabe nicht:

// .NET 10 SDK 10.0.302, C# 14
ref int Widening()
{
    int v = 1;
    ref int r = ref v;   // declaration-block, fixed forever
    r = ref _arr[0];     // OK: caller-context is wider
    return ref r;        // error CS8157: r was *initialized* to a local
}

Das ist absichtlich flussunabhängig. Der Compiler verfolgt nicht, dass r gerade zufällig auf den Heap zeigt. Er kennt nur den deklarierten Context, und der ist declaration-block.

Die return-only-Falle. Ein ref-Parameter ist return-only, was enger ist als caller-context. Das scheitert daher, mit einem anderen Fehlercode:

// .NET 10 SDK 10.0.302, C# 14
ref int FieldThenParam(ref int p)
{
    ref int r = ref _arr[0]; // caller-context
    r = ref p;               // error CS9079
    return ref r;
}
error CS9079: Cannot ref-assign 'p' to 'r' because 'p' can only escape the current method through a return statement.

Sowohl _arr[0] als auch p sind einzeln zurückgebbar, und doch wird die Kombination abgelehnt. Die Lösung ist in all diesen Fällen dieselbe: Initialisieren Sie das ref-Local mit dem engsten Referenten, den es je halten wird, und weisen Sie danach weiteren zu.

// .NET 10 SDK 10.0.302, C# 14
ref int ParamThenField(ref int p, bool useField)
{
    ref int r = ref p;               // return-only
    if (useField) r = ref _arr[0];   // caller-context is wider: OK
    return ref r;                    // OK: return-only is returnable
}

scoped ref-Locals: bewusst einen engeren Context wählen

Sie können ein ref-Local auch gezielt einengen. Deklarieren Sie es als scoped ref, erhält es unabhängig vom Initialisierer den Context function-member:

// .NET 10 SDK 10.0.302, C# 14
ref int ScopedLocal()
{
    scoped ref int r = ref _arr[0]; // function-member, despite caller-context initializer
    return ref r;                   // error CS8157
}

Für sich genommen wirkt das sinnlos. Es lohnt sich, wenn das Local später auf etwas Engeres umgebogen wird. Vergleichen Sie die beiden Methoden unten, die sich nur im Schlüsselwort scoped unterscheiden:

// .NET 10 SDK 10.0.302, C# 14
void Unscoped()
{
    ref int r = ref _arr[0]; // caller-context
    int v = 1;
    r = ref v;               // error CS8374
    r = 5;
}

void Scoped()
{
    scoped ref int r = ref _arr[0]; // function-member
    int v = 1;
    r = ref v;                      // OK: v's block is the method body
    r = 5;
}

Verstehen Sie scoped als den dokumentierten Weg zu sagen: “Dieser Verweis verlässt die Methode nie”, was Neuzuweisungen freischaltet, die der abgeleitete Context verbieten würde. Dasselbe Schlüsselwort an einem ref struct-Local (scoped Span<int> s) engt stattdessen dessen safe-context ein, wodurch sein Inhalt nicht entweichen kann.

Structs, this und [UnscopedRef]

In einer Struct-Instanzmethode ist this ein impliziter scoped ref-Parameter, sein Context ist also function-member, und das gilt auch für alle seine Felder (Regel 4). Deshalb kann ein Struct sein eigenes Feld nicht per ref zurückgeben:

// .NET 10 SDK 10.0.302, C# 14
using System.Diagnostics.CodeAnalysis;

struct Counter
{
    int _n;

    public ref int Bad() => ref _n;                 // error CS8170
    [UnscopedRef] public ref int Good() => ref _n;  // OK: this widened to return-only

    static ref int UseGood()
    {
        Counter c = default;
        ref int r = ref c.Good(); // receiver c is a local: declaration-block
        return ref r;             // error CS8157
    }
}
error CS8170: Struct members cannot return 'this' or other instance members by reference

[UnscopedRef] erweitert this um eine Stufe, von function-member auf return-only, was für return ref _n genügt. Das Ergebnis wird dadurch aber nicht heap-sicher. An der Aufrufstelle ist c.Good() ein ref-zurückgebender Aufruf, dessen Empfänger per ref übergeben wird; Regel 6 greift also, und das Ergebnis erbt den declaration-block-Context des Locals c. Der zurückgegebene Verweis lebt nur so lange wie das Struct, in das er zeigt, und genau das ist korrekt.

Spans: wo ref-safe-context auf safe-context trifft

Span<T> hat einen ref-zurückgebenden Indexer, daher folgt ref span[i] ebenfalls Regel 6. Der Empfänger steuert seine safe-context bei, und die safe-context eines Spans hängt davon ab, woher sein Speicher stammt:

// .NET 10 SDK 10.0.302, C# 14
static ref int FromStackSpan()
{
    Span<int> local = stackalloc int[4]; // safe-context: function-member
    ref int r = ref local[0];
    return ref r;                        // error CS8157
}

static ref int FromHeapSpan()
{
    Span<int> local = new int[4];        // safe-context: caller-context
    ref int r = ref local[0];
    return ref r;                        // OK
}

Die beiden Methoden sind bis auf den Initialisierer von local identisch, und nur eine kompiliert. Ein Span-Parameter hat die safe-context caller-context, wodurch das klassische Muster “Element per Referenz finden” ohne Annotationen erlaubt ist:

// .NET 10 SDK 10.0.302, C# 14
static ref int MaxRef(Span<int> values)
{
    ref int best = ref values[0];        // caller-context via the span parameter
    for (int i = 1; i < values.Length; i++)
        if (values[i] > best) best = ref values[i]; // same context: OK
    return ref best;
}

int[] data = { 3, 9, 4 };
MaxRef(data) = 0;
Console.WriteLine(string.Join(",", data)); // 3,0,4

Jede Neuzuweisung in der Schleife stammt aus demselben Span, der Context gerät also nie in Konflikt. Das ist die Form, die Sie anstreben sollten: eine Quelle, ein Context, beliebig neu zuweisen.

So lesen Sie die Fehlercodes

Wenn Sie auf einen dieser Fehler stoßen, verrät der Code, welche Seite der Regel verletzt wurde:

  1. CS8168 (“not a ref local”): Sie haben return ref auf ein einfaches Local oder einen Wertparameter angewendet. Geben Sie den Wert zurück oder verlagern Sie den Speicher auf den Heap.
  2. CS8157 (“was initialized to a value that cannot be returned”): Der Initialisierer des ref-Locals hatte einen engeren Context als return-only. Schauen Sie auf den Initialisierer, nicht auf die letzte Neuzuweisung. Prüfen Sie Helferaufrufe auf lokale ref-Argumente und markieren Sie nicht entweichende Parameter als scoped.
  3. CS8374 (“narrower escape scope”): Eine Neuzuweisung wollte das Local einengen. Initialisieren Sie zuerst mit dem engsten Referenten oder deklarieren Sie das Local im selben Block wie den Referenten.
  4. CS9079 (“can only escape the current method through a return statement”): der Sonderfall von CS8374, bei dem der neue Referent ein ref- oder in-Parameter (return-only) ist und das Local caller-context hatte. Initialisieren Sie stattdessen aus dem Parameter.
  5. CS8170: Ein Struct gibt einen Verweis auf sein eigenes Feld zurück. Fügen Sie [UnscopedRef] hinzu, wenn Aufrufer damit leben können, dass das Ergebnis an die Lebensdauer des Structs gebunden ist.

Stolperfallen, die man kennen sollte

Verwandte Beiträge

Quellen

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Zurück