共用方式為


低層級結構改善

注意

本文是功能規格。 規格可作為功能的設計檔。 其中包含建議的規格變更,以及功能設計和開發期間所需的資訊。 這些文章會發佈,直到提議的規格變更完成並併併入目前的ECMA規格為止。

功能規格與已完成實作之間可能有一些差異。 這些差異被記錄在相關的 語言設計會議(LDM)筆記中。

您可以在探討 規格的文章中了解將功能規格整合進 C# 語言標準的過程

總結

此提案匯集了數個不同的提案,聚焦於 struct 的效能提升,包括 ref 欄位和覆寫存留期預設值的能力。 目標是將各種建議納入考慮,為低階 struct 改善建立單一整體功能集。

注意:此規格的舊版使用了「ref-safe-to-escape」和「safe-to-escape」這兩個詞彙,這是在 Span safety 功能規格中引入的。 ECMA 標準委員會 分別將名稱變更為 “ref-safe-context”“safe-context”。 已精簡安全內容的值,以一致地使用「declaration-block」、「function-member」和「caller-context」。 這些小規範對這些術語使用了不同的措辭,也使用「安全傳回」作為「呼叫者上下文」的同義詞。 此規格已更新為使用 C# 7.3 標準中的詞彙。

並非所有本檔所述的功能都已在 C# 11 中實作。 C# 11 包括:

  1. ref 欄位和 scoped
  2. [UnscopedRef]

這些功能仍然是未來 C# 版本的開放建議:

  1. 將欄位 refref struct
  2. 日落限制類型

動機

舊版 C# 已將許多低階效能功能新增至語言:ref 傳回、ref struct、函式指標等...這些可讓 .NET 開發人員撰寫高效能的程式代碼,同時繼續運用 C# 語言規則的類型和記憶體安全性。 它也允許在 .NET 連結庫中建立基本效能類型,例如 Span<T>

由於這些功能在 .NET 生態系統中逐漸普及,內部和外部的開發人員已經向我們提供了有關生態系統中剩餘摩擦點的相關信息。 在某些地方,他們仍然需要使用 unsafe 程式代碼來完成工作,或者需要運行時特別處理類型,例如 Span<T>

今天,Span<T> 是使用 internal 類型的 ByReference<T> 來完成,運行時有效地將其視為 ref 字段。 這提供了 ref 欄位的優點,但缺點是語言無法像針對其他用途的 ref那樣提供安全驗證。 此外,只有 dotnet/runtime 可以使用此類型作為 internal,因此第三方無法根據 ref 字段來設計自己的基本類型。 這項工作 動機的一部分是移除 ,並在所有程式代碼基底中使用適當的 字段。

此提案計劃基於現有的低階功能來解決這些問題。 具體來說,它的目標是:

  • 允許 ref struct 類型宣告 ref 欄位。
  • 允許執行階段使用 C# 類型系統完整定義 Span<T>,並移除特殊案例類型,例如 ByReference<T>
  • 允許 struct 類型將 ref 傳回其欄位。
  • 允許執行時期移除因存留期預設值的限制所造成的 unsafe 使用
  • 允許在 struct 中為托管和非托管類型宣告安全的 fixed 緩衝區

詳細設計

ref struct 安全規則使用之前的術語定義於 範圍安全文件 中。 這些規則已納入 C# 7 標準,•9.7.216.4.12。 本文件將說明因應此提案而需對本文件進行的必要變更。 一旦接受為核准的功能,這些變更就會併入該檔。

完成此設計之後,我們的 Span<T> 定義如下所示:

readonly ref struct Span<T>
{
    readonly ref T _field;
    readonly int _length;

    // This constructor does not exist today but will be added as a part 
    // of changing Span<T> to have ref fields. It is a convenient, and
    // safe, way to create a length one span over a stack value that today 
    // requires unsafe code.
    public Span(ref T value)
    {
        _field = ref value;
        _length = 1;
    }
}

提供引用欄位和範圍

語言可讓開發人員在 ref struct內宣告 ref 欄位。 例如,封裝大型可變 struct 實例,或在執行時期外的函式庫中定義 Span<T> 等高效能類型時,這會非常有用。

ref struct S 
{
    public ref int Value;
}

ref 欄位將會使用 ELEMENT_TYPE_BYREF 簽章發出至中繼資料。 這與發出 ref 局部變數或 ref 自變數的方式沒有什麼不同。 例如,ref int _field 將被轉換為 ELEMENT_TYPE_BYREF ELEMENT_TYPE_I4。 這將需要我們更新ECMA335以允許此項目,但這應該相當簡單。

開發人員可以使用 default 表示式,繼續使用 ref 字段初始化 ref struct,在此情況下,所有宣告的 ref 字段都會有 值 null。 任何使用這類欄位的嘗試都會導致拋出 NullReferenceException

ref struct S 
{
    public ref int Value;
}

S local = default;
local.Value.ToString(); // throws NullReferenceException

雖然 C# 語言看似ref不能是null,但這在運行時層級是合法的,並且有明確定義的語義。 在類型中引進 ref 字段的開發人員必須注意這種可能性,而且應該 強烈 不建議將此詳細數據洩漏至取用程式代碼。 相反之下,應該使用 運行時輔助工具ref 字段驗證為非 Null,並在未初始化的 struct 被不正確使用時拋出錯誤。

ref struct S1 
{
    private ref int Value;

    public int GetValue()
    {
        if (System.Runtime.CompilerServices.Unsafe.IsNullRef(ref Value))
        {
            throw new InvalidOperationException(...);
        }

        return Value;
    }
}

ref 欄位可以以下列方式與 readonly 修飾詞結合:

  • readonly ref:這是無法在建構函式或 init 方法外部重新賦值的欄位。 它可以在那些情境之外被賦予值
  • ref readonly:這是可以重新指派但無法在任何時間點指派值的欄位。 這就是 in 參數可能被重新指派到 ref 欄位的方法。
  • readonly ref readonlyref readonlyreadonly ref的組合。
ref struct ReadOnlyExample
{
    ref readonly int Field1;
    readonly ref int Field2;
    readonly ref readonly int Field3;

    void Uses(int[] array)
    {
        Field1 = ref array[0];  // Okay
        Field1 = array[0];      // Error: can't assign ref readonly value (value is readonly)
        Field2 = ref array[0];  // Error: can't repoint readonly ref
        Field2 = array[0];      // Okay
        Field3 = ref array[0];  // Error: can't repoint readonly ref
        Field3 = array[0];      // Error: can't assign ref readonly value (value is readonly)
    }
}

要求 readonly ref structref 欄位必須被宣告為 readonly ref。 沒有要求必須在 readonly ref readonly宣告它們。 這確實允許 readonly struct 透過這類欄位進行間接修改,但這與今天指向參考類型的 readonly 欄位並無二致(詳細數據

readonly ref 會使用 initonly 旗標發出至中繼資料,如同其他任何欄位。 ref readonly 欄位會以 System.Runtime.CompilerServices.IsReadOnlyAttribute屬性化。 readonly ref readonly 會與兩個項目一起被發出。

此功能需要運行時間支援和 ECMA 規格的變更。因此,只有在corelib中設定對應的功能旗標時,才會啟用這些旗標。 確切 API 的問題追蹤記錄在此 https://github.com/dotnet/runtime/issues/64165

為了允許 ref 欄位,我們對安全上下文規則進行的變更集是小範圍且具有針對性的。 這些規則已經考慮到 ref 欄位的存在以及從 API 取用的情況。 這些變更只需專注於兩個方面:如何建立,以及如何重新指派。

首先,針對欄位建立 ref-safe-context 值的規則必須針對 ref 字段更新,如下所示:

表單中的表達式 ref e.Fref-safe-context,如下所示:

  1. 如果 Fref 字段,則其 ref-safe-context安全內容e
  2. 否則,如果 e 是參考型別,它具有由 呼叫者上下文的 決定的 ref-safe-context
  3. 否則,其 ref-safe-context 是從 eref-safe-context 取得的。

這並不代表規則變更,因為規則一律會考慮 ref 狀態存在於 ref struct內。 事實上,Span<T> 中的 ref 狀態一直都是這樣運作的,而取用規則正確地考慮到了這一點。 這裡的變更只是讓開發人員能夠直接存取 ref 欄位,並確保他們遵循隱含適用於 Span<T>的現有規則。

這確實表示 ref 欄位可以從 ref struct 傳回為 ref,但一般欄位無法從 ref struct 傳回為 ref

ref struct RS
{
    ref int _refField;
    int _field;

    // Okay: this falls into bullet one above. 
    public ref int Prop1 => ref _refField;

    // Error: This is bullet four above and the ref-safe-context of `this`
    // in a `struct` is function-member.
    public ref int Prop2 => ref _field;
}

這看起來似乎是一目了然的錯誤,但這是一個刻意的設計點。 不過,這不是這個提案所建立的新規則,而是認可現有的規則,Span<T> 現在開發人員可以宣告自己的 ref 狀態。

接下來,需要針對 ref 字段的存在情況來調整重新指派 ref 的規則。 ref 重新指派的主要案例是 ref struct 建構函式,將 ref 參數儲存到 ref 字段。 支援較為普遍,但這是核心情境。 為了支援這項功能,將會調整 ref 重新指派的規則,以考慮 ref 欄位,如下所示:

Ref 重新指派規則

= ref 運算子的左操作數必須是系結至 ref 局部變數的表達式、ref 參數(this以外)、out 參數、或 ref 欄位

若要進行格式為 e1 = ref e2 的 ref 重新指派,以下兩者都必須成立:

  1. e2ref-safe-context 必須至少與 e1ref-safe-context 一樣大
  2. e1 必須與 e2附註 具有相同 安全上下文

這表示所需的 Span<T> 建構函式可在沒有任何額外的註釋的情況下運作:

readonly ref struct Span<T>
{
    readonly ref T _field;
    readonly int _length;

    public Span(ref T value)
    {
        // Falls into the `x.e1 = ref e2` case, where `x` is the implicit `this`. The 
        // safe-context of `this` is *return-only* and ref-safe-context of `value` is 
        // *caller-context* hence this is legal.
        _field = ref value;
        _length = 1;
    }
}

重新指派規則的變更表示 ref 參數現在可以作為 ref struct 值中的 ref 字段從方法中逸出。 如 相容性考慮一節中所討論, 這樣可以變更從未打算將 ref 參數逸出為 ref 字段的現有 API 規則。 參數的存留期規則只以其宣告為基礎,而不是根據其使用方式。 所有 refin 參數都具有 的 ref-safe-context的呼叫端內容,因此現在可以由 refref 欄位傳回。 為了支援具有可逸出或非逸出之 ref 參數的 API,以此還原 C# 10 呼叫點語義,語言將導入有限生命期註記。

scoped 修飾詞

關鍵詞 scoped 將用來限制值的存留期。 它可以針對 ref 或是屬於 ref struct 的值進行應用,並會對 的 ref 安全內容的安全內容 的存留期分別施加影響,將其限制在 函式成員。 例如:

參數或本機 ref-safe-context 安全情境
Span<int> s 函式成員 呼叫端內容
scoped Span<int> s 函式成員 函式成員
ref Span<int> s 呼叫端內容 呼叫上下文
scoped ref Span<int> s 函式成員 呼叫端內容

在此關聯性中,值 ref-safe-context 永遠不能比 安全上下文更寬。

這可讓 C# 11 中的 API 加上批注,使其具有與 C# 10 相同的規則:

Span<int> CreateSpan(scoped ref int parameter)
{
    // Just as with C# 10, the implementation of this method isn't relevant to callers.
}

Span<int> BadUseExamples(int parameter)
{
    // Legal in C# 10 and legal in C# 11 due to scoped ref
    return CreateSpan(ref parameter);

    // Legal in C# 10 and legal in C# 11 due to scoped ref
    int local = 42;
    return CreateSpan(ref local);

    // Legal in C# 10 and legal in C# 11 due to scoped ref
    Span<int> span = stackalloc int[42];
    return CreateSpan(ref span[0]);
}

scoped 註釋也表示 structthis 參數現在可以定義為 scoped ref T。 先前,ref 參數必須在規則中作為特殊案例處理,因為它具有與其他 ref 參數不同的 參照安全上下文 規則(請參閱在安全上下文規則中包含或排除接收者的所有參考)。 現在,這可以在整體規則中表達為一個一般概念,從而進一步簡化它們。

scoped 批注也可以套用至下列位置:

  • 本地:此批註將存活期設定為 安全內容,或在 ref 本地的情況下使用 安全參照內容,設為 函式成員,而無視初始值的存活期。
Span<int> ScopedLocalExamples()
{
    // Error: `span` has a safe-context of *function-member*. That is true even though the 
    // initializer has a safe-context of *caller-context*. The annotation overrides the 
    // initializer
    scoped Span<int> span = default;
    return span;

    // Okay: the initializer has safe-context of *caller-context* hence so does `span2` 
    // and the return is legal.
    Span<int> span2 = default;
    return span2;

    // The declarations of `span3` and `span4` are functionally identical because the 
    // initializer has a safe-context of *function-member* meaning the `scoped` annotation
    // is effectively implied on `span3`
    Span<int> span3 = stackalloc int[42];
    scoped Span<int> span4 = stackalloc int[42];
}

下方的 ,討論局部變數上 的其他用法。

scoped 批注無法套用至任何其他位置,包括傳回、欄位、陣列元素等。此外,當 scoped 應用於任何 ref時,它會產生影響;而 inout 只有在應用於 ref struct的值時才會產生影響。 擁有 scoped int 之類的宣告並沒有影響,因為任何不屬於 ref struct 的項目永遠可以安全傳回。 編譯程式會建立這類案例的診斷,以避免開發人員混淆。

變更 out 參數的行為

若要更嚴格地限制將 refin 參數變更為可傳回 ref 字段的兼容性變更影響,語言將把 out 參數的預設 ref-safe-context 值變更為 函式成員。 實際上,out 參數將隱含 scoped out 並在未來持續使用。 從相容性的觀點來看,這表示 ref無法傳回它們:

ref int Sneaky(out int i) 
{
    i = 42;

    // Error: ref-safe-context of out is now function-member
    return ref i;
}

這會增加傳回 ref struct 值並具有 out 參數的 API 彈性,因為它不必再考慮參考所擷取的參數。 這很重要,因為它是讀取器樣式 API 中的常見模式:

Span<byte> Read(Span<byte> buffer, out int read)
{
    // .. 
}

Span<byte> Use()
{
    var buffer = new byte[256];

    // If we keep current `out` ref-safe-context this is an error. The language must consider
    // the `read` parameter as returnable as a `ref` field
    //
    // If we change `out` ref-safe-context this is legal. The language does not consider the 
    // `read` parameter to be returnable hence this is safe
    int read;
    return Read(buffer, out read);
}

語言也不會再將傳遞至 out 參數的引數視為可返回。 將 out 參數的輸入視為可返回,對開發人員極為困惑。 藉由強制開發人員考慮呼叫端所傳遞但從不使用的值,除非使用不遵循 out的語言,這基本上顛覆了 out 的意圖。 未來支援 ref struct 的語言必須確保傳遞至 out 參數的原始值永遠不會讀取。

C# 可透過明確指派規則達成此目的。 這樣不僅滿足了 ref 安全的上下文規則,同時也允許現有程式碼的 out 參數值被指派並返回。

Span<int> StrangeButLegal(out Span<int> span)
{
    span = default;
    return span;
}

這些變更意味著 out 參數的引數不會將 安全內容ref-safe-context 值貢獻給方法呼叫。 這可大幅降低 ref 字段的整體相容性影響,並簡化開發人員如何思考 outout 參數的引數不會影響傳回值,而僅是一個輸出。

推測宣告表達式中的安全上下文

out 自變數(M(x, out var y))或解構((var x, var y) = M())宣告變數 安全內容 是下列 最窄的

  • 呼叫者上下文
  • 如果 out 變數標示為 scoped,則 宣告區塊(即函數成員或更窄範圍)。
  • 如果 out 變數的類型是 ref struct,請考慮對應調用中的所有參數,包括接收者:
    • 任何自變數的安全內容,當其對應的參數不為 out,且具有 較寬的安全內容,含有 僅傳回 或更寬。
    • 只要其對應參數具有 ref-safe-context 的任何自變數,其 ref-safe-context 的範圍包含為 只返回 或更廣。

另請參閱 宣告表達式的推斷安全內容範例

隱含 scoped 參數

整體而言,有兩個 ref 位置被隱含地宣告為 scoped

  • thisstruct 實例方法上
  • out 參數

ref 安全內容規則會以 scoped refref撰寫。 針對 ref 安全內容,in 參數相當於 ref,而 out 相當於 scoped ref。 只有在規則語意很重要時,才會特別呼叫 inout。 否則,它們只會分別視為 refscoped ref

在討論對應至 in 參數的 ref-safe-context 自變數時,這些自變數會在規範中一般化為 ref 自變數。如果自變數是左值,則 ref-safe-context 是該左值的引用安全上下文,否則則為 函式成員的上下文。 同樣地,只有當目前規則的語意很重要時,才會在這裡呼叫 in

僅退回的安全上下文

設計也需要引進新的安全內容:只允許傳回。 這類似於 呼叫端內容,因為它可以傳回,但只能透過 語句傳回

僅限傳回 的詳細資訊是,這是一個大於 函式成員 且小於 呼叫端情境的內容。 提供給 return 語句的運算式至少必須是 僅限傳回的。 因此,大多數現有的規則都會失效。例如,將具有 安全內容 的表示式指派給 ref 參數,而該表示式為 僅限傳回 時將會失敗,因為這個表示式的安全內容比 ref 參數的 安全內容 更小,而後者為 呼叫端內容。 這個新的逃逸語境的需求將會在下方 討論

有三個位置預設為 僅傳回

  • refin 參數將具備 ref-safe-context 且為 傳回專用。 這部分是為了 ref struct,以防止 愚蠢的迴圈指派 問題。 不過,為了簡化模型,以及將相容性變更降到最低,則會一致地完成。
  • ref structout 參數將具有 安全上下文僅回傳。 這可讓傳回和 out 同樣具有表達性。 這沒有愚蠢的循環指派問題,因為 out 隱含為 scoped,所以 參考安全上下文 仍然小於 安全上下文
  • this 參數對於 struct 建構函式將具有 安全內容,其中 視為回傳專用。 這是因為模型化為 out 參數。

任何明確從方法或 Lambda 傳回值的表達式或語句都必須在至少 僅傳回的情況下具有 safe-context,如果適用,則必須具有 ref-safe-context。 這包括 return 語句、表達式函數成員和 lambda 表達式。

同樣地,對 out 的任何指派都必須具有至少 僅返回安全內容。 不過,這不是特殊案例,這隻會遵循現有的指派規則。

注意:類型不是 ref struct 類型的運算式一律具有 安全內容呼叫端內容

方法調用的規則

方法調用的 ref 安全內容規則將會以數種方式更新。 第一個是辨識 scoped 對自變數的影響。 給定的自變數 expr 傳遞至參數 p時:

  1. 如果 ,則在考慮參數時, 不會對 ref-safe-context 有貢獻。
  2. 如果 pscoped,則在考慮自變數時,expr 不會對 的安全上下文 做出貢獻。
  3. 如果 pout,則 expr 不會提供 ref-safe-context安全內容,更多詳細資訊

語言「不貢獻」表示在分別計算方法回傳的 ref-safe-context安全情境 值時,這些參數並沒有被考慮。 這是因為 scoped 註釋會防止值影響該存留期。

現在可以簡化方法調用規則。 接收者不再需要特別處理,在 struct 的情況下,現在它只是 scoped ref T。 值規則必須變更以考慮 ref 欄位的返回:

方法調用 e1.M(e2, ...)所產生的值,在此情況下,M() 不會傳回 ref-to-ref-struct,並具有取自以下最窄範圍的 安全情境

  1. 呼叫端內容
  2. 當傳回值為 ref struct 時,所有引數表達式共同構成的 安全上下文
  3. 當傳回值是 ref struct 時,所有由 ref 參數提供的 ref-safe-context

如果 M() 傳回 ref-to-ref-struct,則 安全內容 會與所有 ref-to-ref-struct 參數的 安全內容 相同。 如果有多個自變數具有不同 安全內容,因為 方法自變數必須符合,就會發生錯誤。

ref 呼叫規則可以簡化為:

方法調用 ref e1.M(e2, ...)所產生的值,其中 M() 不會傳回 ref-to-ref-struct,ref-safe-context 下列內容中最窄的內容:

  1. 呼叫端內容
  2. 由所有參數表達式所貢獻的 安全上下文
  3. 由所有 ref 自變數貢獻的 ref-safe-context

如果 M() 傳回 ref-to-ref-struct,則 ref-safe-context 是由所有 ref-to-ref-struct 參數所貢獻的最狹窄的 ref-safe-context

此規則現在可讓我們定義所需方法的兩個變體:

Span<int> CreateWithoutCapture(scoped ref int value)
{
    // Error: value Rule 3 specifies that the safe-context be limited to the ref-safe-context
    // of the ref argument. That is the *function-member* for value hence this is not allowed.
    return new Span<int>(ref value);
}

Span<int> CreateAndCapture(ref int value)
{
    // Okay: value Rule 3 specifies that the safe-context be limited to the ref-safe-context
    // of the ref argument. That is the *caller-context* for value hence this is not allowed.
    return new Span<int>(ref value);
}

Span<int> ComplexScopedRefExample(scoped ref Span<int> span)
{
    // Okay: the safe-context of `span` is *caller-context* hence this is legal.
    return span;

    // Okay: the local `refLocal` has a ref-safe-context of *function-member* and a 
    // safe-context of *caller-context*. In the call below it is passed to a 
    // parameter that is `scoped ref` which means it does not contribute 
    // ref-safe-context. It only contributes its safe-context hence the returned
    // rvalue ends up as safe-context of *caller-context*
    Span<int> local = default;
    ref Span<int> refLocal = ref local;
    return ComplexScopedRefExample(ref refLocal);

    // Error: similar analysis as above but the safe-context of `stackLocal` is 
    // *function-member* hence this is illegal
    Span<int> stackLocal = stackalloc int[42];
    return ComplexScopedRefExample(ref stackLocal);
}

物件初始化的規則

物件初始化表示式表示式 安全內容 範圍最窄:

  1. 建構函式呼叫的安全環境
  2. 安全內容 和與能逸出至接收者的成員初始化索引器的參數相關的 ref-安全內容
  3. 成員初始化表達式中指派的 RHS 安全內容 為非只讀 setter,或在 ref 指派時 ref-safe-context

另一種模型化方式是將任何可以指派給接收者的自變數視為成員初始化所使用的建構函式參數。 這是因為成員初始化表達式實際上是建構函式呼叫。

Span<int> heapSpan = default;
Span<int> stackSpan = stackalloc int[42];
var x = new S(ref heapSpan)
{
    Field = stackSpan;
}

// Can be modeled as 
var x = new S(ref heapSpan, stackSpan);

此模型很重要,因為它顯示我們的 MAMM 需要特別處理成員初始化器。 請考慮此特定案例必須是非法的,因為它允許具有較窄 安全內容的值, 指派給較高的值。

方法自變數必須相符

ref 欄位的存在意味著方法參數的規則需更新以達成匹配,因為 ref 參數現在可以被儲存為方法的 ref struct 參數中的一個欄位。 先前規則只需要考慮另一個 ref struct 儲存為欄位。 在 相容性考慮中,會討論這一點的影響。 新規則為 ...

針對任何方法調用 e.M(a1, a2, ... aN)

  1. 從下列項目計算最窄 安全上下文
    • 呼叫端內容
    • 所有自變數 安全內容
    • 所有對應參數具有 ref-safe-context 並屬於 呼叫端內容 的 ref 參數,其 ref-safe-context 為
  2. ref struct 型別的所有 ref 自變數都必須由具有該 安全內容的值來指派。 這是 ref 一般化以包含 inout

針對任何方法調用 e.M(a1, a2, ... aN)

  1. 請計算出最窄的 安全內容,從以下內容開始:
    • 呼叫端內容
    • 所有自變數 安全內容
    • 對應參數未 scoped 之所有 ref 自變數的 ref 安全內容
  2. ref struct 型別的所有 out 自變數都必須由具有該 安全內容的值來指派。

scoped 的存在使開發人員能夠通過將未傳回的參數標記為 scoped,從而減少此規則所引起的摩擦。 這會將上述兩種情況中的參數從(1)中移除,並提供給呼叫者更大的靈活性。

在以下的 中,將會對此變更的影響進行深入討論。 整體而言,這可讓開發人員藉由使用 scoped標註非逸出的 ref 型值,讓呼叫網站更具彈性。

參數範圍差異

參數上的 scoped 修飾詞和 [UnscopedRef] 屬性(參見下面的 ),也會影響我們的物件重載、介面實作及 delegate 轉換規則。 覆寫、介面實作或 delegate 轉換的函式簽名可以:

  • scoped 新增至 refin 參數
  • scoped 新增至 ref struct 參數
  • out 參數移除 [UnscopedRef]
  • [UnscopedRef]ref struct 類型的 ref 參數中移除

scoped[UnscopedRef] 相關的任何其他差異都會被視為不相符。

如果不相符的簽章都使用 C#11 參考安全內容規則,則會回報為 錯誤; 否則,診斷是 警告

以 C#7.2 ref 安全內容規則編譯的模組可能會報告範圍不符警告,其中 scoped 無法使用。 在某些情況下,如果無法修改其他不相符的簽章,可能需要隱藏警告。

scoped 修飾詞和 [UnscopedRef] 屬性也會對方法簽章產生下列影響:

  • scoped 修飾詞和 [UnscopedRef] 屬性不會影響隱藏功能
  • 重載不能僅以 scoped[UnscopedRef] 的不同來區分

由於 [ref] 字段和 [scoped] 區段很長,因此想要以簡短摘要提議的重大變更來結束。

  • 具有 ref-safe-context呼叫端內容 的值,可以由 refref 欄位傳回。
  • out 參數會有 安全內容函式成員

詳細注意事項:

  • ref 欄位只能在 ref struct 內宣告
  • 無法宣告 ref 字段為 staticvolatileconst
  • ref 欄位不能有 ref struct 的類型
  • 參考組件產生流程必須保留 ref structref 欄位的存在。
  • readonly ref struct 必須將其 ref 字段宣告為 readonly ref
  • 針對 by-ref 值,scoped 修飾符必須出現在 inoutref 之前
  • 範圍安全規則檔將會更新,如本檔所述
  • 新的 ref 安全內容規則會在任一情況下生效
    • 核心程式庫包含特性旗標,顯示支援 ref 欄位。
    • langversion 值為 11 或更高

語法

13.6.2 局部變數宣告:已新增 'scoped'?

local_variable_declaration
    : 'scoped'? local_variable_mode_modifier? local_variable_type local_variable_declarators
    ;

local_variable_mode_modifier
    : 'ref' 'readonly'?
    ;

13.9.4 for 語句'scoped'?間接從 local_variable_declaration 新增。

13.9.5 foreach 語句:已新增 'scoped'?

foreach_statement
    : 'foreach' '(' 'scoped'? local_variable_type identifier 'in' expression ')'
      embedded_statement
    ;

12.6.2 自變數清單:已新增 out 宣告變數的 'scoped'?

argument_value
    : expression
    | 'in' variable_reference
    | 'ref' variable_reference
    | 'out' ('scoped'? local_variable_type)? identifier
    ;

12.7 解構運算式

[TBD]

15.6.2 方法參數:已將 'scoped'? 新增至 parameter_modifier

fixed_parameter
    : attributes? parameter_modifier? type identifier default_argument?
    ;

parameter_modifier
    | 'this' 'scoped'? parameter_mode_modifier?
    | 'scoped' parameter_mode_modifier?
    | parameter_mode_modifier
    ;

parameter_mode_modifier
    : 'in'
    | 'ref'
    | 'out'
    ;

20.2 委派宣告:從 fixed_parameter間接新增 'scoped'?

12.19 匿名函式表示式:已新增 'scoped'?

explicit_anonymous_function_parameter
    : 'scoped'? anonymous_function_parameter_modifier? type identifier
    ;

anonymous_function_parameter_modifier
    : 'in'
    | 'ref'
    | 'out'
    ;

日落限制類型

編譯程式有一個「受限制類型」的概念,這基本上是未充分記載的。 因為 C# 1.0 中沒有表達其行為的一般用途方法,因此會提供這些類型的特殊狀態。 值得注意的是,型別可以包含對執行堆疊的引用。 相反地,編譯器對它們有特殊的理解,並限制其使用方式以確保一律是安全的:禁止返回、不能作為陣列元素使用、不能用於泛型等等。

一旦 ref 欄位可用並擴充以支援 ref struct,這些類型就可以在 C# 中使用 ref structref 欄位的組合來正確定義。 因此,當編譯程式偵測到運行時間支援 ref 欄位時,它將不再有受限制的類型概念。 它將直接使用程式碼中定義的型別。

為了支援此,我們的 ref 安全內容規則將會更新,如下所示:

  • __makeref 會被視為具有簽名 static TypedReference __makeref<T>(ref T value) 的方法
  • __refvalue 會被視為具有簽名 static ref T __refvalue<T>(TypedReference tr)的方法。 表達式 __refvalue(tr, int) 會有效地使用第二個自變數作為類型參數。
  • 作為參數的 __arglist 會有 ref-safe-contextsafe-context ,屬於 函式成員的。
  • 作為表達式的 __arglist(...),會有 ref 安全內容,並且 函式成員的安全內容

符合規範的執行時間可確保 TypedReferenceRuntimeArgumentHandleArgIterator 定義為 ref struct。 進一步來說,TypedReference 必須被視為具有一個 ref 欄位至 ref struct,以適應任何可能類型(它可以儲存任何值)。 結合上述規則可確保堆疊的引用不會在其存留期之外逸出。

注意:嚴格來說,這是編譯器的實作細節,而不是語言的一部分。 但是,鑒於與 ref 字段的關係,為了簡單起見,它正包含在語言提案中。

提供未限定範圍

最值得注意的摩擦點之一,就是無法在 struct實例成員中 ref 傳回字段。 這表示開發人員無法建立 ref 傳回方法/屬性,而且必須直接公開字段。 這會降低 refstruct 中通常最受歡迎的效用。

struct S
{
    int _field;

    // Error: this, and hence _field, can't return by ref
    public ref int Prop => ref _field;
}

此預設值的 原理 合理,但參考 struct 逸出 this 原本就沒有錯,只是 ref 安全內容規則選擇的預設。

若要修正此問題,語言會藉由支援 UnscopedRefAttribute來提供一個與 scoped 存留期批注相反的功能。 可以將此功能套用至任何 ref,並將 ref-safe-context 變更為比預設值寬一級。 例如:

UnscopedRef 被應用於 原始 ref-safe-context 新的 ref-safe-context
實例成員 函數成員 僅能回傳
in / ref 參數 僅傳回 呼叫者上下文
out 參數 函式成員 僅限傳回

[UnscopedRef] 套用至 struct 實例方法時,它會影響修改隱含 this 參數。 這表示 this 作為相同類型的未加註釋 ref

struct S
{
    int field; 

    // Error: `field` has the ref-safe-context of `this` which is *function-member* because 
    // it is a `scoped ref`
    ref int Prop1 => ref field;

    // Okay: `field` has the ref-safe-context of `this` which is *caller-context* because 
    // it is a `ref`
    [UnscopedRef] ref int Prop1 => ref field;
}

註釋也可以放在 out 參數上,以將它們還原至 C# 10 行為。

ref int SneakyOut([UnscopedRef] out int i)
{
    i = 42;
    return ref i;
}

針對 ref 安全內容規則的目的,這類 [UnscopedRef] out 只會被視為 ref。 類似於 in 在生命周期上被視為 ref 的情況。

struct內,不允許 [UnscopedRef] 批註用於 init 成員和建構子上。 這些成員在 ref 語意方面已經特別,因為他們將 readonly 成員視為可變動。 這表示將這些成員 ref 顯示為簡單的 ref,而不是 ref readonly。 這在建構函式和 init的範圍內允許。 允許 [UnscopedRef] 會導致這類 ref 錯誤地從建構子中逸出,並且允許在 readonly 語意發生後出現突變。

屬性類型會有下列定義:

namespace System.Diagnostics.CodeAnalysis
{
    [AttributeUsage(
        AttributeTargets.Method | AttributeTargets.Property | AttributeTargets.Parameter,
        AllowMultiple = false,
        Inherited = false)]
    public sealed class UnscopedRefAttribute : Attribute
    {
    }
}

詳細注意事項:

  • [UnscopedRef] 標註的實體方法或屬性,this 的 ref-safe-context 設為 呼叫端內容
  • [UnscopedRef] 批注的成員無法實作介面。
  • 使用 [UnscopedRef] 時發生錯誤
    • 未在 struct 上宣告的成員
    • static 成員、init 成員或 struct 上的建構函式
    • 標示為 scoped 的參數
    • 以值傳遞的參數
    • 由參考傳遞的參數,未隱含限定範圍

ScopedRefAttribute

scoped 批注會透過類型 System.Runtime.CompilerServices.ScopedRefAttribute 屬性發出至元數據。 屬性會以命名空間限定名稱比對,因此定義不需要出現在任何特定元件中。

ScopedRefAttribute 類型僅供編譯程式使用 - 在來源中不允許。 如果編譯中尚未包含,編譯程式就會合成類型宣告。

這個類型會有下列定義:

namespace System.Runtime.CompilerServices
{
    [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = false)]
    internal sealed class ScopedRefAttribute : Attribute
    {
    }
}

編譯程式會使用 scoped 語法,在 參數上發出這個屬性。 只有當語法導致值與其默認狀態不同時,才會發出這個值。 例如,scoped out 會不發出任何屬性。

RefSafetyRulesAttribute

C#7.2 和 C#11 之間,安全內容 規則有數個差異。 當使用 C#11 重新編譯並針對使用 C#10 或更早版本編譯的參考時,這些差異可能會導致中斷性變更。

  1. 未限定範圍的 ref/in/out 參數可能會逸出方法調用,做為 C#11 中 ref structref 字段,而不是在 C#7.2 中
  2. out 參數在 C#11 中隱含限定範圍,在 C#7.2 中未限定範圍
  3. ref / in ref struct 類型的參數在 C#11 中隱含範圍,而未限定在 C#7.2 中

為了降低使用 C#11 重新編譯時中斷變更的機會,我們將更新 C#11 編譯程式,以針對方法 調用使用 ref 安全內容規則 符合用來分析方法宣告的規則。 基本上,分析使用舊版編譯程式編譯之方法的呼叫時,C#11 編譯程式會使用 C#7.2 ref 安全內容規則。

若要啟用此功能,編譯程式會在使用 -langversion:11 或更新版本編譯模組時發出新的 [module: RefSafetyRules(11)] 屬性,或使用包含 ref 字段功能旗標的 corlib 編譯。

屬性的自變數表示編譯模組時所使用的 ref 安全內容 規則的語言版本。 不論傳遞至編譯程式的實際語言版本為何,版本目前固定在 11

預期未來的編譯程式版本會更新 ref 安全上下文規則,並生成具有不同版本的屬性。

如果編譯程式載入了一個模組,該模組包含一個 而非 ,並且在模組中有任何對所宣告的方法的呼叫,則編譯程式將針對無法辨識的版本報告警告。

當 C#11 編譯程式 分析呼叫方法時:

  • 如果包含方法宣告的模組中包含 [module: RefSafetyRules(version)],那麼不論 version,該方法呼叫將依據 C#11 規則進行分析。
  • 如果包含方法宣告的模組來自原始碼,且以 -langversion:11 編譯,或使用包含 ref 欄位功能旗標的 corlib 編譯,則會使用 C#11 規則來分析方法呼叫。
  • 如果包含方法宣告並參考 System.Runtime { ver: 7.0 }的模組,則會使用 C#11 規則分析方法呼叫。 此規則是使用舊版 C#11 / .NET 7 預覽所編譯之模組的暫時緩和措施,稍後將會移除。
  • 否則,會依照 C#7.2 規則分析方法的呼叫。

在 C#11 以前的編譯器會忽略任何 RefSafetyRulesAttribute,並僅使用 C#7.2 的規則來分析方法呼叫。

RefSafetyRulesAttribute 會使用命名空間限定的名稱進行比對,因此定義不需要出現在任何特定組件中。

RefSafetyRulesAttribute 類型僅供編譯程式使用 - 在來源中不允許。 如果編譯中尚未包含,編譯程式就會合成類型宣告。

namespace System.Runtime.CompilerServices
{
    [AttributeUsage(AttributeTargets.Module, AllowMultiple = false, Inherited = false)]
    internal sealed class RefSafetyRulesAttribute : Attribute
    {
        public RefSafetyRulesAttribute(int version) { Version = version; }
        public readonly int Version;
    }
}

安全固定大小緩衝區

C# 11 中未提供安全的固定大小緩衝區功能。 這項功能可能會在未來的 C# 版本中實作。

語言會放寬固定大小陣列的限制,使其可以在安全代碼中宣告,而且元素類型可以是受控或非受控。 這將使以下類型變得合法:

internal struct CharBuffer
{
    internal char Data[128];
}

這些宣告,就像其 unsafe 對應物一樣,會在所屬類型中定義一連串 N 元素。 這些成員可以使用索引器存取,也可以轉換成 Span<T>ReadOnlySpan<T> 實例。

索引到 fixed 類型的 T 緩衝區時,必須考慮容器的 readonly 狀態。 如果容器 readonly,索引器會傳回 ref readonly T 否則會傳回 ref T

在沒有索引器的情況下存取 fixed 緩衝區沒有自然類型,但可轉換成 Span<T> 類型。 如果容器 readonly 緩衝區會隱含轉換成 ReadOnlySpan<T>,否則它可以隱含轉換成 Span<T>ReadOnlySpan<T>Span<T> 轉換會被視為 更好的)。

產生的 Span<T> 實例長度會等於 fixed 緩衝區上宣告的大小。 傳回值的 安全內容 會等於容器 安全內容,就像備份數據是以欄位存取一樣。

針對類型中每個 fixed 宣告,當其元素類型為 T 時,該語言會生成一個對應的僅索引器方法 get,其返回類型為 ref T。 索引器會以 [UnscopedRef] 屬性標註,因為程式實作會傳回宣告類型的欄位。 成員的權限將會符合 fixed 欄位的權限。

例如,CharBuffer.Data 索引器簽章如下:

[UnscopedRef] internal ref char DataIndexer(int index) => ...;

如果提供的索引超出 fixed 陣列的宣告界限,則會擲回 IndexOutOfRangeException。 在此情況下,若提供常數值,則會以適當元素的直接參考來取代。 除非常數超出宣告界限,否則會發生編譯時間錯誤。

也會為每個 fixed 緩衝區產生具名存取子,這些存取子能依值提供 getset 作業。 擁有這表示 fixed 緩衝區將更加接近現有的陣列語意,因為它擁有 ref 存取子以及 byval getset 操作。 這表示編譯程式在產生取用 fixed 緩衝區的代碼時具有相同的彈性,就像取用陣列時一樣。 這應該會讓針對 fixed 緩衝區進行的 await 操作更容易執行。

這還有一個額外的優點,就是讓其他語言更容易取用 fixed 緩衝區。 具名索引器是自 .NET 1.0 版以來已存在的功能。 即便是無法直接產生具名索引器的語言,通常也可以使用它們(C# 實際上是一個很好的例子)。

緩衝區的備份記憶體將會使用 [InlineArray] 屬性產生。 這是 問題 12320 中所討論的機制,特別允許有效率地宣告相同類型欄位的序列。 這個特定問題仍在積極討論中,預期會根據該討論的結果來進行此功能的實作。

newwith 表示式中具有 ref 值的初始化表達式

12.8.17.3 物件初始化表達式一節中,我們會將文法更新為:

initializer_value
    : 'ref' expression // added
    | expression
    | object_or_collection_initializer
    ;

with 表示式區段中,我們會將文法更新為:

member_initializer
    : identifier '=' 'ref' expression // added
    | identifier '=' expression
    ;

指派的左操作數必須是系結至 ref 欄位的表達式。
右操作數必須是能產生一個左值的表達式,該左值與左操作數的類型相同。

我們會新增類似的規則到 本地重新指派的
如果左操作數是可寫入的引用(亦即指涉到 ref readonly 欄位以外的任何對象),則右操作數必須是可寫入的左值。

建構函式調用的逸出規則 會維持:

呼叫構造函式的 new 表達式遵循與方法調用相同的規則,該方法調用被視為傳回所構造的類型。

也就是上面提到的更新後方法調用 的規則:

由方法調用 e1.M(e2, ...) 所產生的右值,具有以下上下文中最小的 安全上下文

  1. 呼叫端內容
  2. 所有引數表達式所貢獻的 安全上下文
  3. 當回傳值是 ref struct 時,所有 ref 自變數共同貢獻於 ref-safe-context

對於具有初始化程式碼的 new 表示式,初始化表達式會計算為參數(它們貢獻其 安全內容),而 ref 初始化表達式則會遞歸地計算為 ref 參數(它們遞歸地貢獻其 ref-safe-context)。

不安全環境中的變更

指標類型(第 23.3 節)會擴充為允許 Managed 類型做為引用類型。 這類指標類型會寫入為Managed類型,後面接著 * 令牌。 它們會產生警告。

地址運算符(第23.6.5節)已放寬,以接受具有受管理的類型的變數作為操作數。

fixed 語句(第 23.7 節)會放寬,接受 fixed_pointer_initializer 是 managed 型別 T 變數的位址,或是具有 managed 型別專案之 array_type 的表達式 T

堆疊配置初始化表達式(區段 12.8.22)同樣寬鬆。

考量事項

在評估此功能時,開發堆疊的其他部分也應考慮一些因素。

相容性考量

此提案的挑戰在於此設計對於我們現有的 跨域安全規則§9.7.2的相容性影響。 雖然這些規則完全支援 ref struct 具有 ref 欄位的概念,但除了 stackalloc以外,它們不允許 API 擷取參考堆疊的 ref 狀態。 ref 安全內容規則具有硬式假設,或§16.4.12.8,假設形式為Span(ref T value) 的建構函式不存在。 這表示安全規則不會考慮能夠逸出為 ref 欄位的 ref 參數,因此它允許以下程式碼。

Span<int> CreateSpanOfInt()
{
    // This is legal according to the 7.2 span rules because they do not account
    // for a constructor in the form Span(ref T value) existing. 
    int local = 42;
    return new Span<int>(ref local);
}

實際上,ref 參數有三種方式可從方法調用中逸出:

  1. 依值傳回
  2. ref 返回
  3. ref 字段在 ref struct 中作為 ref / out 參數傳回或傳遞時

現有的規則只包含(1)和(2)。 他們沒有考慮(3),因此未考慮返回本地變數時出現的差距,例如 ref 字段。 此設計必須變更規則以考慮 (3)。 這會對現有 API 的相容性產生很小的影響。 具體來說,它會影響具有下列屬性的 API。

  • 簽章中有 ref struct
    • 其中 ref struct 是傳回型別、refout 參數
    • 具有一個額外的 inref 參數,且不包含接收者

在 C# 10 中,呼叫這類 API 的開發者從未需要考慮 ref 的狀態輸入會被當作 ref 字段擷取。 這允許在 C# 10 中安全地存在數個模式,但由於 ref 狀態可能作為 ref 欄位洩漏,因此在 C# 11 中將變得不安全。 例如:

Span<int> CreateSpan(ref int parameter)
{
    // The implementation of this method is irrelevant when considering the lifetime of the 
    // returned Span<T>. The ref safe context rules only look at the method signature, not the 
    // implementation. In C# 10 ref fields didn't exist hence there was no way for `parameter`
    // to escape by ref in this method
}

Span<int> BadUseExamples(int parameter)
{
    // Legal in C# 10 but would be illegal with ref fields
    return CreateSpan(ref parameter);

    // Legal in C# 10 but would be illegal with ref fields
    int local = 42;
    return CreateSpan(ref local);

    // Legal in C# 10 but would be illegal with ref fields
    Span<int> span = stackalloc int[42];
    return CreateSpan(ref span[0]);
}

此相容性中斷的影響應該很小。 在缺少 ref 字段的情況下,受到影響的 API 結構沒有什麼意義,因此不太可能有許多客戶建立這些。 執行工具來識別現有存放庫中此 API 結構的實驗證實了該論點。 唯一具有此圖形任何顯著計數的存放庫是 dotnet/runtime,這是因為該存放庫可以透過 ByReference<T> 內建類型方法創建 ref 欄位。

即便如此,設計也必須考慮現有的這類 API,因為它表示有效的模式,而不只是常見的模式。 因此,設計必須提供開發人員在升級至 C# 10 時還原現有存留期規則的工具。 具體來說,它必須提供機制,讓開發人員能夠將 ref 參數標註為不可被 refref 字段逸出的。 這可讓客戶在 C# 11 中定義具有相同 C# 10 呼叫月臺規則的 API。

參考元件

使用此提案中所述功能的編譯參考組件必須維持傳遞 ref 安全內容資訊的元素。 這表示所有生命週期註釋屬性都必須保留在其原始位置。 任何嘗試取代或省略它們都可能導致無效的參考元件。

代表 ref 欄位更為細緻。 在理想情況下,ref 字段會出現在參考元件中,就像任何其他字段一樣。 不過,ref 欄位代表元數據格式的變更,這可能會造成尚未更新以理解此元數據變更的工具鏈發生問題。 具體範例是C++/CLI,如果它取用 ref 字段,可能會發生錯誤。 因此,如果可以從核心連結庫中的參考元件省略 ref 字段,這會是有利的。

ref 欄位本身不會影響 ref 安全內容規則。 具體來說,考慮將現有的 Span<T> 定義翻轉為使用 ref 欄位,這不會影響耗用量。 因此,可以安全地省略 ref 本身。 不過,ref 欄位對必須保留的耗用量有其他影響:

  • 具有 ref 欄位的 ref struct 永遠不會被視為 unmanaged
  • ref 欄位的類型會影響無限的泛型擴充規則。 因此,如果 ref 欄位的類型包含必須保留的類型參數

根據這些規則,這是 ref struct的有效參考組件轉換:

// Impl assembly 
ref struct S<T>
{
    ref T _field;
}

// Ref assembly 
ref struct S<T>
{
    object _o; // force managed 
    T _f; // maintain generic expansion protections
}

附註

生命周期最自然是使用類型來表達。 當存留期類型檢查時,指定的程式存留期是安全的。 雖然 C# 的語法會隱含地將存留期新增至值,但有一個基礎類型系統在這裡描述基本規則。 就這些規則而言,討論變更對設計的影響通常比較容易,因此為了討論而納入此處。

請注意,這不是 100 個% 完整檔。 在這裡,記錄每個個別行為並不是目標。 相對地,它的目的是要建立一個共同的瞭解和用語,以便於對模型及其潛在變更進行討論。

通常不需要直接討論生命週期型別。 例外是生命周期可能會根據特定「實例化」位置而有所不同的地方。 這是一種多型,我們稱之為這些不同存留期的「泛型存留期」,以泛型參數表示。 C# 不提供表達存留期泛型的語法,因此我們會從 C# 定義隱含的「翻譯」到包含明確泛型參數的擴充較低語言。

下列範例會使用具名生命週期。 語法 $a 是指名為 a的存留期。 這是一個本身沒有意義的存留期,但可以透過 where $a : $b 語法,與其他存留期建立關聯性。 這表明 $a 可以轉換為 $b。 可能有助於將此視為確立 $a 的存活期至少和 $b一樣長。

有一些預先定義的存留期,以方便和簡潔為目的如下:

  • $heap:這是存在於堆中任何值的生命週期。 它適用於所有情境和方法簽名。
  • $local:這是方法堆疊上存在之任何值的存留期。 它實際上是 函數成員的名稱佔位符。 它會在方法中隱含定義,而且可以出現在方法簽章中,但任何輸出位置除外。
  • $ro:名稱佔位符 只返回
  • $cm來電者情境 的名稱佔位符

存活期之間有幾種預先定義的關係:

  • 適用於所有生命週期 where $heap : $a$a
  • where $cm : $ro
  • where $x : $local 所有預先定義的存留期。 除非有明確的定義,使用者定義的存留期與本機沒有任何關聯。

在型別上定義的存留期變數可以是非變異或共變數。 這些是使用與泛型參數相同的語法來表示:

// $this is covariant
// $a is invariant
ref struct S<out $this, $a> 

類型定義的存留期參數 $this未預先定義,但在定義時,它確實有一些相關聯的規則:

  • 它必須是第一個生命週期參數。
  • 它必須是協變(covariant):out $this
  • ref 欄位的存留期必須可轉換成 $this
  • 所有非 ref 欄位的 $this 存留期都必須 $heap$this

ref 的存留期是藉由提供生命週期參數給 ref 來表示。例如,指向堆疊的 ref 會以 ref<$heap>表示。

在模型中定義建構函式時,new 名稱將用於 方法。 必須有傳回值的參數清單,以及建構函式自變數。 這是必要的,以表達建構函式的輸入與建構值之間的關係。 模型不會使用 Span<$a><$ro>,而是會改用 Span<$a> new<$ro>。 建構函式中的 this 類型,包括存留期,將會是定義的傳回值。

生命週期的基本規則定義為:

  • 所有生命週期都會以語法結構表示為泛型參數,並且位於類型參數之前。 除了 $heap$local之外,預先定義的存留期也是如此。
  • 所有不屬於 ref structT 類型都會隱含擁有 T<$heap>的存留期。 這是隱含的,不需要在每個範例中撰寫 int<$heap>
  • 對於定義為 ref<$l0> T<$l1, $l2, ... $ln>ref 字段:
    • 所有存留期 $l1$ln 都必須是不變的。
    • $l0 的存活時間必須能轉換為 $this
  • 對於定義為 ref<$a> T<$b, ...>ref$b 必須可轉換成 $a
  • 變數的 ref 具有以下定義的存活期:
    • 針對類型為 ref<$a> Tref 本機變數、參數、欄位或傳回值,其存留期為 $a
    • $heap 用於所有參考型別的類型和欄位
    • 其他所有專案 $local
  • 當基礎類型轉換合法時,指派或傳回是合法的
  • 表示式的存留期可以使用轉換批註明確:
    • (T<$a> expr) 值存留期明確 $aT<...>
    • ref<$a> (T<$b>)expr 的值存留期是 $bT<...>,和 ref 存留期是 $a

針對存留期規則的目的,ref 會被視為表達式類型的一部分,以供轉換之用。 它會以邏輯方式表示,其方式是將 ref<$a> T<...> 轉換成 ref<$a, T<...>>$a 為共變數,而 T 是不變的。

接下來,讓我們定義規則,讓我們將 C# 語法對應至基礎模型。

為了簡潔起見,沒有明確存留期參數的型別,會被視為 out $this 定義並套用至類型的所有欄位。 具有 ref 欄位的類型必須定義明確的存留期參數。

這些規則的存在是用來支援我們現有的不變性,即 T 可以被指派給任何類型的 scoped T。 這對應於在所有已知可轉換為 $local的存留期間,T<$a, ...> 可以指派給 T<$local, ...>。 此外,這支援其他項目,例如能夠將 Span<T> 從堆中指派給棧上的項目。 這確實排除了目前 C# 現實情況中,對於非 ref 值,其欄位具有不同存留期的類型。 若要改變這一點,需要大幅改變 C# 規則,而且需要詳細規劃。

在個體方法中,類型 S<out $this, ...> 內的類型 this 被隱式定義為以下內容:

  • 對於一般實例方法:ref<$local> S<$cm, ...>
  • 針對以 [UnscopedRef]標註的實例方法: ref<$ro> S<$cm, ...>

缺乏明確的 this 參數會使此處的隱含規則發揮作用。 對於複雜的範例和討論,請考慮將其撰寫成 static 方法,並讓 this 成為一個明確的參數。

ref struct S<out $this>
{
    // Implicit this can make discussion confusing 
    void M<$ro, $cm>(ref<$ro> S<$cm> s) {  }

    // Rewrite as explicit this to simplify discussion
    static void M<$ro, $cm>(ref<$local> S<$cm> this, ref<$ro> S<$cm> s) { }
}

C# 方法語法會以下列方式映射到模型:

  • ref 參數的 ref 存留期為 $ro
  • 類型 ref struct 的參數具有此存留期 $cm
  • ref 回傳值的參考存續期為 $ro
  • 類型 ref struct 的傳回值存留期為 $ro
  • 參數或 ref 上的 scoped 會將 ref 存留期變更為 $local

既然如此,讓我們來探索一個示範此模型的簡單範例:

ref int M1(ref int i) => ...

// Maps to the following. 

ref<$ro> int Identity<$ro>(ref<$ro> int i)
{
    // okay: has ref lifetime $ro which is equal to $ro
    return ref i;

    // okay: has ref lifetime $heap which convertible $ro
    int[] array = new int[42];
    return ref array[0];

    // error: has ref lifetime $local which has no conversion to $a hence 
    // it's illegal
    int local = 42;
    return ref local;
}

現在,讓我們使用 ref struct來探索相同的範例:

ref struct S
{
    ref int Field;

    S(ref int f)
    {
        Field = ref f;
    }
}

S M2(ref int i, S span1, scoped S span2) => ...

// Maps to 

ref struct S<out $this>
{
    // Implicitly 
    ref<$this> int Field;

    S<$ro> new<$ro>(ref<$ro> int f)
    {
        Field = ref f;
    }
}

S<$ro> M2<$ro>(
    ref<$ro> int i,
    S<$ro> span1)
    S<$local> span2)
{
    // okay: types match exactly
    return span1;

    // error: has lifetime $local which has no conversion to $ro
    return span2;

    // okay: type S<$heap> has a conversion to S<$ro> because $heap has a
    // conversion to $ro and the first lifetime parameter of S<> is covariant
    return default(S<$heap>)

    // okay: the ref lifetime of ref $i is $ro so this is just an 
    // identity conversion
    S<$ro> local = new S<$ro>(ref $i);
    return local;

    int[] array = new int[42];
    // okay: S<$heap> is convertible to S<$ro>
    return new S<$heap>(ref<$heap> array[0]);

    // okay: the parameter of the ctor is $ro ref int and the argument is $heap ref int. These 
    // are convertible.
    return new S<$ro>(ref<$heap> array[0]);

    // error: has ref lifetime $local which has no conversion to $a hence 
    // it's illegal
    int local = 42;
    return ref local;
}

接下來,讓我們看看這如何協助解決迴圈自我指派問題:

ref struct S
{
    int field;
    ref int refField;

    static void SelfAssign(ref S s)
    {
        s.refField = ref s.field;
    }
}

// Maps to 

ref struct S<out $this>
{
    int field;
    ref<$this> int refField;

    static void SelfAssign<$ro, $cm>(ref<$ro> S<$cm> s)
    {
        // error: the types work out here to ref<$cm> int = ref<$ro> int and that is 
        // illegal as $ro has no conversion to $cm (the relationship is the other direction)
        s.refField = ref<$ro> s.field;
    }
}

接下來,讓我們看看這如何協助解決愚蠢的擷取參數問題:

ref struct S
{
    ref int refField;

    void Use(ref int parameter)
    {
        // error: this needs to be an error else every call to this.Use(ref local) would fail 
        // because compiler would assume the `ref` was captured by ref.
        this.refField = ref parameter;
    }
}

// Maps to 

ref struct S<out $this>
{
    ref<$this> int refField;
    
    // Using static form of this method signature so the type of this is explicit. 
    static void Use<$ro, $cm>(ref<$local> S<$cm> @this, ref<$ro> int parameter)
    {
        // error: the types here are:
        //  - refField is ref<$cm> int
        //  - ref parameter is ref<$ro> int
        // That means the RHS is not convertible to the LHS ($ro is not covertible to $cm) and 
        // hence this reassignment is illegal
        @this.refField = ref<$ro> parameter;
    }
}

未解決問題

變更設計以避免相容性中斷

此設計會針對現有的 ref-safe-context 規則提出數個相容性中斷。 儘管認為這些改變的影響很小,但對於沒有引入重大變更的設計進行了重要考量。

不過,相容性保留的設計比這一設計要複雜得多。 為了保留相容性 ref 字段需要不同的存留期,才能傳回 ref 和傳回 ref 字段。 基本上,它要求我們為方法的所有參數提供 ref-field-safe-context 追蹤。 這必須針對所有表達式進行計算,並在幾乎到處的所有值中追蹤到 ref-safe-context

此外,這個值與 ref-safe-context關聯性。 例如,將值作為 ref 字段傳回,卻不能直接作為 ref傳回,這是不合常理的。 這是因為 ref 已經可以輕易地返回 ref 欄位(即使包含的值不能,ref struct 中的ref 狀態也可以由 ref 返回)。 因此,規則進一步需要不斷調整,以確保這些值彼此有理。

這也表示語言需要語法來表示 ref 參數,這些參數可以透過三種不同的方式傳回:依 ref 字段、依 ref 和傳值傳回。 預設值可以由 ref傳回。 未來更自然的回報,特別是在涉及 ref struct 的情況下,預計將通過 ref 字段或 ref。 這表示新的 API 預設需要額外的語法註釋才能正確。 這是不想要的。

不過,這些相容性變更會影響具有下列屬性的方法:

  • 具有 Span<T>ref struct
    • 其中 ref struct 是傳回型別、refout 參數
    • 有額外的 inref 參數(不包含接收者)

將 API 分成類別有助於理解其影響。

  1. 希望消費者考慮將 ref 擷取為 ref 欄位。 主要範例是 Span(ref T value) 建構函式
  2. 不希望消費者將 ref 記為 ref 字段。 這些卻分成兩個類別
    1. 不安全的 API。 這些是 UnsafeMemoryMarshal 類型的 API,其中 MemoryMarshal.CreateSpan 最突出。 這些 API 會以不安全方式擷取 ref,但也稱為不安全的 API。
    2. 安全 API。 這些 API 採用了 ref 參數以提高效率,但實際上並未在任何地方記錄。 範例雖然很小,但有一個是 AsnDecoder.ReadEnumeratedBytes

這項變更主要有利於上述的 (1) 。 這些預期會構成大部分採用 ref 並返回 ref struct 的 API,展望未來。 變更會對現有的呼叫語意(2.1)和(2.2)造成負面影響,因為存續期規則發生了變化。

不過,類別(2.1)中的 API 主要是由 Microsoft 或最能從 ref 領域獲益最多的開發者撰寫,像 Tanner 這樣的人。 假設這個類別的開發人員在升級至 C# 11 時,可以採用幾個批註的形式,以保留現有語意的相容性稅,前提是 ref 字段在傳回時保留現有的語意。

類別 (2.2) 中的 API 是最大的問題。 目前還不知道有多少這類 API 存在,也不清楚這些 API 在第三方代碼中是否會更常見或更少見。 預期這些變更的數量非常少,特別是如果我們在 out上進行兼容性變更。 到目前為止,搜尋顯示,在 public 表面積中,這些現有的數量非常少。 這是很難搜尋的模式,因為它需要語意分析。 在進行這項變更之前,需要工具型方法,才能驗證這會影響少數已知案例的假設。

針對類別(2)中的這兩種情況,修正方法都是簡單明瞭的。 若不希望將 ref 參數視為可擷取的,必須將 scoped 新增至 ref。 在 (2.1) 中,這也可能會強制開發人員使用 UnsafeMemoryMarshal,但這預期適用於不安全的樣式 API。

在理想情況下,語言可以藉由在 API 悄然進入問題行為時發出警告,以減少靜默中斷變更的影響。 這兩種方法都會採用 ref,會傳回 ref struct,但實際上不會擷取 ref struct中的 ref。 在此情況下,編譯程式可能會發出診斷訊息,通知開發人員應將 ref 標記為 scoped ref

決策 這個設計可以達成,但所產生的功能變得更難使用,因此決定實施相容性中斷。

決策 編譯程式會在方法符合準則時提供警告,但不會將 ref 參數擷取為 ref 字段。 這應該適當地警告客戶在升級時可能會產生的問題。

關鍵詞與屬性

此設計要求使用屬性來標註新的生命週期規則。 這也可以同樣輕鬆地使用內容關鍵詞來完成。 例如,[DoesNotEscape] 可以對應至 scoped。 不過,即使是內容相關的關鍵詞,通常也必須符合非常高的納入標準。 他們佔據了語言中寶貴的位置,是語言中更為顯著的部份。 這項功能雖然很有價值,但會為少數 C# 開發人員提供服務。

表面上似乎偏好不使用關鍵詞,但有兩個重要考慮:

  1. 批注會影響程式語意。 讓屬性影響程式語意是 C# 不願意跨越的界線,目前尚不清楚這個功能是否應該成為語言採取這一步驟的理由。
  2. 最有可能使用此功能的開發人員與使用函式指標的開發人員的集合有高度重疊。 這項功能,儘管只有少數開發人員使用,卻仍然值得引入新的語法,這項決定至今依然被視為合理。

綜合來看,應該考慮語法。

語法的粗略草圖如下:

  • [RefDoesNotEscape] 對應至 scoped ref
  • [DoesNotEscape] 映射到 scoped
  • [RefDoesEscape] 對應至 unscoped

決策 使用 scopedscoped ref語法;使用 屬性 unscoped

允許固定緩衝區局部變數

此設計允許可支援任何類型的安全 fixed 緩衝區。 此處的其中一個可能延伸模組是允許將這類 fixed 緩衝區宣告為局部變數。 這可讓一些現有的 stackalloc 作業取代為 fixed 緩衝區。 它也會擴充我們可以進行堆疊分配的情境範圍;因為 stackalloc 僅限於非受控的元素類型,而 fixed 緩衝區則不受此限制。

class FixedBufferLocals
{
    void Example()
    {
        Span<int> span = stackalloc int[42];
        int buffer[42];
    }
}

此工作是可行的,但確實需要我們稍微擴展本地語法。 不確定這是否值得增加的複雜性。 目前我們可以暫時決定不執行,如果未來證明有足夠的需求,再重新考慮。

範例,說明此處會有幫助的情況:https://github.com/dotnet/runtime/pull/34149

決定 暫時擱置

是否要使用modreqs

如果以新的存留期屬性標示的方法應該或不應該轉譯為發出中的 modreq,就必須做出決策。 如果採用此方法,註釋與 modreq 之間實際上會有 1:1 的對應。

新增 modreq 的理由是屬性會變更 ref 安全內容規則的語意。 只有那些了解這些語意的語言應該呼叫所討論的方法。 在套用至 OHI 案例時,生命週期就成為所有衍生方法必須遵循的合約。 在沒有 modreq 的情況下,如果註釋存在,可能會導致載入具有衝突存留期批註的方法鏈 virtual(這種情況可能發生於只編譯了 virtual 鏈的一部分,而另一部分未編譯)。

初始「ref 安全上下文」工作並未使用 modreq,而是依賴語言和框架來理解。 同時,雖然參與 ref 安全內容規則的所有元素都是方法簽章的強項:refinref struct等...因此,對方法現有規則所做的任何變更,都會導致簽章的二進位變更。 若要讓新的存留期註解達到相同的效果,則需要 modreq 的強制執行。

人們擔心這是否是過度行為。 它確實會產生負面影響,即藉由例如將 [DoesNotEscape] 新增至參數來讓簽章更具彈性,將會導致二進位相容性變更。 這種取捨意味着,隨著時間的推移,像 BCL 這樣的框架可能無法放寬這類簽章的限制。 可以透過採取語言處理 in 參數的方法,並僅在虛擬位置使用 modreq,來在某種程度上進行緩解。

決策 不要在元數據中使用 modreqoutref 之間的差異不是 modreq,但它們現在有不同的 ref 安全上下文值。 這裡對 modreq 規則的部分執行沒有真正的好處。

允許多維度固定緩衝區

是否應擴充 fixed 緩衝區的設計以包含多維度樣式陣列? 基本上允許如下的宣告:

struct Dimensions
{
    int array[42, 13];
}

決策 目前不允許

違反限定範圍

執行時存放庫有數個非公用 API,會將 ref 參數擷取為 ref 欄位。 這些是不安全的,因為未追蹤所產生值的存留期。 例如,Span<T>(ref T value, int length) 建構函式。

大部分的 API 都可能會選擇在傳回時有適當的存留期追蹤,只要更新至 C# 11 即可達成。 不過,一些會想要保留其目前的語意,使其不會追蹤傳回值,因為它們的整個意圖是不安全的。 最值得注意的範例是 MemoryMarshal.CreateSpanMemoryMarshal.CreateReadOnlySpan。 這可藉由將參數標示為 scoped來達成。

這表示運行時間需要已建立的模式,以便從參數中不安全地移除 scoped

  1. Unsafe.AsRef<T>(in T value) 可以藉由變更為 scoped in T value來擴充其現有用途。 這可讓它同時從參數中移除 inscoped。 然後,它成為通用的“移除 ref 安全”方法
  2. 引進一種新方法,其主要目的是移除 scopedref T Unsafe.AsUnscoped<T>(scoped in T value)。 這也會移除 in,因為如果不這樣,呼叫者仍然需要透過多種方法呼叫來「移除 ref safety」,而此時現有的解決方案可能就已足夠。

預設會移除範圍嗎?

預設設計只有兩個位置,其值為 scoped

  • thisscoped ref
  • out scoped ref

關於 out 的決定是大幅減輕 ref 欄位的相容性負擔,同時是更自然的預設值。 它讓開發人員能夠將 out 視為數據只向外流動,而如果是 ref,則規則必須考慮數據的雙向流動。 這會導致嚴重的開發人員混淆。

this 的決定是不可取的,因為這意味著 struct 無法藉由 ref傳回字段。 這是高效能開發人員的重要案例,而且基本上已針對此案例新增 [UnscopedRef] 屬性。

關鍵詞設定的標準很高,僅針對單一情況新增關鍵詞是值得懷疑的。 是否可以考慮完全避免這個關鍵字,方法是將 this 預設為 ref,而不是 scoped ref。 所有需要 this 成為 scoped ref 的成員都可以透過標記方法 scoped 來達成這一點(正如某個方法可以被標記為 readonly 以便於今天創建 readonly ref)。

在一般情況的 struct,這通常是一個正面的改變,因為它只有在成員有 ref 返回時才會引入相容性問題。 有 非常 其中一些方法,工具可以找出這些方法,並將它們快速轉換成 scoped 成員。

ref struct 上,這項變更帶來了更大的相容性問題。 請考慮下列事項:

ref struct Sneaky
{
    int Field;
    ref int RefField;

    public void SelfAssign()
    {
        // This pattern of ref reassign to fields on this inside instance methods would now
        // completely legal.
        RefField = ref Field;
    }

    static Sneaky UseExample()
    {
        Sneaky local = default;

        // Error: this is illegal, and must be illegal, by our existing rules as the 
        // ref-safe-context of local is now an input into method arguments must match. 
        local.SelfAssign();

        // This would be dangerous as local now has a dangerous `ref` but the above 
        // prevents us from getting here.
        return local;
    }
}

基本上,這表示在 可變ref struct 的局部變數上調用的所有實例方法都是非法的,除非該局部變數進一步標註為 scoped。 規則必須考慮欄位重新指派給 this中的其他欄位的情況。 readonly ref struct 沒有此問題,因為 readonly 的特性防止 ref 被重新指派。 但是,這將是對向後相容性的重大破壞性變更,因為它幾乎會影響到每個現有的可變 ref struct

一旦我們擴展到擁有從 ref 字段到 ref structreadonly ref struct 依然存在問題。 透過將擷取移至 [ref] 字段的值,可以解決相同的基本問題:

readonly ref struct ReadOnlySneaky
{
    readonly int Field;
    readonly ref ReadOnlySpan<int> Span;

    public void SelfAssign()
    {
        // Instance method captures a ref to itself
        Span = new ReadOnlySpan<int>(ref Field, 1);
    }
}

有人認為,根據 struct 或成員的類型,讓 this 有不同的預設值。 例如:

  • this 作為 refstructreadonly ref structreadonly member
  • thisscoped refref structreadonly ref struct,具有 ref 字段以 ref struct

這可將相容性問題降到最低,並最大化彈性,但代價是讓客戶的過程變得更複雜。 它也不會完全解決問題,因為未來的功能,例如安全 fixed 緩衝區,要求可變的 ref structref 傳回的字段,而此設計無法單獨運作,因為它會落入 scoped ref 類別。

決策保持thisscoped ref。 這表示上述偷偷摸摸的範例會產生編譯程序錯誤。

將 ref 欄位轉換為 ref 結構

此功能會開啟一組新的 ref 安全內容規則,因為它允許 ref 字位參考 ref structByReference<T> 的這種泛型本質表示到目前為止,執行期無法有這類結構。 因此,我們的所有規則都是根據假設撰寫的,這是不可能的。 ref 欄位功能主要不是關於制定新規則,而是在我們的系統中編纂現有的規則。 允許 ref 欄位 ref struct 需要我們編纂新的規則,因為有數個新的案例需要考慮。

第一個是,readonly ref 現在能夠儲存 ref 狀態。 例如:

readonly ref struct Container
{
    readonly ref Span<int> Span;

    void Store(Span<int> span)
    {
        Span = span;
    }
}

當我們考慮方法參數必須符合規則時,我們需要考慮 readonly ref T 作為潛在的方法輸出,特別是在 T 可能有一個 ref 欄位指向 ref struct的情況下。

第二個問題是語言必須考慮到一種新的安全內容:ref-field-safe-context。 可轉移包含 ref 欄位的所有 ref struct 都有另一個逸出範圍,代表 ref 欄位中的值。 在多個 ref 欄位的情況下,可以將它們集體追蹤為一個單一的值。 參數的預設值為 caller-context

ref struct Nested
{
    ref Span<int> Span;
}

Span<int> M(ref Nested nested) => nested.Span;

此值與容器的 安全環境 無關;也就是說,當容器環境變小時,它不會影響 ref 欄位值的 ref-field-safe-context。 進一步,參考欄位安全上下文 永遠不能小於容器的 安全上下文

ref struct Nested
{
    ref Span<int> Span;
}

void M(ref Nested nested)
{
    scoped ref Nested refLocal = ref nested;

    // the ref-field-safe-context of local is still *caller-context* which means the following
    // is illegal
    refLocal.Span = stackalloc int[42];

    scoped Nested valLocal = nested;

    // the ref-field-safe-context of local is still *caller-context* which means the following
    // is still illegal
    valLocal.Span = stackalloc int[42];
}

這個 ref-field-safe-context 基本上一直存在。 ** 到目前為止,ref 欄位只能指向一般 struct,這樣的話就很容易被簡單地折疊到 呼叫端上下文。 若要支持從 ref 字段到 ref struct,我們現有的規則必須更新,以納入考量這個新的 ref-safe-context

第三,需要更新 ref 重新指派的規則,以確保我們不會違反 ref-field-context 的值。 基本上,對於 x.e1 = ref e2,其中 e1 的類型是 ref structref-field-safe-context 必須相等。

這些問題非常可解決。 編譯程式小組已勾勒出這些規則的幾個版本,大部分都來自我們現有的分析。 問題在於,這類規則並沒有使用程式碼來輔助證明其正確性和可用性。 這讓我們在新增支援上非常猶豫,因為擔心會選擇錯誤的預設值,而導致在運行時利用這個功能時被局限在可用性的死胡同。 這種擔憂特彆強烈,因為 .NET 8 可能會以 allow T: ref structSpan<Span<T>>推動我們走向這個方向。 如果規則與消費代碼一起撰寫,則會更完善。

決策 延遲允許 ref 欄位進行 ref struct,直到 .NET 8 出現能夠支持制定這些相關情境規則的案例為止。 從 .NET 9 起,尚未實作此設定

什麼會成就 C# 11.0?

本檔中概述的功能不需要在單一階段實作。 相對地,它們可以在下列分類中分階段跨不同語言版本進行實作:

  1. ref 欄位和 scoped
  2. [UnscopedRef]
  3. 將欄位從 ref 移到 ref struct
  4. 日落限制類型
  5. 固定大小的緩衝區

在哪個版本中實作的內容只是範圍界定練習。

決定只有由(1)和(2)產生了 C# 11.0。 其餘部分將在未來的 C# 版本中考慮。

未來考慮

進階生命週期註解

此提案中的生命週期註解有限,因為它們可讓開發人員變更值的預設逃逸/不逃逸行為。 這確實為我們的模型增添了強大的彈性,但它不會從根本上變更可表達的關聯性集合。 在 C# 模型的核心仍然是有效的二進位:是否可以傳回值?

這可以讓人們了解有限期限的關係。 例如,無法從方法傳回的值,其存留期小於可從方法傳回的值。 不過,無法描述可從方法傳回之值之間的存留期關聯性。 具體來說,一旦確定這兩個值都可以從方法傳回,就無法說明某個值的存留期比另一個更長。 我們一生演進的下一步將是允許描述這類關係。

Rust 等其他方法可表示這種類型的關聯性,因此可以實作更複雜的樣式作業 scoped。 如果包含這類功能,我們的語言可能會同樣受益。 目前尚無推動壓力來這樣做,但如果將來有,我們的 scoped 模型可以擴大,以相當簡單易行的方式將其納入。

每個 scoped 都可以透過在語法中添加泛型類型參數來指派具名的生命週期。 例如,scoped<'a> 是具有存留期 'a的值。 然後,您可以使用 where 之類的約束條件來描述這些生命週期之間的關聯性。

void M(scoped<'a> ref MyStruct s, scoped<'b> Span<int> span)
  where 'b >= 'a
{
    s.Span = span;
}

此方法會定義兩個存留期,'a'b 及其關聯性,特別是 'b 大於 'a。 這讓呼叫端擁有更精細的規則,以安全地將值傳遞至方法,相較於目前的粗略規則。

問題

下列問題都與此提案有關:

建議

下列提案與此提案相關:

現有範例

Utf8JsonReader

此特定代碼片段需要使用不安全操作,因為在傳遞 Span<T> 時會遇到問題,而 ref struct上的實例方法可能涉及堆疊配置。 即使未擷取此參數,語言也必須假設為 ,因此不必要地在這裡造成摩擦。

Utf8JsonWriter

此代碼段想要藉由逸出數據的元素來變動參數。 逸出的數據可以堆疊配置以提高效率。 即使參數未逸出,編譯程式還是會將 安全內容指派給封入方法外部的安全內容,因為它是參數。 這表示若要使用堆棧配置,實作必須使用 unsafe,才能在逸出數據之後指派回 參數。

有趣的範例

ReadOnlySpan<T>

public readonly ref struct ReadOnlySpan<T>
{
    readonly ref readonly T _value;
    readonly int _length;

    public ReadOnlySpan(in T value)
    {
        _value = ref value;
        _length = 1;
    }
}

節儉清單

struct FrugalList<T>
{
    private T _item0;
    private T _item1;
    private T _item2;

    public int Count = 3;

    public FrugalList(){}

    public ref T this[int index]
    {
        [UnscopedRef] get
        {
            switch (index)
            {
                case 0: return ref _item0;
                case 1: return ref _item1;
                case 2: return ref _item2;
                default: throw null;
            }
        }
    }
}

範例和附注

以下是一組範例,示範規則的運作方式和原因。 包括數個範例,其中顯示危險行為,以及規則如何防止它們發生。 在對提案進行調整時,請務必記住這些事項。

Ref 重新指派和呼叫點

示範 參考重新指派方法調用 如何一起運作。

ref struct RS
{
    ref int _refField;

    public ref int Prop => ref _refField;

    public RS(int[] array)
    {
        _refField = ref array[0];
    }

    public RS(ref int i)
    {
        _refField = ref i;
    }

    public RS CreateRS() => ...;

    public ref int M1(RS rs)
    {
        // The call site arguments for Prop contribute here:
        //   - `rs` contributes no ref-safe-context as the corresponding parameter, 
        //      which is `this`, is `scoped ref`
        //   - `rs` contribute safe-context of *caller-context*
        // 
        // This is an lvalue invocation and the arguments contribute only safe-context 
        // values of *caller-context*. That means `local1` has ref-safe-context of 
        // *caller-context*
        ref int local1 = ref rs.Prop;

        // Okay: this is legal because `local` has ref-safe-context of *caller-context*
        return ref local1;

        // The arguments contribute here:
        //   - `this` contributes no ref-safe-context as the corresponding parameter
        //     is `scoped ref`
        //   - `this` contributes safe-context of *caller-context*
        //
        // This is an rvalue invocation and following those rules the safe-context of 
        // `local2` will be *caller-context*
        RS local2 = CreateRS();

        // Okay: this follows the same analysis as `ref rs.Prop` above
        return ref local2.Prop;

        // The arguments contribute here:
        //   - `local3` contributes ref-safe-context of *function-member*
        //   - `local3` contributes safe-context of *caller-context*
        // 
        // This is an rvalue invocation which returns a `ref struct` and following those 
        // rules the safe-context of `local4` will be *function-member*
        int local3 = 42;
        var local4 = new RS(ref local3);

        // Error: 
        // The arguments contribute here:
        //   - `local4` contributes no ref-safe-context as the corresponding parameter
        //     is `scoped ref`
        //   - `local4` contributes safe-context of *function-member*
        // 
        // This is an lvalue invocation and following those rules the ref-safe-context 
        // of the return is *function-member*
        return ref local4.Prop;
    }
}

Ref 重新指派和不安全的逸出

ref 重新指派規則 中以下這一行的原因可能乍看之下不太明顯:

e1 必須與 e2 具有相同的 安全上下文

這是因為 ref 位置所指向的值存留期是不變的。 間接處理使我們無法在這裡允許任何形式的變化,甚至是縮短生命周期。 如果允許縮小,則會開啟下列不安全的程式代碼:

void Example(ref Span<int> p)
{
    Span<int> local = stackalloc int[42];
    ref Span<int> refLocal = ref local;

    // Error:
    // The safe-context of refLocal is narrower than p. For a non-ref reassignment 
    // this would be allowed as its safe to assign wider lifetimes to narrower ones.
    // In the case of ref reassignment though this rule prevents it as the 
    // safe-context values are different.
    refLocal = ref p;

    // If it were allowed this would be legal as the safe-context of refLocal
    // is *caller-context* and that is satisfied by stackalloc. At the same time
    // it would be assigning through p and escaping the stackalloc to the calling
    // method
    // 
    // This is equivalent of saying p = stackalloc int[13]!!! 
    refLocal = stackalloc int[13];
}

對於從 ref 到非 ref struct 的情況,由於所有值在 安全內容上皆相同,因此滿足此規則。 當值是 ref struct時,這個規則才會真正生效。

在允許 ref 欄位 ref struct的未來,ref 的行為也將非常重要。

具有範圍的局部變數

在本地變量上使用 scoped 對於那些根據條件將不同 安全上下文 的值指派給本地變量的代碼模式會特別有幫助。 這表示程式碼不再需要依賴像 = stackalloc byte[0] 這樣的初始化技巧來定義本機 安全環境,現在只需簡單使用 scoped即可。

// Old way 
// Span<byte> span = stackalloc byte[0];
// New way 
scoped Span<byte> span;
int len = ...;
if (len < MaxStackLen)
{
    span = stackalloc byte[len];
}
else
{
    span = new byte[len];
}

此模式經常出現在低階程序代碼中。 當 ref struct 涉及到 Span<T> 時,可以使用上述技巧。 不過,它不適用於其他 ref struct 型別,而且可能會導致低階程序代碼需要求助於 unsafe,以因應無法正確指定存留期。

具範圍限制的參數值

低階程式碼中反覆產生衝突的其中一個來源是參數的預設跳脫設定過於寬鬆。 它們 安全內容 對於 呼叫端內容。 這是合理的預設值,因為它會與整個 .NET 的程式代碼撰寫模式一致。 在低階程式碼中,雖然大量使用 ref struct,但這一預設值可能會與 ref 安全內容規則的其他部分產生衝突。

發生主要摩擦點的原因是 方法自變數必須符合 規則。 此規則最常在 ref struct 上與實例方法搭配使用,其中至少有一個參數也是 ref struct。 這是低階程式代碼中的常見模式,其中 ref struct 類型通常會在其方法中利用 Span<T> 參數。 例如,它會發生在任何使用 Span<T> 來傳遞緩衝區的寫入器樣式 ref struct 上。

此規則存在以防止下列案例:

ref struct RS
{
    Span<int> _field;
    void Set(Span<int> p)
    {
        _field = p;
    }

    static void DangerousCode(ref RS p)
    {
        Span<int> span = stackalloc int[] { 42 };

        // Error: if allowed this would let the method return a reference to 
        // the stack
        p.Set(span);
    }
}

基本上,此規則存在,因為語言必須假設方法的所有輸入都會逸出至允許的最大 安全內容。 當有 refout 參數,包括接收者時,輸入可能會逸出為這些 ref 值的欄位(如上述 RS.Set 所示)。

實際上,有許多這類方法會傳遞 ref struct 作為參數,但從不打算在輸出中加以利用。 它只是目前方法內所使用的值。 例如:

ref struct JsonReader
{
    Span<char> _buffer;
    int _position;

    internal bool TextEquals(ReadOnlySpan<char> text)
    {
        var current = _buffer.Slice(_position, text.Length);
        return current == text;
    }
}

class C
{
    static void M(ref JsonReader reader)
    {
        Span<char> span = stackalloc char[4];
        span[0] = 'd';
        span[1] = 'o';
        span[2] = 'g';

        // Error: The safe-context of `span` is function-member 
        // while `reader` is outside function-member hence this fails
        // by the above rule.
        if (reader.TextEquals(span))
        {
            ...
        }
    }
}

為了規避這個低階代碼的限制,將利用 unsafe 技巧,誤導編譯器對其 ref struct的存留期的認識。 這可大幅降低 ref struct 的價值主張,因為它們旨在避免 unsafe,同時繼續撰寫高效能程式碼。

這就是 scoped 成為 ref struct 參數有效工具的原因,因為根據更新後的 方法,自變數傳回的方法將其從考慮中排除,且必須符合規則。 取用但從未傳回的 ref struct 參數,可以標示為 scoped,讓通話網站更具彈性。

ref struct JsonReader
{
    Span<char> _buffer;
    int _position;

    internal bool TextEquals(scoped ReadOnlySpan<char> text)
    {
        var current = _buffer.Slice(_position, text.Length);
        return current == text;
    }
}

class C
{
    static void M(ref JsonReader reader)
    {
        Span<char> span = stackalloc char[4];
        span[0] = 'd';
        span[1] = 'o';
        span[2] = 'g';

        // Okay: the compiler never considers `span` as capturable here hence it doesn't
        // contribute to the method arguments must match rule
        if (reader.TextEquals(span))
        {
            ...
        }
    }
}

防止棘手的 ref 指派從 readonly 突變

ref 被用於建構函式或 init 成員中的 readonly 欄位時,類型是 ref 而不是 ref readonly。 這是一種長期的行為,可讓程序代碼像下面這樣:

struct S
{
    readonly int i; 

    public S(string s)
    {
        M(ref i);
    }

    static void M(ref int i) { }
}

這確實會造成潛在問題,但如果這類 ref 能夠儲存到相同類型的 ref 欄位,那就會產生潛在問題。 它允許直接從實例成員對 readonly struct 進行變更:

readonly ref struct S
{ 
    readonly int i; 
    readonly ref int r; 
    public S()
    {
        i = 0;
        // Error: `i` has a narrower scope than `r`
        r = ref i;
    }

    public void Oops()
    {
        r++;
    }
}

由於該提案違反了參考安全上下文規則,因此防止了這種情況的發生。 請考慮下列事項:

  • thisref-safe-context函式成員,而 安全內容 則是 呼叫端內容。 這兩者都是 struct 成員中 this 的標準。
  • i 的 ref-safe-context 函式成員。 這是由 欄位的存留期規則所產生的結果。 具體來說,規則 4。

此時,第 r = ref i 行根據 引用重新指派規則是非法的。

這些規則的目的不是為了防止這種行為,而是做為副作用。 請務必將這點牢記在心,以便在未來更新規則時評估其對此類情境的影響。

愚蠢循環指派

這個設計挑戰的一個方面,就是如何自由地從方法傳回 ref。 讓所有 ref 都能像正常值一樣自由地被傳回,這很可能是大多數開發人員直覺上的期望。 不過,它允許編譯器在計算 ref 安全性時必須考慮的病態情況。 請考慮下列事項:

ref struct S
{
    int field;
    ref int refField;

    static void SelfAssign(ref S s)
    {
        // Error: s.field can only escape the current method through a return statement
        s.refField = ref s.field;
    }
}

這不是我們預期任何開發人員使用的程式代碼模式。 然而,當 ref 可以傳回的存留期與規則下的值相同時,它是合法的。 編譯程式在評估方法呼叫時必須考慮所有法律案例,這會導致這類 API 實際上無法使用。

void M(ref S s)
{
    ...
}

void Usage()
{
    // safe-context to caller-context
    S local = default; 

    // Error: compiler is forced to assume the worst and concludes a self assignment
    // is possible here and must issue an error.
    M(ref local);
}

若要讓這些 API 可供使用,編譯程式可確保 ref 參數 ref 存留期小於相關聯參數值中任何參考的存留期。 這是讓 refref structref-safe-context傳回out呼叫端內容的理由。 這可防止循環賦值,因為壽命的不同。

請注意,[UnscopedRef] 任何 refref-safe-contextref struct呼叫端內容,因此它允許迴圈指派,並強制使用 [UnscopedRef] 呼叫鏈結:

S F()
{
    S local = new();
    // Error: self assignment possible inside `S.M`.
    S.M(ref local);
    return local;
}

ref struct S
{
    int field;
    ref int refField;

    public static void M([UnscopedRef] ref S s)
    {
        // Allowed: s has both safe-context and ref-safe-context of caller-context
        s.refField = ref s.field;
    }
}

同樣地,[UnscopedRef] out 允許迴圈指派,因為參數同時擁有 安全內容參考安全內容,以及 僅回傳

當類型 並非 一個 ref struct 時,將 [UnscopedRef] ref 提升為 的呼叫端上下文 是很有幫助的(請注意,我們希望保持規則簡單,因此這些規則不區分對 ref 和非 ref 結構的引用)。

int x = 1;
F(ref x).RefField = 2;
Console.WriteLine(x); // prints 2

static S F([UnscopedRef] ref int x)
{
    S local = new();
    local.M(ref x);
    return local;
}

ref struct S
{
    public ref int RefField;

    public void M([UnscopedRef] ref int data)
    {
        RefField = ref data;
    }
}

就進階註釋而言,[UnscopedRef] 設計會產生以下項目:

ref struct S { }

// C# code
S Create1(ref S p)
S Create2([UnscopedRef] ref S p)

// Annotation equivalent
scoped<'b> S Create1(scoped<'a> ref scoped<'b> S)
scoped<'a> S Create2(scoped<'a> ref scoped<'b> S)
  where 'b >= 'a

readonly 無法通過 ref 欄位實現深度操作

請考慮下列程式代碼範例:

ref struct S
{
    ref int Field;

    readonly void Method()
    {
        // Legal or illegal?
        Field = 42;
    }
}

在沒有其他因素的情況下設計 readonly 實例上的 ref 欄位規則時,可以有效地將規則設計成使得上述行為合法或非法。 基本上,readonly 可以有效地深入 ref 字段,或者只能套用至 ref。 僅套用至 ref 可防止 ref 重新指派,但允許變更所參考值的一般指派。

不過,這項設計並不孤立存在,而是在針對已經擁有 ref 字段的類型設計規則。 其中最突出的 Span<T>已經對 readonly 的依賴不深。 其主要情境是能夠透過 readonly 實例將值指派給 ref 欄位。

readonly ref struct SpanOfOne
{
    readonly ref int Field;

    public ref int this[int index]
    {
        get
        {
            if (index != 1)
                throw new Exception();
            return ref Field;
        }
    }
}

這表示我們必須選擇 readonly的淺層解譯。

模型建構子

其中一個微妙的設計問題是:建構函式主體如何針對 ref 安全性進行模型化? 基本上,下列建構函式是如何分析的?

ref struct S
{
    ref int field;

    public S(ref int f)
    {
        field = ref f;
    }
}

大致有兩種方法:

  1. 模型化為 static 方法,其中 this 是本地,它的 安全內容 作為 呼叫端內容
  2. 模型作為 static 方法,其中 thisout 參數。

此外,建構函式必須符合下列非變異值:

  1. 請確定 ref 參數可以擷取為 ref 字段。
  2. 確保 refthis 字段不能通過 ref 參數被逃逸。 這會違反 棘手的 ref 指派

我們的意圖是選擇一種符合不變式的形式,而無需為建構函式引入任何特殊規則。 假設建構函式的最佳模型是將 this 視為 out 的參數。 只傳回 out 本質,可讓我們滿足上述所有不變量,而不需要任何特殊情況:

public static void ctor(out S @this, ref int f)
{
    // The ref-safe-context of `ref f` is *return-only* which is also the 
    // safe-context of `this.field` hence this assignment is allowed
    @this.field = ref f;
}

方法自變數必須相符

方法參數必須匹配的規則是開發人員常見的困惑來源。 這是一個規則,其中有許多特殊案例很難理解,除非您熟悉規則背後的推理。 為了更好地了解規則的原因,我們將把 ref-safe-contextsafe-context 簡化為 context

方法可以相當自由地傳回作為參數傳遞給它們的狀態。 基本上,任何不在範圍內的可達狀態都可以返回(包括由 ref返回)。 這可以直接透過 return 語句傳回,或透過指派至 ref 值間接傳回。

直接傳回不會對 ref 安全性造成太大問題。 編譯器只需查看方法中所有可返回的輸入,並將返回值有效地限制為輸入的最小 範疇。 該傳回值接著會經過一般處理。

間接回傳會造成重大問題,因為所有 ref 都是方法的輸入和輸出。 這些輸出已有已知的 內容。 編譯器無法自行推斷新的元素,它必須在其目前層級進行考量。 這表示編譯程式必須查看呼叫方法中可指派的每個單一 ref、評估其 內容,然後確認方法沒有任何可傳回的輸入具有比該 ref內容。 如果有任何此類案例存在,則方法呼叫必須是非法的,因為它可能會違反 ref 安全性。

編譯器執行此安全性檢查的過程是方法參數必須相符的原因。

有一種不同的評估方式,通常更容易讓開發人員考慮,就是進行以下練習:

  1. 查看方法定義,找出可以間接傳回狀態的所有位置:a。 指向 ref struct b 的可變動 ref 參數。 具有 ref 可指派 ref 字段 c 的可變動 ref 參數。 可分配的 ref 參數或 ref 字段指向 ref struct(需遞歸考慮)
  2. 查看呼叫點 a。 確認與上方識別出的地點對應的情境 (b)。 識別可傳回之方法之所有輸入的內容(不要與 scoped 參數一致)

如果 2.b 中的任何值小於 2.a,則方法呼叫必須是非法的。 讓我們看看幾個範例來說明規則:

ref struct R { }

class Program
{
    static void F0(ref R a, scoped ref R b) => throw null;

    static void F1(ref R x, scoped R y)
    {
        F0(ref x, ref y);
    }
}

查看對 F0 的呼叫,讓我們來看看 (1) 和 (2)。 具有間接傳回潛力的參數是 ab,因為它們都可以被直接指派。 與這些參數對應的引數如下:

  • 對應至具有 內容呼叫端內容xa
  • b 映射到 y,其具有 背景函數成員

方法的可傳回輸入集為

  • 呼叫端內容 的 之逸出範圍 的
  • 呼叫端內容 的 逸出範圍
  • y 具有函式成員 的逸出範圍

ref y 無法傳回,因為它對應至 scoped ref 因此不會被視為輸入。 但是,假設至少有一個輸入(y 自變數)的 逸出範圍 小於其中一個輸出(x 自變數),那麼方法呼叫是非法的。

不同的變化如下:

ref struct R { }

class Program
{
    static void F0(ref R a, ref int b) => throw null;

    static void F1(ref R x)
    {
        int y = 42;
        F0(ref x, ref y);
    }
}

同樣地,具有間接傳回潛力的參數是 ab,因為兩者都可以直接指派。 但是可以排除 b,因為它不會指向 ref struct 因此無法用來儲存 ref 狀態。 因此,我們有:

  • 映射至具有 內容呼叫者內容xa

方法的可傳回輸入集如下:

  • 呼叫端內容 的背景 上具有 x
  • 具有 內容呼叫端內容ref x
  • 中具有 函式成員 內容 ref y

假設至少有一個輸入(ref y 參數)的 逸出範圍 小於其中一個輸出(x 參數)的範圍,這樣的方法呼叫是不合法的。

這是方法參數必須遵循的規則所嘗試包含的邏輯。 進一步探討使用 scoped 排除輸入的考慮,和使用 readonly 用於移除 ref 作為輸出的方式(因無法指派至 readonly ref,所以它不能是輸出的來源)。 這些特殊案例確實會將複雜度新增至規則,但這樣做是為了開發人員的利益。 編譯器會尋求移除其識別的所有無關輸入和輸出,以便在呼叫成員時為開發人員提供最大靈活性。 就像多載解析一樣,當規則為使用者創造更多的彈性時,值得努力讓我們的規則變得更加複雜。

宣告表達式的推斷 安全內容 範例

與宣告表達式的 推斷 安全內容 相關,

ref struct RS
{
    public RS(ref int x) { } // assumed to be able to capture 'x'

    static void M0(RS input, out RS output) => output = input;

    static void M1()
    {
        var i = 0;
        var rs1 = new RS(ref i); // safe-context of 'rs1' is function-member
        M0(rs1, out var rs2); // safe-context of 'rs2' is function-member
    }

    static void M2(RS rs1)
    {
        M0(rs1, out var rs2); // safe-context of 'rs2' is function-member
    }

    static void M3(RS rs1)
    {
        M0(rs1, out scoped var rs2); // 'scoped' modifier forces safe-context of 'rs2' to the current local context (function-member or narrower).
    }
}

請注意,scoped 修飾詞產生的當地上下文是可以用於變數的最狹窄可能,任何更窄的表達式都意味著該表達式指的是僅在比表達式更狹窄的上下文中被宣告的變數。