低级别结构改进

注意

本文是功能规格说明。 此规范是功能的设计文档。 它包括建议的规范变更,以及功能设计和开发过程中所需的信息。 这些文章将持续发布,直至建议的规范变更最终确定并纳入当前的 ECMA 规范。

功能规范与已完成的实现之间可能存在一些差异。 这些差异记录在相关的语言设计会议 (LDM) 说明中。

可以在有关规范的文章中了解更多有关将功能规范子块纳入 C# 语言标准的过程。

总结

此建议是 struct 性能改进的几种不同建议的聚合:ref 字段和替代生存期默认值的能力。 我们的目标是在设计中考虑到各种建议,为低级struct改进创建单一总体功能集。

注意:此规范的早期版本使用了术语“ref-safe-to-escape”和“safe-to-escape”,这些术语是在跨度安全功能规范中引入的。 ECMA 标准委员会分别将名称改为“ref-safe-context”“安全上下文”。 对安全上下文的值进行了改进,以便更加一致地使用“声明块”、“函数成员”和“调用者上下文”。 小规范对这些术语使用了不同的措辞,还使用了“safe-to-return”作为“caller-context”的同义词。 此规范已更新为使用 C# 7.3 标准中的术语。

本文档中概述的功能并非都已在 C# 11 中实现。 C# 11 包括:

  1. ref 字段和 scoped
  2. [UnscopedRef]

这些功能仍是 C# 未来版本的开放方案:

  1. ref 字段到 ref 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 字段设计自己的原始类型。 这项工作的动机的部分目的是删除 ByReference<T> 并在所有代码库中使用合适的 ref 字段。

此提案计划通过在现有的底层功能之上进行构建来解决这些问题。 具体而言,它旨在:

  • 允许 ref struct 类型声明 ref 字段。
  • 允许运行时使用 C# 类型系统完全定义 Span<T>,并删除特殊情况类型,如 ByReference<T>
  • 允许 struct 类型返回 ref 到其字段。
  • 使运行时能够删除由于生存期默认值的限制而导致的 unsafe 使用
  • 允许在 struct 中为托管和非托管类型声明安全的 fixed 缓冲区

详细设计

ref struct 安全规则在范围安全文档中使用上述术语定义。 这些规则已被纳入 C# 7 标准 §9.7.2§16.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 字段和作用域

该语言将允许开发人员在 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。 这将要求我们更新 ECMA 335 以允许此条目,但这应该是比较简单的。

开发人员可以继续使用 ref struct 表达式来初始化带有 ref 字段的 default,在这种情况下,所有声明的 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 struct 要求 ref 字段声明为 readonly ref。 没有要求必须声明它们为 readonly ref readonly。 这确实允许 readonly struct 通过此类字段进行间接突变,但这与今天指向引用类型的 readonly 字段不同(更多详细信息

将使用 initonly 标志向元数据发出 readonly ref,与任何其他字段相同。 将为 ref readonly 字段赋予 System.Runtime.CompilerServices.IsReadOnlyAttributereadonly ref readonly 将与两个项目一起发出。

此功能需要运行时支持和对 ECMA 规范的更改。因此,只有在 corelib 中设置了相应的功能标志后,才能启用这些功能。 跟踪确切 API 的问题在此处跟踪 https://github.com/dotnet/runtime/issues/64165

为了允许 ref 字段,我们需要对安全上下文规则进行一些必要的、规模小而有针对性的更改。 这些规则已经考虑到了 ref 字段的存在以及通过 API 的使用。 这些更改只需要关注两个方面:如何创建更改以及如何重新分配它们。

首先,需要为 ref 字段更新 ref-safe-context 值的规则,如下所示:

形式为 ref e.Fref-safe-context 的表达式如下所示:

  1. 如果 Fref 字段,则其 ref-safe-contexte
  2. 否则,如果 e 为引用类型,则拥有调用方上下文引用安全上下文
  3. 否则,ref-safe-context 取自 eref-safe-context

但这并不代表规则的改变,因为规则一直允许 ref 状态存在于 ref struct 中。 事实上,ref 状态在 Span<T> 中一直是这样运作的,消耗规则也正确地考虑到了这一点。 此处的更改仅是为了让开发人员能够直接访问 ref 字段,并确保他们遵循适用于 Span<T>的隐含规则。

这就意味着,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 struct 构造函数将 ref 参数存储到 ref 字段中。 支持将更加广泛,但这是核心方案。 为支持这一点,将对 ref 重定向规则进行调整,以考虑 ref 字段,具体如下:

Ref 重新分配规则

= ref 运算符的左操作数必须是一个表达式,该表达式绑定到 ref 局部变量、ref 参数(非 this)、out 参数、或 ref 字段

e1 = ref e2 格式中的 ref 重新分配中,以下两项都必须成立:

  1. e2ref-safe-context 必须至少与 e1 一样大。
  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 参数现在可以从方法中逸出为 ref 值中的 ref struct 字段。 在兼容性注意事项部分中提到,这一变化可以更改现有 API 的规则,这些 API 从未打算将 ref 参数转义为 ref 字段。 参数的生存期规则完全基于参数的声明,而不是参数的用法。 所有 refin 参数都具有调用方上下文引用安全上下文,因此现在可以通过 refref 字段返回。 为了支持具有可转义或非转义 ref 参数的 API,并恢复 C# 10 调用处语义,该语言将引入有限的生存期注释。

scoped 修饰符

关键字 scoped 将用于限制值的生存期。 它可以应用于 refref struct 值,并起到将 ref-safe-context 安全上下文的生存期分别限制在函数成员的效果。 例如:

参数或本地 ref-safe-context safe-context
Span<int> s function-member caller-context
scoped Span<int> s function-member function-member
ref Span<int> s caller-context caller-context
scoped ref Span<int> s function-member caller-context

在此关系中,值的 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 设为 function-member,而不考虑初始值设定项的生存期。
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 批注不能应用于包括返回值、字段、数组元素等在内的任何其他位置。此外,scoped 对任何应用在 ref上的情况都有效,而 inout 只有在应用于 ref struct值时才会产生影响。 声明如 scoped int 对结果没有影响,因为任何不是 ref struct 的返回都是安全的。 编译器将为这种情况创建一个诊断程序,以免开发人员混淆。

更改 out 参数的行为

为了进一步限制使 refin 参数作为 ref 字段可返回的兼容性变化的影响,该语言将把 参数的默认 out 值更改为 function-member。 实际上,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 参数不再向方法调用贡献 safe-contextref-safe-context 的值。 这显著降低了 ref 字段的整体兼容性影响,并简化了开发人员对 out 的考虑。 out 参数的参数不会导致返回,它只是输出。

推断声明表达式的 safe-context

来自参数(out)或析构(M(x, out var y))的声明变量的(var x, var y) = M()是以 中最窄的

  • caller-context
  • 如果 out 变量标记为 scoped,则 声明块(即函数成员或更窄范围)。
  • 如果 out 变量的类型是 ref struct,请考虑到该调用中包含的所有参数,包括接收者:
    • 任何参数的安全上下文,其中其相应的参数未 out,并且具有仅返回 或更宽的安全上下文
    • ref-safe-context 的任何参数,其对应参数的 ref-safe-contextreturn-only 或更宽

另请参阅声明表达式的安全上下文的推断示例

隐式 scoped 参数

总体来看,有两个 ref 地点被隐式声明为 scoped

  • struct 实例方法上的 this
  • out 个参数

ref 安全上下文规则将用scoped refref来编写。 就 ref 安全上下文而言,in 参数等效于 ref,而 out 等效于 scoped refinout 仅在规则的语义显得特别重要时才会被明确调用。 否则,它们只被视为 refscoped ref

在讨论与 参数对应的参数的 in 时,这些参数将在规范中被概括为 ref 参数。如果该参数是左值,则其 ref-safe-context 是该左值的,否则为 函数成员。 同样,只有在对当前规则的语义有重要影响时,才会在此调用 in

仅返回安全上下文

设计还要求引入新的安全上下文:仅返回。 这类似于调用方上下文,因为它可以返回,但只能通过 return 语句返回。

仅返回的详细信息是,它是一个大于函数成员但小于调用方上下文的上下文。 提供给 return 语句的表达式必须至少为仅返回。 因此,大多数现有规则不再适用。例如,将表达式中的值赋给 ref 参数时,如果该表达式的安全上下文属于仅返回,则会失败,因为它的安全上下文小于 ref 参数的安全上下文,该参数属于调用方上下文。 此新的转义上下文的需求将在以下讨论。

有三个位置默认仅返回

  • refin 参数将具有仅返回ref-safe-context。 这部分是为了ref struct防止愚蠢循环分配 问题。 不过,为了简化模型并最大程度地减少兼容性更改,可以统一完成此操作。
  • ref structout 参数将具有仅返回安全上下文。 这允许 return 和 out 拥有同样的表达能力。 这没有愚蠢的循环赋值问题,因为 out 隐式为 scoped,因此 ref-safe-context 仍然小于安全上下文
  • this 构造函数的 struct 参数将具有仅返回安全上下文。 由于被建模为 out 参数,这导致了此结果。

任何从方法或 lambda 显式返回值的表达式或语句都必须具有 安全上下文,并且如果适用的话,还必须具有 ref-safe-context,且至少具有 return-only。 这包括 return 语句、表达式主体成员和 lambda 表达式。

同样,对 out 的任何赋值都必须具有至少 仅返回安全上下文。 这并非一个特例,而是根据现有的赋值规则得出的结论。

注意:类型不是 ref struct 类型的表达式始终具有调用方上下文安全上下文

方法调用规则

方法调用的 ref 安全上下文规则将以多种方式进行更新。 首先是认识到 scoped 对参数的影响。 对于给定的参数 expr,它被传递给参数 p

  1. 如果 pscoped ref,那么在考虑参数时,expr 不对 ref-safe-context 构成贡献。
  2. 如果 pscoped,那么在考虑参数时,expr 不对 safe-context 构成贡献。
  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-safe-context 由所有 ref 参数贡献

如果 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

如果 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-safe-context 可转义到接收器。
  3. 成员初始值设定项中分配给非只读 setter 的 RHS 安全上下文,或者在 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. 从以下内容计算最窄安全上下文
    • caller-context
    • 所有参数的安全上下文
    • 所有 ref 参数的 ref-safe-context,其相应参数具有调用方上下文ref-safe-context
  2. ref struct类型的所有 ref参数必须由具有该安全上下文的值分配。 在这种情况下,ref通用化以包括 inout

对于任何方法调用 e.M(a1, a2, ... aN)

  1. 从以下内容计算最窄安全上下文
    • caller-context
    • 所有参数的安全上下文
    • 其相应参数未 的所有引用参数的 scoped
  2. ref struct类型的所有 out参数必须由具有该安全上下文的值分配。

scoped 的存在允许开发人员通过将不返回的参数标记为 scoped 来减少此规则产生的摩擦。 这在上述两种情况下都从(1)中删除了其参数,并为调用方提供了更大的灵活性。

此更改的影响在下方进行更深入的讨论。 总的来说,这将允许开发人员通过在 scoped 注释非转义类似 ref 的值来使调用网站更加灵活。

参数范围差异

参数的 scoped 修饰符和 [UnscopedRef] 属性(请参阅下方)也影响我们的对象重写、接口实现和 delegate 转换规则。 替代、接口实现或 delegate 转换的签名可以:

  • scoped 添加到 refin 参数中
  • scoped 添加到 ref struct 参数
  • out 参数中删除 [UnscopedRef]
  • ref struct 类型的 ref 参数中删除 [UnscopedRef]

scoped[UnscopedRef] 的任何其他差异都会被视为不匹配。

在以下情况下对于覆盖、接口实现和委托转换的 不安全作用域不匹配,编译器将报告诊断。

  • 该方法有一个 ref struct类型的 refout 参数,与添加 [UnscopedRef](不删除 scoped)不匹配。 (在这种情况下,愚蠢循环赋值 是可能的,因此不需要其他参数。
  • 或者这两者都是真的:
    • 该方法返回 ref struct 或返回 refref readonly,或者该方法具有 ref struct 类型的 refout 参数。
    • 该方法至少有一个附加参数:refinout,或一个 ref struct 类型的参数。

在其他情况下不会报告诊断,因为:

  • 具有此类签名的方法无法捕获传入的引用,因此任何范围不匹配都没有危险。
  • 这些场景包括非常常见和简单的情况(例如,在 TryParse 方法签名中使用的普通旧 out 参数),以及仅仅因为它们在语言版本11中使用(因此 out 参数的范围不同)而报告范围不匹配会令人困惑。

如果不匹配的签名都使用 C#11 ref 安全上下文规则,则诊断报告为 错误;否则,诊断为警告

对于在 C#7.2 中使用 ref 安全上下文规则编译的模块,可能会报告作用域不匹配警告,其中 scoped 不可用。 在某些情况下,如果无法修改其他不匹配的签名,可能有必要禁用警告。

scoped 修饰符和 [UnscopedRef] 属性对方法签名也存在以下影响:

  • scoped 修饰符和[UnscopedRef] 属性不会对隐藏产生影响
  • 重载不能仅在 scoped[UnscopedRef] 上有所不同

有关 ref 字段和 scoped 的部分较长,因此希望通过简要总结提议的重大更改来结束:

  • ref-safe-context 中的值,可以通过 ref 字段返回给ref
  • out 参数将具有函数成员安全上下文

详细说明:

  • 只能在 ref struct 内声明 ref 字段
  • ref 字段不能被声明为 staticvolatileconst
  • ref 字段的类型不能是 ref struct
  • 引用程序集生成过程必须保留 refref struct 字段的存在
  • 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 语句:从 local_variable_declaration 间接添加了 'scoped'?

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,就可以使用 ref structref 字段的组合在 C# 中正确定义这些类型。 因此,当编译器检测到运行时支持 ref 字段时,它将不再有受限类型的概念。 它将改为使用代码中定义的类型。

为此,我们将对 ref 安全上下文规则进行如下更新:

  • __makeref 将被视为具有签名 static TypedReference __makeref<T>(ref T value) 的方法
  • __refvalue 将被视为具有签名 static ref T __refvalue<T>(TypedReference tr) 的方法。 表达式 __refvalue(tr, int) 将有效地使用第二个参数作为类型参数。
  • __arglist 作为参数将具有 ref-safe-context以及函数成员安全上下文
  • 作为表达式的 __arglist(...) 将具有函数成员ref-safe-context安全上下文

符合要求的运行时将确保 TypedReferenceRuntimeArgumentHandleArgIterator 被定义为 ref struct。 此外,TypedReference 必须被视为有一个 ref 字段到 ref struct 的任何可能类型(它可以存储任何值)。 这与上述规则结合使用可确保对堆栈的引用不会超出其生存期。

注意:严格地说,这属于编译器实现细节,而非语言的一部分。 但鉴于与 ref 字段的关系,为了简便期间,它被纳入语言提案中。

提供不受限

最值得注意的摩擦点之一是在 ref 实例成员中,struct 无法返回字段。 这意味着开发人员无法创建 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 安全上下文规则下选择的默认设置。

若要解决此问题,该语言将通过支持 scoped 来实现与 UnscopedRefAttribute 生存期批注相反的效果。 这可以应用于任何 ref,它会将 ref-safe-context 更改为比默认值宽一级。 例如:

应用于“UnscopedRef” 原始 ref-safe-context 新的 ref-safe-context
实例成员 function-member 仅限退回
in / ref 参数 仅限退回 caller-context
out 参数 function-member 仅限退回

[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;
}

出于引用安全上下文规则的目的,此类 [UnscopedRef] out 仅被视为 ref。 类似于 in 被视为 ref 的生存期用途。

[UnscopedRef] 批注将不允许使用于 init 内的 struct 成员和构造函数。 那些成员由于将 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] 注释的实例方法或属性将 thisref-safe-context 设置为 调用方上下文
  • 使用 [UnscopedRef] 批注的成员无法实现接口。
  • 使用 [UnscopedRef] 时出错
    • 未在 struct 上声明的成员
    • struct 上的 static 成员、init 成员或构造函数
    • 标记为 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 之间的 ref safe context 规则存在一些差异。 使用 C#11 重新编译时,与使用 C#10 或更早版本编译的引用存在的任何这些差异,都可能导致破坏性更改。

  1. 未限定范围的 ref/in/out 参数在 C#11 中可能以 ref 下的 ref struct 字段形式逃逸方法调用,而在 C#7.2 中则不会出现这种情况。
  2. out 参数在 C#11 中隐式作用域,在 C#7.2 中没有作用域
  3. ref struct 类型的 ref / in 参数在 C#11 中被隐式限定,而在 C#7.2 中未被限定

为了减少使用 C#11 重新编译时中断性变更的可能性,我们将更新 C#11 编译器以使用 ref 安全上下文规则 方法调用与用于分析方法声明的规则相匹配。 从本质上讲,在分析对使用旧版编译器编译的方法的调用时,C#11 编译器将使用 C#7.2 ref 安全上下文规则。

若要启用此功能,当模块用 [module: RefSafetyRules(11)] 或更高版本编译,或者用包含 -langversion:11 字段功能标志的 corlib 进行编译时,编译器将发出新的 ref 属性。

该属性的参数表示编译模块时使用的 ref 安全上下文规则的语言版本。 目前,无论编译器收到的实际语言版本是多少,版本都固定为 11

预期编译器的未来版本将更新 ref 安全上下文规则,并针对不同版本生成属性。

如果编译器加载的模块包含 [module: RefSafetyRules(version)],并且其中 version 不同于 11,则当模块中存在任何对该模块中声明的方法的调用时,如果该模块中声明的方法有任何调用,编译器将报告无法识别版本的警告。

当 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> 实例。

在索引到类型Tfixed缓冲区时,必须考虑容器的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 操作。 这意味着,通过具有 ref 访问器和 byval getset 操作,fixed 缓冲区将更类似于现有数组语义。 这意味着编译器在生成使用 fixed 缓冲区的代码时,将具有与使用数组时相同的灵活性。 这会使 await 等操作更容易通过 fixed 缓冲区发出。

这样做的另一个好处是,可以让其他语言更容易使用 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(即它指定除 ref readonly 字段以外的任何内容),则右操作数必须是可写左值。

构造函数调用 的转义规则仍保持不变:

调用构造函数的 new 表达式与调用方法的规则相同,后者被视为返回被构造的类型。

即上面更新了方法调用的规则

由方法调用e1.M(e2, ...)产生的右值具有安全上下文,该安全上下文来自下列上下文中最小的一个:

  1. 调用方上下文
  2. 由所有参数表达式提供的安全上下文
  3. 当返回为 ref struct 时,则 ref-safe-context 由所有 ref 参数贡献

对于具有初始值设定项的 new 表达式,初始值设定项表达式以递归方式计为参数(它们贡献其安全上下文),而 ref 表达式则计为 ref 参数(它们贡献其 ref-safe-context)。

不安全上下文中的更改

指针类型(第 23.3 节)被扩展以允许托管类型作为引用类型。 此类指针类型写为托管类型,后跟 * 令牌。 它们生成警告。

地址运算符(第 23.6.5 节)被放宽,以接受具有托管类型的变量作为其操作数。

fixed 语句(第 23.7 节)被放宽,可以接受 fixed_pointer_initializer,它是托管类型 T 变量的地址,或者是具有托管类型元素的 array_type 的表达式T

堆栈分配初始化器(第 12.8.22 节)同样更加灵活。

注意事项

评估此功能时,开发堆栈中其他相关部分也应进行考虑。

兼容性注意事项

此提案的挑战在于此设计对我们现有的 跨度安全规则的兼容性影响,以及对 §9.7.2的兼容性影响。 虽然这些规则完全支持 ref struct 具有 ref 字段的概念,但不允许 API(stackalloc除外)捕获与堆栈相关的 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 struct中的ref字段返回或作为ref / out参数传递

现行规则只考虑 (1) 和 (2)。 它们没有顾及(3),因此,像将局部变量作为 ref 字段返回这样的情况就没有考虑。 此设计必须改变规则以考虑 (3)。 这对现有 API 的兼容性影响不大。 具体来说,它将影响具有以下属性的 API。

  • 签名中有 ref struct
    • 其中 ref struct 是返回类型、refout 参数
    • 具有额外的 inref 参数(不包括接收方)

在 C# 10 中,此类 API 的调用方永远不需要考虑 API的 ref 状态输入可能会作为 ref 字段被捕获。 这在 C# 10 中允许多个模式安全地存在,但在 C# 11 中则不安全,因为 ref 状态可能会作为 ref 字段泄漏。 例如:

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被预定义,但在定义时,确实有一些相关规则:

  • 它必须是第一个生命周期参数。
  • 它必须是协变:out $this
  • ref 字段的生存期必须可转换为 $this
  • 所有非引用字段的$this生存期都必须是$heap$this

ref 的生命周期通过提供生命周期参数来表示。例如,指向堆的 ref 表述为 ref<$heap>

在模型中定义构造函数时,方法将使用 new 作为名称。 有必要为返回值和构造函数参数提供一个参数列表。 这对于表达构造函数输入和构造值之间的关系是必要的。 模型将改用 Span<$a><$ro>,而不是 Span<$a> new<$ro>。 构造函数中 this 的类型(包括生存期)将是定义的返回值。

生命周期的基本规则被定义为:

  • 所有生命周期以泛型参数的语法形式表示,位于类型参数之前。 除 $heap$local 外,对于预定义的生存期都是如此。
  • 不属于 T 的所有类型 ref struct 隐式具有T<$heap>的生存期。 这是隐式的,不需要在每个示例中编写 int<$heap>
  • 对于定义为ref<$l0> T<$l1, $l2, ... $ln>ref字段:
    • 所有从 $l1$ln 的生命周期都必须保持固定不变。
    • $l0 的生命周期必须可转换为 $this
  • 对于定义为 ref<$a> T<$b, ...>ref 来说,$b 必须可以转换为 $a
  • 变量 ref 的生存期由以下项定义:
    • 对于类型为refref<$a> T本地变量、参数、字段或返回值,其生存期为$a
    • $heap 针对所有引用类型及其引用类型的字段
    • $local其他任何内容
  • 当基础类型转换合法时,赋值或返回才合法
  • 可以通过使用类型转换注释来明确表达表达式的生存期。
    • (T<$a> expr)值生存期明确为$aT<...>
    • ref<$a> (T<$b>)expr 值生存期对于 $b 而言是 T<...>,引用生存期为 $a

出于生命周期规则的目的,ref 在进行转换时被视为表达式类型的一部分。 它在逻辑上通过将 ref<$a> T<...> 转换为 ref<$a, T<...>> 来表示,$a 是协变的,而 T 是不变的。

接下来,让我们定义将 C# 语法映射到基础模型的规则。

出于简洁考虑,对于没有显式生命周期参数的类型,视为其具有 out $this,并将其应用于该类型的所有字段。 带有 ref 字段的类型必须定义明确的生存期参数。

这些规则的存在是为了支持我们现有的不变性,即 T 可以分配给所有类型的 scoped T。 这映射到 T<$a, ...> 可分配给 T<$local, ...> 的所有生存期,已知可转换为 $local。 此外,这还支持其他项,例如能够将堆中的 Span<T> 分配给堆栈上的项。 这确实排除了具有字段对非 ref 值有不同生存期的类型,但这正是 C# 目前的情况。 更改这需要对需要映射的 C# 规则进行重大更改。

在实例方法中,类型 this 内的 S<out $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 返回值的 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 字段需要有不同的生存期以支持通过 refref 字段返回的能力。 本质上,它要求我们为方法的所有参数提供 ref-field-safe-context 跟踪。 这需要对所有表达式进行计算,并在目前几乎所有 ref-safe-context 进行跟踪的所有数值中跟踪它们。

此值与 ref-safe-context具有关系。 例如,一个值可以作为 ref 字段返回,但不能直接作为 ref返回,这是不合逻辑的。 这是因为 ref 字段已经可以通过 ref 简单返回(即使包含的值不能返回,ref 状态在 ref struct 中仍然可以由 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) 点。 预计这些将构成未来大多数 API,它们处理 ref 并返回 ref struct。 更改会对 (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中捕获 ref struct。 在这种情况下,编译器可能会发出诊断信息,通知开发人员此类 ref 应该改为 scoped ref 进行标注。

决策 此设计可以实现,但由此生成的功能难以使用,因此做出了决定,进行兼容性中断。

决定如果方法符合标准,但没有将 ref 参数作为 ref 字段捕获,编译器将发出警告。 在升级时应该适当地警告客户可能产生的问题

关键字与属性

这种设计要求使用属性来批注新的生存期规则。 如果使用上下文关键词,也可以很容易地做到这一点。 例如,[DoesNotEscape] 可映射到 scoped。 但是,关键字,即使是上下文关键字,一般也必须达到很高的标准才能被纳入。 他们占据了宝贵的语言位置,是语言中更显著的部分。 此功能虽然很有价值,但只能为少数 C# 开发人员服务。

从表面上看,这似乎有利于不使用关键字,但有两个要点需要考虑:

  1. 批注将影响程序语义。 属性影响程序语义是 C# 不愿意逾越的界限,目前还不清楚这个功能是否是语言应该采取这一步骤的理由。
  2. 最有可能使用此功能的开发人员与那些使用函数指针的开发人员之间存在很大的交集。 虽然也有少数开发人员会使用这一功能,但确实需要一种新的语法,而且这一决定仍然被认为是正确的。

综上所述,这意味着应考虑语法问题。

大致的语法如下:

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

决策scopedscoped ref使用语法;对unscoped使用属性。

允许固定缓冲区局部变量

这种设计允许安全的 fixed 缓冲区支持任何类型。 一个可能的扩展是允许将此类 fixed 缓冲区声明为局部变量。 这样就可以用 fixed 缓冲区取代现有的一些 stackalloc 操作。 它还将扩展我们可能有堆栈样式分配的一组方案,因为 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,因为如果不这样做,调用者仍然需要通过多种方法调用组合来“移除引用安全性”,在这种情况下,现有的解决方案可能已经足够。

默认情况下,取消作用域限制?

设计中默认只有两个位置,都是 scoped

  • thisscoped ref
  • outscoped 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

不过,一旦我们扩展到有 readonly ref struct 字段到 refref 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);
    }
}

考虑过让this根据struct或成员的类型具有不同的默认值。 例如:

  • this 作为 refstructreadonly ref structreadonly member
  • this 作为 scoped refref structreadonly ref struct,带有 ref 字段的 ref struct

这样可以最大程度地减少兼容性中断,并最大限度地提高灵活性,但代价是增加了客户理解的难度。 它还未能完全解决问题,因为未来的功能(如安全 fixed 缓冲区)要求可变 ref struct 的字段有 ref 返回,而仅靠这种设计无法生效,因为它将归入 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可能有一个指向Tref字段时,ref struct是潜在的方法输出。

第二个问题是语言必须考虑一种新的安全上下文:ref-field-safe-context。 所有传递性包含 ref struct 字段的 ref 都有另一个转义范围,表示 ref 字段内的值。 对于多个 ref 字段,可以统一将这些字段作为一个整体值进行跟踪。 参数的默认值为调用方上下文

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

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

此值与容器的安全上下文无关;即容器上下文变小,它不会影响ref字段值的 ref-field-safe-context。 进一步说,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 struct,则 ref-field-safe-context 必须相等。

这些问题都非常容易解决。 编译器团队已经草拟了这些规则的几个版本,它们在很大程度上与我们现有的分析不谋而合。 问题在于,没有用于这些规则的代码来证明其正确性和可用性。 由于担心我们可能会选择错误的默认值,并让运行时在利用这一点时陷入可用性的死角,这让我们在添加支持时非常犹豫。 这种担忧尤为强烈,因为 .NET 8 很可能通过 allow T: ref structSpan<Span<T>> 将我们推向这个方向。 如果将规则与消耗代码结合编写,它们会被写得更好。

决策 延迟允许 ref 字段到 ref struct,直到.NET 8,这时我们将有一些可以帮助制定这些方案规则的情境。 截至 .NET9 这一点尚未实现

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字段到ref struct时也将非常重要。

范围局部变量

在局部变量上使用 scoped对于有条件地为本地分配具有不同安全上下文赋值的代码结构特别有帮助。 这意味着代码不再需要依赖 = stackalloc byte[0] 等初始化技巧来定义本地 safe-context,现在只需使用 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 赋值导致只读突变

当将 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++;
    }
}

但该建议阻止了这种情况的发生,因为它违反了 ref 安全上下文规则。 考虑以下情况:

  • this函数成员,而且安全上下文调用上下文。 这两者都是 struct 成员中 this 的标准。
  • i函数成员。 这源于字段生存期规则的结果。 特别是规则 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 生存期小于关联参数值中任何引用的生存期。 这就是将 ref-safe-context 设为 refref structreturn-only 以及将 out 设为 caller-context 的理由。 这样可以防止因生存期不同而导致的循环分配。

请注意,[UnscopedRef]提升任何refref 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 允许循环赋值,因为该参数具有安全上下文引用安全上下文仅返回

在类型为[UnscopedRef] refa时,将升级至ref struct会非常有用(请注意,我们希望简化规则,因此不会区分 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;
    }
}

在不受外界影响的情况下设计 ref 实例上的 readonly 字段规则时,这些规则可以合理地设计为使上述内容合法或非法。 实质上,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 方法进行建模,其中 this 是一个 out 参数。

构造函数必须满足以下不变式:

  1. 确保 ref 参数可以作为 ref 字段捕获。
  2. 确保 refthis 字段不能通过 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安全上下文安全上下文 简化为 上下文

方法可以随意返回作为参数传递给它们的状态。 从本质上讲,任何未定义范围的可达状态都可以返回(包括通过 ref返回)。 这可以通过 return 语句直接返回,也可以通过赋值给 ref 值间接返回。

直接返回对 ref 安全性没有太大问题。 编译器只需查看方法的所有可返回的输入值,然后将返回值有效地限制为输入的最小上下文。 然后对返回值进行正常处理。

间接返回值会产生重大问题,因为所有 ref 同时是该方法的输入和输出。 这些输出已具有已知的上下文。 编译器无法推断新函数,因此必须将其视为当前级别。 这意味着编译器必须查看在调用方法中可分配的每个 ref,评估其上下文,然后验证该方法返回的输入中是否没有小于ref。 如果存在这种情况,那么方法调用一定是非法的,因为它可能违反 ref 安全性。

方法参数必须匹配编译器断言此安全检查的过程。

评估这种情况的另一种通常更容易被开发人员考虑的方法是进行以下练习:

  1. 查看方法定义,确定所有可以间接返回状态的位置:a. 指向 ref struct b 的可变 ref 参数。 具有可引用分配 ref 字段 c 的可变 ref 参数。 可分配的ref参数或指向 refref 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,因为这两个参数都可以直接分配。 与这些参数对应的参数是:

  • a映射到具有x上下文
  • b 映射到具有y上下文

该方法的可返回输入集合是

  • 具有x转义范围
  • 具有ref x转义范围
  • 具有函数成员转义范围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 状态。 因此,我们有:

  • a映射到具有x上下文

该方法的可返回输入集合是:

  • 具有调用方上下文上下文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 修饰符导致的本地上下文是可能用于变量的最窄上下文——如果再窄一些,意味着该表达式引用的变量仅在比表达式本身更窄的上下文中声明。