ref readonly 个参数

注意

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

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

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

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

总结

允许参数声明站点修饰符 ref readonly 和更改调用站点规则,如下所示:

Callsite 批注 ref 参数 ref readonly 参数 in 参数 out 参数
ref 允许 允许 警告 错误
in 错误 允许 允许 错误
out 错误 错误 错误 允许
无批注 错误 警告 允许 错误

请注意,现有规则已有一项更改:带有 ref 调用点注释的 in 参数现在会生成警告而不是错误。

更改参数值规则,如下所示:

值种类 ref 参数 ref readonly 参数 in 参数 out 参数
右值 错误 警告 允许 错误
左值 允许 允许 允许 允许

其中左值表示变量(即具有位置的值;不必可写/可赋值),右值表示任何类型的值。

动机

C# 7.2 引入了 in 参数 作为传递只读引用的方法。 in 参数允许左值和右值,并且可以在 callsite 上不使用任何批注。 然而,从其参数中捕获或返回引用的 API 希望禁止右值,并在 callsite 强制执行一些指示,表明正在捕获引用。 在这种情况下,ref readonly 参数是理想的选择,因为如果与右值一起使用或在 callsite 没有任何批注,它们会发出警告。

此外,还有一些 API 仅需只读引用,但使用

  • 由于在 in 可用之前引入了 ref 参数,更改为 in 将会导致源代码和二进制的兼容性中断,例如 QueryInterface,或
  • in 参数接受只读引用,即使向它们传递右值实际上没有意义,例如 ReadOnlySpan<T>..ctor(in T value)
  • ref 参数不允许右值,即便它们不改变所传递的引用,例如 Unsafe.IsNullRef

这些 API 可以迁移到 ref readonly 参数,而不会中断用户。 有关二进制兼容性的详细信息,请参阅建议的 元数据编码。 具体而言,更改

  • refref readonly 对于虚拟方法来说,只是一个二进制重大更改,
  • refin 也是虚拟方法的二进制中断性变更,但不是源中断性变更(因为规则更改为仅针对传递给 in 参数的 ref 参数发出警告),
  • inref readonly 不是重大更改(但没有 callsite 批注或右值会导致警告),
    • 请注意,这对于使用旧版编译器的用户来说将是一次破坏性变更(因为这些编译器将 ref readonly 参数解释为 ref 参数,并且不允许在调用点使用 in 或不加批注),而对于包含 LangVersion <= 11 的新版编译器来说(为保持与旧版编译器的一致性,会发出错误提示,除非通过 ref 修饰符传递相应的参数,否则不支持 ref readonly 参数)。

相反的方向,更改

  • ref readonlyref 可能是源代码重大更改(除非只使用 ref callsite 批注并且只使用只读引用作为参数),以及是虚拟方法的二进制重大更改,
  • ref readonlyin 不是重大更改(但 ref callsite 批注将导致警告)。

请注意,上述规则适用于方法签名,但不适用于委托签名。 例如,将委托签名中的 ref 更改为 in 可能导致源代码不兼容的变更(如果用户将具有 ref 参数的方法分配给该委托类型,则 API 更改后会出现错误)。

详细设计

通常,ref readonly 参数的规则与 其建议in 参数指定的规则相同,除非在此建议中明确更改。

参数声明

无需更改语法。 修饰符 ref readonly 将被允许用于参数。 除了普通方法之外,索引器参数允许使用 ref readonly(如 in 但与 ref不同),但是运算符参数不允许使用 ref readonly(如 ref 但与 in不同)。

默认参数值将被允许用于 ref readonly 参数,并出现警告,因为它们相当于传递右值。 这允许 API 作者将 in 参数修改为 ref readonly 参数,而这些参数具有默认值,且无需引入源代码中断性变更。

值种类检查

请注意,即使 ref 参数修饰符可以用于 ref readonly 参数,与值类型检查相关的内容也不会更改,即

  • ref 只能与可赋值一起使用;
  • 若要传递只读引用,必须改用 in 参数修饰符;
  • 若要传递右值,必须不使用修饰符(这会导致针对 ref readonly 参数的警告,如此建议摘要所述)。

重载解析

重载解析将允许混合 ref/ref readonly/in/无 callsite 批注和参数修饰符,如此建议摘要中的表所示,即所有允许警告情况将被视为重载解析期间的可能候选项。 具体而言,现有行为发生了变化,其中具有 in 参数的方法将与标记为 ref的相应参数匹配调用,此更改将限制在 LangVersion 上。

但是,如果参数为以下值,则禁止将不带 callsite 修饰符的参数传递给 ref readonly 参数的警告

  • 扩展方法调用中的接收方,
  • 隐式用作自定义集合初始值设定项或内插字符串处理程序的一部分。

如果不存在参数修饰符(in 参数具有相同的行为),则按值重载将优先于 ref readonly 重载。

方法转换

同样,出于匿名函数 [§10.7] 和方法组 [§10.8] 转换,这些修饰符被视为兼容(但不同修饰符之间的任何允许转换都会导致警告):

  • 允许目标方法的 ref readonly 参数与委托的 inref 参数匹配,
  • 目标方法的 in 参数被允许匹配 ref readonly 或者,根据 LangVersion,匹配委托的 ref 参数。
  • 注意:目标方法的 ref 参数 不允许 匹配委托的 inref readonly 参数。

例如:

DIn dIn = (ref int p) => { }; // error: cannot match `ref` to `in`
DRef dRef = (in int p) => { }; // warning: mismatch between `in` and `ref`
DRR dRR = (ref int p) => { }; // error: cannot match `ref` to `ref readonly`
dRR = (in int p) => { }; // warning: mismatch between `in` and `ref readonly`
dIn = (ref readonly int p) => { }; // warning: mismatch between `ref readonly` and `in`
dRef = (ref readonly int p) => { }; // warning: mismatch between `ref readonly` and `ref`
delegate void DIn(in int p);
delegate void DRef(ref int p);
delegate void DRR(ref readonly int p);

请注意,函数指针转换的行为没有发生变化。 提醒一下,如果引用类型修饰符不匹配,则不允许隐式函数指针转换,并且始终允许显式强制转换而不发出任何警告。

匹配签名

在单个类型中声明的成员仅凭 ref/out/in/ref readonly 不能在签名上有所不同。 对于签名匹配的其他目的(例如隐藏或重写),ref readonly 可与 in 修饰符交换,但这会导致声明网站发出警告 [§7.6]。 当将 partial 声明与其实现进行匹配,并将侦听器签名与截获签名匹配时,这不适用。 请注意,ref/inref readonly/ref 修饰符对的重写方式没有变化,它们不能互换,因为其签名在二进制上不兼容。 为了一致性,对于其他签名匹配目的(例如隐藏)也是如此。

元数据编码

提醒:

  • ref 参数以普通 byref 类型发出(IL 中为 T&),
  • in 参数类似于 ref,并使用 System.Runtime.CompilerServices.IsReadOnlyAttribute进行批注。 在 C# 7.3 及更高版本中,它们也以 [in] 的形式发出,如果是虚拟的,则以 modreq(System.Runtime.InteropServices.InAttribute) 的形式发出。

ref readonly 参数将以 [in] T& 的形式发出,并附上以下属性进行标注:

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

此外,如果是虚拟的,则会以 modreq(System.Runtime.InteropServices.InAttribute) 的形式发出,以确保与 in 参数的二进制兼容性。 请注意,与 in 参数不同,不会为 ref readonly 参数发出任何 [IsReadOnly],以避免增加元数据大小,并使较旧的编译器版本将 ref readonly 参数解释为 ref 参数(因此,即使不同编译器版本 refref readonly 也不会是源中断性变更)。

RequiresLocationAttribute 将通过命名空间限定名进行匹配,如果尚未包含在编译中,则由编译器合成。

在源代码中指定属性如果应用于参数,将导致错误,类似于 ParamArrayAttribute

函数指针

在函数指针中,in 参数通过 modreq(System.Runtime.InteropServices.InAttribute) 发出(请参阅 函数指针建议)。 ref readonly 参数将在不使用 modreq 的情况下发出,而使用 modopt(System.Runtime.CompilerServices.RequiresLocationAttribute)。 较旧的编译器版本将忽略 modopt,因此将 ref readonly 参数解释为 ref 参数(与上述具有 ref readonly 参数的正常方法的旧编译器行为一致),而意识到 modopt 的新编译器版本将使用它来识别 ref readonly 参数,以便在转换调用期间发出警告。 为了与较旧的编译器版本保持一致性,具有 LangVersion <= 11 的新编译器版本将报告不支持 ref readonly 参数的错误,除非使用 ref 修饰符传递相应的参数。

请注意,如果函数指针签名中的修饰符是公共 API 的一部分,则更改它们是二进制重大更改,因此将 refin 更改为 ref readonly 时将是二进制重大更改。 但是,与正常方法一致,只有在更改 inref readonly 时(如果使用 in callsite 修饰符调用指针),具有 LangVersion <= 11 的调用方才会发生源代码重大更改。

重大更改

重载解析中的 ref/in 不匹配宽松导致了在以下示例中演示的行为重大更改:

class C
{
    string M(in int i) => "C";
    static void Main()
    {
        int i = 5;
        System.Console.Write(new C().M(ref i));
    }
}
static class E
{
    public static string M(this C c, ref int i) => "E";
}

在 C# 11 中,调用绑定到 E.M,因此会打印 "E"。 在 C# 12 中,允许 C.M 进行绑定(带警告),并且由于已经有一个合适的候选项,因此不再搜索扩展作用域,因此会输出 "C"

由于同样的原因,还会有源代码重大更改。 以下示例在 C# 11 中打印 "1",但在 C# 12 中无法编译并出现歧义错误:

var i = 5;
System.Console.Write(C.M(null, ref i));

interface I1 { }
interface I2 { }
static class C
{
    public static string M(I1 o, ref int x) => "1";
    public static string M(I2 o, in int x) => "2";
}

上面的示例演示了方法调用的损坏,但由于是由重载解析更改引起的,这种情况也可能出现在方法转换时。

替代方案

参数声明

API 作者可以批注 in 参数,这些参数旨在仅接受具有自定义属性的左值,并提供分析器来标记不正确的用法。 这将不允许 API 作者更改已经选择使用 ref 参数以禁止右值的现有 API 的签名。 如果此类 API 的调用方仅有权访问 ref readonly 变量,则仍需执行额外的工作才能获取 ref。 将这些 API 从 ref 更改为 [RequiresLocation] in 将会导致源代码的破坏性更改(在涉及虚函数的情况下,也会导致二进制兼容性破坏)。

编译器可以识别何时将特殊属性(如 [RequiresLocation])应用于参数,而不是允许修饰符 ref readonly。 这在 LDM 2022-04-25 中进行了讨论,认为这是一个语言特性,而不是一个分析器,所以它应该看起来像一个分析器。

值种类检查

可以允许在没有任何警告的情况下将不带任何修饰符的左值传递给 ref readonly 参数,类似于 C++ 的隐式 byref 参数。 这在 LDM 2022-05-11 中进行了讨论,指出 ref readonly 参数的主要动机是从这些参数中捕获或返回引用的 API,因此有某种标记是有益的。

将右值传递到 ref readonly 可能是一个错误,而非一个警告。 这最初在 LDM 2022-04-25中被接受,但后来的电子邮件讨论放宽了这一点,因为我们将失去在不中断用户的情况下更改现有 API 的能力。

in 可能是 ref readonly 参数的"自然"callsite 修饰符,并且使用 ref 可能会引发警告。 这将确保代码样式一致,并在调用站点上明确引用是只读的(与 ref不同)。 最初在 LDM 2022-04-25 被接受。 但是,警告可能成为 API 作者从 ref 过渡到 ref readonly的障碍。 此外,in 已重新定义为 ref readonly + 便利功能,因此在 LDM 2022-05-11中拒绝了这一点。

待 LDM 审阅

C# 12 中未实现以下选项。 它们仍然是潜在的建议。

参数声明

可以允许修饰符(readonly ref 而不是 ref readonly)的逆序排序。 这与 readonly ref 返回和字段的行为方式不一致(反顺序是不允许的,或分别表示不同的东西),如果将来实现,则可能会与只读参数冲突。

默认参数值可能导致 ref readonly 参数出现错误。

值种类检查

当向 ref readonly 参数传递右值或 callsite 批注和参数修饰符不匹配时,可能会发出错误,而不是警告。 同样,可以使用特殊的 modreq 而不是属性来确保 ref readonly 参数不同于二进制级别的 in 参数。 这将提供更有力的保证,因此它适用于新的 API,但防止在无法引入重大更改的现有运行时 API 中采用。

可以放宽值类别检查,以便允许通过 refin/ref readonly 参数传递只读引用。 这类似于 ref 赋值和 ref 返回当前的工作原理,它们还允许通过源表达式上的 ref 修饰符以只读方式传递引用。 但是,ref 通常靠近目标声明为 ref readonly 的位置,因此很明显我们是以只读方式传递引用,这与参数和参数修饰符通常相距甚远的调用不同。 此外,它们只允许 ref 修饰符,这与允许也 in的参数不同,因此 inref 将变为可互换参数,或者如果用户希望使代码保持一致,则 in 实际上会过时(他们可能会随处使用 ref,因为它是唯一允许引用分配和 ref 返回的修饰符)。

重载解析

重载解析、覆写和转换可能会禁止 ref readonlyin 修饰符的可互换性。

现有 in 参数的重载解析更改可以无条件地进行(不考虑 LangVersion),但这将是一项重大更改。

调用具有 ref readonly 接收器的扩展方法可能会导致警告:“参数 1 应使用 refin 关键字传递”。这种情况就像没有 callsite 修饰符的非扩展调用一样(用户可以通过将扩展方法调用转换为静态方法调用来修复此类警告)。 使用具有 ref readonly 参数的自定义集合初始值设定项或内插字符串处理程序时,可能会报告相同的警告,尽管用户无法解决此问题。

当没有 callsite 修饰符或可能存在歧义错误时,ref readonly 重载可能优于按值重载。

方法转换

我们可以允许目标方法的 ref 参数匹配委托的 inref readonly 参数。 这将使 API 作者能够在委托签名中将 ref 更改为 in,而不会影响用户(这与正常方法签名允许的更改保持一致)。 然而,这也会导致以下违反 readonly 保证的行为,并仅发出警告:

class Program
{
    static readonly int f = 123;
    static void Main()
    {
        var d = (in int x) => { };
        d = (ref int x) => { x = 42; }; // warning: mismatch between `ref` and `in`
        d(f); // changes value of `f` even though it is `readonly`!
        System.Console.WriteLine(f); // prints 42
    }
}

函数指针转换可能会在 ref readonly/ref/in 不匹配时发出警告,但如果我们想要在 LangVersion 上限制该转换,则需要大量实现投资,因为目前类型转换不需要访问编译。 此外,即使不匹配目前是一个错误,如果用户愿意,也可以很容易地添加一个强制转换来允许不匹配。

元数据编码

可以允许在源中指定 RequiresLocationAttribute,类似于 InOut 属性。 或者,在应用于除了参数以外的其他上下文时,它可能会出错,就像 IsReadOnly 属性一样。这是为了保留更多的设计空间。

函数指针 ref readonly 参数可以用不同的 modopt/modreq 组合发出(请注意,此表中的“源代码重大更改”意味着具有 LangVersion <= 11 的调用方):

修饰符 可以在多个编译中识别 旧编译器将其视为 refref readonly inref readonly
modreq(In) modopt(RequiresLocation) 是的 in 二进制、源代码重大更改 二进制重大更改
modreq(In) in 二进制、源代码重大更改 还行
modreq(RequiresLocation) 是的 不受支持 二进制,源代码中断 二进制,源代码中断
modopt(RequiresLocation) 是的 ref 二进制重大更改 二进制、源代码重大更改

我们可以为 ref readonly 参数发出 [RequiresLocation][IsReadOnly] 属性。 然后,即使对于较旧的编译器版本,inref readonly 也不会是中断性变更,但 refref readonly 将成为较旧编译器版本的源中断性变更(因为它们将 ref readonly 解释为 in、禁止 ref 修饰符)和具有 LangVersion <= 11 的新编译器版本(一致性)。

我们可以使 LangVersion <= 11 的行为不同于旧编译器版本的行为。 例如,每当调用 ref readonly 参数时(即使在 callsite 使用 ref 修饰符),可能会产生错误,或在任何情况下都可以始终允许而不会产生任何错误。

重大更改

此提议建议接受行为重大更改,因为这类情况很少见,并且该更改受 LangVersion 控制。用户可以通过显式调用扩展方法来规避此问题。 相反,我们可以通过以下方式缓解此问题

  • 禁止 ref/in 不匹配(这只会阻止那些由于 in 尚不可用而使用 ref 的旧 API 迁移到 in),
  • 当本提议中引入了引用类型不匹配时,修改重载解析规则以继续寻找更好的匹配(由下面指定的优化规则确定),
    • 或者,也可以只对 refin 不匹配的情况继续处理,而不在其他情况下继续(ref readonlyref/in/按值)。
优化规则

以下示例目前导致三次调用 M 时出现三个歧义错误。 我们可以添加新的改进规则来解决歧义。 这还将解决前面所述的源代码破坏性更改。 一种方法是让示例打印 221(其中 ref readonly 参数与 in 参数匹配,因为如果不带修饰符调用它,将会发出警告,而 in 参数则允许这样调用)。

interface I1 { }
interface I2 { }
class C
{
    static string M(I1 o, in int i) => "1";
    static string M(I2 o, ref readonly int i) => "2";
    static void Main()
    {
        int i = 5;
        System.Console.Write(M(null, ref i));
        System.Console.Write(M(null, in i));
        System.Console.Write(M(null, i));
    }
}

新的优化规则可能会将参数标记为较差,该参数的参数本可以通过不同的参数修饰符传递以使其更好。 换句话说,用户应始终能够通过更改相应的参数修饰符将更差的参数转换为更好的参数。 例如,当参数通过 in传递时,ref readonly 参数优先于 in 参数,因为用户可以按值传递参数来选择 in 参数。 此规则仅是当前生效的“按值/in 优先规则”的一个扩展(这是重载解析的最后一个规则,如果任何参数比其他参数更优,而没有参数比另一个重载的相应参数更差,则整个重载更优)。

argument 更好的参数 更差的参数
ref/in ref readonly in
ref ref ref readonly/in
按值 按值/in ref readonly
in in ref

我们应该以类似的方式处理方法转换。 以下示例目前导致两个委托分配出现两个歧义错误。 新的优化规则可能更倾向于使用其引用修饰符与相应的目标委托参数引用修饰符匹配的方法参数,而不是不匹配的参数。 因此,以下示例将打印 12

class C
{
    void M(I1 o, ref readonly int x) => System.Console.Write("1");
    void M(I2 o, ref int x) => System.Console.Write("2");
    void Run()
    {
        D1 m1 = this.M;
        D2 m2 = this.M; // currently ambiguous

        var i = 5;
        m1(null, in i);
        m2(null, ref i);
    }
    static void Main() => new C().Run();
}
interface I1 { }
interface I2 { }
class X : I1, I2 { }
delegate void D1(X s, ref readonly int x);
delegate void D2(X s, ref int x);

设计会议