允许在迭代器和异步中使用 ref 和 unsafe

注意

本文是特性规范。 该规范充当该功能的设计文档。 它包括建议的规范更改,以及功能设计和开发过程中所需的信息。 这些文章将发布,直到建议的规范更改最终确定并合并到当前的 ECMA 规范中。

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

可以在 规范一文中详细了解将功能规范采用 C# 语言标准的过程。

支持者问题:https://github.com/dotnet/csharplang/issues/1331

总结

在迭代器和异步方法之间统一行为。 具体说来:

  • 允许在迭代器和异步方法中 ref/ref struct 局部变量和 unsafe 块,前提是它们在代码段中使用,而无需任何 yieldawait
  • 警告 yield 位于 lock内部。

动机

如果异步/迭代器方法中的 ref/ref struct 局部变量和 unsafe 块不在 yieldawait 中使用,则不必禁止它们,因为它们不需要被提升。

async void M()
{
    await ...;
    ref int x = ...; // error previously, proposed to be allowed
    x.ToString();
    await ...;
    // x.ToString(); // still error
}

中断性变更

语言规范中没有重大更改,但 Roslyn 实现中有一处重大更改(由于违反规范)。

Roslyn 违反了规范的一部分,该规范指出迭代器引入了安全上下文(§13.3.1)。 例如,如果存在包含本地函数的迭代器方法的 unsafe class,则本地函数从类继承不安全上下文,尽管它应该已根据迭代器方法处于安全上下文中。 事实上,整个迭代器方法继承了 Roslyn 中的不安全上下文,只是不允许在迭代器中使用任何不安全构造。 在 LangVersion >= 13中,迭代器将正确引入安全上下文,因为我们希望允许迭代器中的不安全构造。

unsafe class C // unsafe context
{
    System.Collections.Generic.IEnumerable<int> M() // an iterator
    {
        yield return 1;
        local();
        async void local()
        {
            int* p = null; // allowed in C# 12; error in C# 13 (breaking change)
            await Task.Yield(); // error in C# 12, allowed in C# 13
        }
    }
}

注意:

  • 只需在本地函数中添加 unsafe 修饰符即可解决中断问题。
  • 这不会影响 lambda,因为它们“继承”了“迭代器上下文”,因此不可能在它们内使用不安全的构造。

详细设计

以下更改与 LangVersion 相关,即 C# 12 和更低版本将继续禁止异步方法和迭代器中的类似引用的局部变量和 unsafe 块,C# 13 将取消这些限制,如下所示。 但是,与现有 Roslyn 实现匹配的规范说明应该适用于所有语言版本。

§13.3.1 块 > 常规

包含一个或多个 语句(yield)的 块称为迭代器块,即使这些 yield 语句仅间接包含在嵌套块(不包括嵌套 lambda 和本地函数)中也是如此。

[...]

迭代器块包含不安全的代码上下文时会产生编译时错误(§23.2)。 迭代器块始终定义安全上下文,即使其声明嵌套在不安全的上下文中也是如此。 用于实现迭代器(§15.14)的迭代器块始终定义安全上下文,即使迭代器声明嵌套在不安全的上下文中也是如此。

从这一规范也可以看出:

  • 如果使用 unsafe 修饰符标记迭代器声明,则签名位于不安全的范围内,但用于实现迭代器的迭代器块仍定义安全作用域。
  • 迭代器属性或索引器的 set 访问器(即,其 get 访问器是通过迭代器块实现的),从声明中“继承”其安全/不安全的范围。
  • 这不会影响没有实现的部分声明,因为它们只是签名,并且不能具有迭代体。

请注意,在 C# 12 中,使用 unsafe 修饰符标记迭代器方法是错误的,但由于规范更改,C# 13 中允许此方法。

例如:

using System.Collections.Generic;
using System.Threading.Tasks;

class A : System.Attribute { }
unsafe partial class C1
{ // unsafe context
    [/* unsafe context */ A]
    IEnumerable<int> M1(
        /* unsafe context */ int*[] x)
    { // safe context (this is the iterator block implementing the iterator)
        yield return 1;
    }
    IEnumerable<int> M2()
    { // safe context (this is the iterator block implementing the iterator)
        unsafe
        { // unsafe context
            { // unsafe context (this is *not* the block implementing the iterator)
                yield return 1; // error: `yield return` in unsafe context
            }
        }
    }
    [/* unsafe context */ A]
    unsafe IEnumerable<int> M3(
        /* unsafe context */ int*[] x)
    { // safe context
        yield return 1;
    }
    [/* unsafe context */ A]
    IEnumerable<int> this[
        /* unsafe context */ int*[] x]
    { // unsafe context
        get
        { // safe context
            yield return 1;
        }
        set { /* unsafe context */ }
    }
    [/* unsafe context */ A]
    unsafe IEnumerable<int> this[
        /* unsafe context */ long*[] x]
    { // unsafe context (the iterator declaration is unsafe)
        get
        { // safe context
            yield return 1;
        }
        set { /* unsafe context */ }
    }
    IEnumerable<int> M4()
    {
        yield return 1;
        var lam1 = async () =>
        { // safe context
          // spec violation: in Roslyn, this is an unsafe context in LangVersion 12 and lower
            await Task.Yield(); // error in C# 12, allowed in C# 13
            int* p = null; // error in both C# 12 and C# 13 (unsafe in iterator)
        };
        unsafe
        {
            var lam2 = () =>
            { // unsafe context, lambda cannot be an iterator
                yield return 1; // error: yield cannot be used in lambda
            };
        }
        async void local()
        { // safe context
          // spec violation: in Roslyn, this is an unsafe context in LangVersion 12 and lower
            await Task.Yield(); // error in C# 12, allowed in C# 13
            int* p = null; // allowed in C# 12, error in C# 13 (breaking change in Roslyn)
        }
        local();
    }
    public partial IEnumerable<int> M5() // unsafe context (inherits from parent)
    { // safe context
        yield return 1;
    }
}
partial class C1
{
    public partial IEnumerable<int> M5(); // safe context (inherits from parent)
}
class C2
{ // safe context
    [/* unsafe context */ A]
    unsafe IEnumerable<int> M(
        /* unsafe context */ int*[] x)
    { // safe context
        yield return 1;
    }
    unsafe IEnumerable<int> this[
        /* unsafe context */ int*[] x]
    { // unsafe context
        get
        { // safe context
            yield return 1;
        }
        set { /* unsafe context */ }
    }
}

§13.6.2.4 Ref 局部变量声明

在使用 ref struct 声明的方法中或在迭代器中声明 ref 局部变量或 async 类型的变量是一个编译时错误 (§15.14)。ref struct 表达式或 await 语句中声明和使用(即使是在编译器合成的代码中隐含地)ref 局部变量或 yield return 类型的变量是一个编译时错误。更确切地说,该错误是由以下机制导致的:在 await 表达式 (§12.9.8) 或 yield return 语句 (§13.15) 之后,作用域中的所有 ref 局部变量和 ref struct 类型的变量都被视为肯定未赋值 (§9.4)。

请注意,在 unsafe 上下文中,该错误不会像其他一些 ref 安全错误一样降级为警告。 这是因为在 unsafe 上下文中,如果不依赖于状态机重写工作方式的实现细节,就无法操作这些类 ref 局部变量,因此该错误超出了我们希望在 unsafe 上下文中降级为警告的范围。

§15.14.1 迭代器 > 一般

使用迭代器块实现函数成员时,如果函数成员的正式参数列表指定了任何 inref readonlyoutref 参数,或指定了 ref struct 类型的参数 或指针类型,则会导致编译时错误。

无需更改规范即可允许在异步方法中使用不包含 unsafeawait 块,因为该规范从未禁止过在异步方法中使用 unsafe 块。 然而,规范本应始终禁止 await 块内的 unsafe(如上文所述,在 yield 中,规范已禁止 unsafe 中的 ),因此我们建议对规范作如下修改:

§15.15.1 异步函数 > 常规

指定异步函数的参数列表时,如果包含任何 inoutref 参数,或者任何 ref struct 类型的参数,则会导致编译时错误。

在不安全上下文(§23.2)中包含 await 表达式(§12.9.8)或 yield return 语句(§13.15)是编译时错误。

§23.6.5 运算符的地址

如果在迭代器中尝试获取局部变量或参数的地址,将报告编译时错误。

目前,在异步方法中获取本地或参数的地址是 C# 12 警告波中的一个警告


请注意,由于 ref 允许在片段内使用,而不允许在异步/迭代器方法中使用 awaityield,因此更多构造可以正常工作,尽管不需要专门针对它们进行规范修改,因为所有这些都是上述规范修改的结果:

using System.Threading.Tasks;

ref struct R
{
    public ref int Current { get { ... }};
    public bool MoveNext() => false;
    public void Dispose() { }
}
class C
{
    public R GetEnumerator() => new R();
    async void M()
    {
        await Task.Yield();
        using (new R()) { } // allowed under this proposal
        foreach (var x in new C()) { } // allowed under this proposal
        foreach (ref int x in new C()) { } // allowed under this proposal
        lock (new System.Threading.Lock()) { } // allowed under this proposal
        await Task.Yield();
    }
}

替代方案

  • 只有在不包含 ref/ref struct的块(§13.3.1)中才允许 await/yield 局部变量。

    // error always since `x` is declared/used both before and after `await`
    {
        ref int x = ...;
        await Task.Yield();
        x.ToString();
    }
    // allowed as proposed (`x` does not need to be hoisted as it is not used after `await`)
    // but alternatively could be an error (`await` in the same block)
    {
        ref int x = ...;
        x.ToString();
        await Task.Yield();
    }
    
  • yield return 中的 lock 可以是一个错误(就像 await 中的 lock 一样),也可以是一个警告,但这将是一个重大更改:https://github.com/dotnet/roslyn/issues/72443。 请注意,新的基于 Lock 对象的 lock 会在编译时对其正文中的 yield return 报告错误,因为此类 lock 语句等同于不允许在其正文中出现 usingref struct 上的 yield return 语句。

  • 异步或迭代器方法中的变量不应是“固定的”,而应是“可移动的”,以便在需要时被提升为状态机的字段(类似于捕获变量)。 请注意,这是规范中一个早已存在的 bug,与提议的其他部分无关,因为 unsafe 方法中的 async 块一直是允许的。 目前在 C# 12 警告波中对此有一个警告,而将其变成错误将是一个重大更改。

    §23.4 固定和可移动变量

    确切地说,固定变量是下列变量之一:

    • simple_name (§12.8.4) 产生的变量指向局部变量、值参数或参数数组,除非该变量被匿名函数 (§12.19.6.2) 或局部函数 (§13.6.4) 捕获了变量,或者变量需要作为异步 (§15.15) 或迭代器 (§15.14) 方法的一部分被提升。
    • [...]
    • 目前,我们在 C# 12 中针对异步方法中的取址符提出了警告,并针对迭代器中的取址符提出了针对 LangVersion 13+ 的错误报告(在早期版本中无需报告,因为在迭代器中不可能使用不安全代码)。 我们可以放宽这些规则,仅适用于实际被提升的变量,而不是所有的局部变量和参数。

    • 可以使用 fixed 来获取被提升或被捕获变量的地址,尽管这些都是字段这一事实是一个实现细节,因此在其他实现中可能无法对它们使用 fixed。 请注意,我们只是建议将提升的变量也视为“可移动”变量,但捕获变量本来就是“可移动”变量,而 fixed 是不允许的。

  • 除了在 await 语句中,我们可以在 / 内部允许 yieldunsafefixed。但是,编译器不能跨方法边界固定变量。 这可能会导致一些意想不到的行为,例如下面嵌套要点中描述的围绕 stackalloc 的行为。 否则,在某些场景下,提升指针仍然是受到支持的(下面有一个与指针作为参数相关的示例),因此在允许这样做时不应该有其他限制。

    • 我们可以禁止异步/迭代器方法中 stackalloc 的不安全变体,因为堆栈分配的缓冲区不会跨 await/yield 语句生存。 我觉得没有必要这样做,因为不安全代码在设计上并不妨碍“免费后使用”。 请注意,我们可以允许不安全的 stackalloc,条件是它不用于 await/yield中,但这可能很难分析(生成的指针可以在任何指针变量中传递)。 或者我们可以要求它在异步/迭代器方法中是fixed。 这将阻止await/yield 中使用它,但不符合 fixed 的语义,因为 stackalloc 表达式不是可移动的值。 (请注意,跨 stackallocawait 使用 / 结果并非yield,因为您可以将今天的任何 fixed 指针保存到另一个指针变量中,并在 fixed 块之外使用它。)
  • 可以允许迭代器和异步方法具有指针参数。 它们需要被提升,但这应该不成问题,因为即使在今天也支持提升指针,例如:

    unsafe public void* M(void* p)
    {
        var d = () => p;
        return d();
    }
    
  • 当前,该提案保留(并扩展/澄清)已有规范,即迭代器方法在开始时进入安全上下文,即使它们处于不安全的上下文中。 例如,迭代器方法不是不安全的上下文,即使它在具有 unsafe 修饰符的类中定义也是如此。 或者,我们可以使迭代器“继承”unsafe 修饰符,就像其他方法一样。

    • 优点:从规范和实现中删除复杂性。
    • 优点:将迭代器与异步方法(功能动机之一)保持一致。
    • 缺点:不安全类内的迭代器不能包含 yield return 语句,此类迭代器必须在单独的分部类声明中定义,而无需 unsafe 修饰符。
    • 缺点:这将会是 LangVersion=13 中的重大变更(在 C# 12 中,允许在不安全类中使用迭代器)。
  • 与仅为主体定义安全上下文的迭代器不同,整个签名可以是一个安全上下文。 这与该语言的其它部分不一致,因为代码体通常不影响声明,但在这个地方,声明的安全性取决于代码体是否为迭代器。 它也是 LangVersion=13 中的中断性变更,因为 C# 12 迭代器签名不安全(例如,它们可以包含指针数组参数)。

  • unsafe 修饰符应用于迭代器:

    • 可能不仅会影响正文,还会影响签名。 不过,这种迭代器并不是非常有用,因为它们的不安全主体不能包含 yield return,只能包含 yield break
    • LangVersion >= 13 中的错误可能与 LangVersion <= 12 中的一样,因为不安全迭代器成员的用处不大,因为它只允许使用指针数组参数或不安全设置器,而不需要额外的不安全块。 但将来可以允许正常指针参数。
  • Roslyn 重大变更:

    • 我们可以保留当前行为(甚至修改规范以匹配它),例如,在迭代器方法中引入安全上下文,但随后还原到本地函数中的不安全上下文。
    • 或者我们可以修改所有的 LangVersions,而不仅仅是 13 及更新版本。
    • 还可以通过使迭代器像所有其他方法一样继承不安全的上下文来更大幅简化规则。 上面已讨论。 可以在所有语言版本中使用,也可以只在 LangVersion >= 13 中使用。

设计会议