params Collections

注释

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

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

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

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

摘要

在 C# 12 语言中添加了对创建集合类型的实例的支持,而不仅仅是数组。 请参阅 集合表达式。 此建议将支持扩展到 params 所有此类集合类型。

动机

params数组参数提供了调用采用任意长度参数列表的方法的便捷方法。 今天 params 参数必须是数组类型。 对于开发人员来说,能够在调用使用其他集合类型的 API 时享受到相同的便利性可能是有益的。 例如,ImmutableArray<T>ReadOnlySpan<T>或纯 IEnumerable。 尤其是在编译器能够避免隐式数组分配以创建集合(ImmutableArray<T>ReadOnlySpan<T>等)的情况下。

目前,当 API 采用集合类型时,开发人员通常会添加一个 params 重载,该重载采用数组,构造目标集合并使用该集合调用原始重载,因此 API 使用者必须交易额外的数组分配,以便于方便。

另一种动机是能够添加参数范围重载,并使其优先于数组版本,只需重新编译现有源代码即可。

详细设计

方法参数

方法参数 ”部分按如下方式进行调整。

formal_parameter_list
    : fixed_parameters
-    | fixed_parameters ',' parameter_array
+    | fixed_parameters ',' parameter_collection
-    | parameter_array
+    | parameter_collection
    ;

-parameter_array
+parameter_collection
-    : attributes? 'params' array_type identifier
+    : attributes? 'params' 'scoped'? type identifier
    ;

parameter_collection由一组可选属性params修饰符、可选scoped修饰符、类型和标识符组成。 参数集合使用给定名称声明给定类型的单个参数。 参数集合 的类型 应为集合表达式的以下有效目标类型之一(请参阅 https://github.com/dotnet/csharplang/blob/main/proposals/csharp-12.0/collection-expressions.md#conversions):

  • 一维数组类型T[],在这种情况下,元素类型T
  • 范围类型
    • System.Span<T>
    • System.ReadOnlySpan<T>
      在这些情况下,元素类型T
  • 具有适当创建方法类型,它可以无需任何附加参数即可调用,其访问权限至少与声明成员一样,并且具有由此决定产生的相应元素类型
  • 结构类类型,实现System.Collections.IEnumerable,其中:
    • 类型具有一个构造函数,可以在没有参数的情况下调用,并且该构造函数至少与声明成员一样可访问。

    • 类型 具有实例(而不是扩展)方法 Add ,其中:

      • 可以使用单个值参数调用该方法。
      • 如果方法是泛型方法,则可以从参数推断类型参数。
      • 该方法至少与声明成员一样可访问。

      在这种情况下,元素类型就是类型迭代类型

  • 接口类型
    • System.Collections.Generic.IEnumerable<T>,
    • System.Collections.Generic.IReadOnlyCollection<T>,
    • System.Collections.Generic.IReadOnlyList<T>,
    • System.Collections.Generic.ICollection<T>,
    • System.Collections.Generic.IList<T>
      在这些情况下,元素类型T

在方法调用中,参数集合允许指定给定参数类型的单个参数,或者允许指定集合 的元素类型的 零个或多个参数。 参数集合在参数集合进一步介绍。

parameter_collection可以位于可选参数之后,但不能有默认值;当parameter_collection的参数被省略时,会导致创建一个空集合。

参数集合

参数数组” 部分已重命名和调整,如下所示。

使用 params 修饰符声明的参数是参数集合。 如果正式参数列表包含参数集合,则它应为列表中的最后一个参数,并且其类型应为 Method parameters 节中指定的类型。

注意:无法将 params 修饰符与修饰符 in合并, out或者 ref尾注

参数集合允许在方法调用中通过以下两种方式之一指定参数:

  • 为参数集合提供的参数可以是可隐式转换为参数集合类型的单个表达式。 在这种情况下,参数集合的行为与值参数类似。
  • 或者,调用可以为参数集合指定零个或多个参数,其中每个参数都是可隐式转换为参数集合的 元素类型的表达式。 在这种情况下,调用会根据集合表达式中指定的规则创建参数集合类型的实例,就像参数按相同的顺序用作集合表达式中的表达式元素,并使用新创建的集合实例作为实际参数。 构造集合实例时,将使用原始 未转换 的参数。

除了在调用中允许可变数量的参数外,参数集合与相同类型的值参数完全等效。

执行重载解析时,具有参数集合的方法可能适用,可以是正常形式,也可以采用扩展形式。 仅当方法的普通形式不适用且仅当具有与扩展窗体相同的签名的适用方法尚未在同一类型中声明时,方法的扩展形式才可用。

当单个参数集合实参本身既可以用作参数集合本身,同时又可以用作参数集合的元素时,包含此单个参数集合实参的方法的普通形式与扩展形式之间就会出现潜在歧义。 但是,歧义没有问题,因为如果需要,可以通过插入强制转换或使用集合表达式来解决该问题。

签名和重载

签名和重载中有关 params 修饰符的所有规则保持不变。

适用的函数成员

适用的函数成员部分按如下方式进行调整。

如果包含参数集合的函数成员在其普通形式中不适用,则函数成员可能改为在其 扩展形式中适用:

  • 如果参数集合不是数组,则扩展窗体不适用于语言版本 C# 12 及更低版本。
  • 扩展窗体是通过将函数成员声明中的参数集合替换为参数集合的 元素类型的 零个或多个值参数来构造的,以便参数列表中的 A 参数数与参数总数匹配。 如果 A 的参数数少于函数成员声明中的固定参数数,则无法构造函数成员的扩展形式,因此不适用。
  • 否则,如果 A中的每个参数满足以下其中一个条件,那么可以使用展开形式:
    • 参数的参数传递模式与相应参数的参数传递模式相同,并且
      • 对于由扩展创建的固定值参数或值参数,存在从参数表达式到相应参数类型的隐式转换,或者
      • 对于 inoutref 参数,参数表达式的类型与相应参数的类型相同。
    • 参数的参数传递模式为值,相应参数的参数传递模式为输入,并且存在从参数表达式到相应参数类型的隐式转换

更好的函数成员

更优的函数成员部分按如下方式调整。

给定参数列表 A,其中包含一组参数表达式 {E₁, E₂, ..., Eᵥ},以及具有参数类型 MᵥMₓ的两个适用函数成员 {P₁, P₂, ..., Pᵥ}{Q₁, Q₂, ..., Qᵥ},如果 Mᵥ 被定义为一个比 更好的函数成员

  • 对于每个参数,从 EᵥQᵥ 的隐式转换并不优于从 EᵥPᵥ的隐式转换,并且
  • 对于至少一个参数,从 EᵥPᵥ 的转换优于从 Eᵥ 转换为 Qᵥ

如果参数类型序列 {P₁, P₂, ..., Pᵥ}{Q₁, Q₂, ..., Qᵥ} 是等效的(即每个 Pᵢ 与相应的 Qᵢ 有相同的转换),则依次采用以下决胜规则,以确定更好的函数成员。

  • 如果 Mᵢ 是非泛型方法,Mₑ 是泛型方法,则 Mᵢ 优于 Mₑ
  • 否则,如果 Mᵢ 以正常形式适用,并且 Mₑ 具有参数集合,并且仅适用于其扩展形式,则 Mᵢ 优于 Mₑ
  • 否则,如果两种方法都具有参数集合,并且仅适用于其扩展形式,并且如果参数集合的 Mᵢ 元素少于参数集合的参数集合 Mₑ,则 MᵢMₑ它更好。
  • 否则,如果 Mᵥ 的参数类型比 Mₓ更具体,则 Mᵥ 优于 Mₓ。 让 {R1, R2, ..., Rn}{S1, S2, ..., Sn} 表示 MᵥMₓ的未经证实和未表达式的参数类型。 如果对于每个参数,Mᵥ 不小于 Mₓ,并且对于至少一个参数,RxSx更具体,则 Rx的参数类型比 Sx更具体:
    • 类型参数不特定于非类型参数。
    • 递归地,构造类型比另一个构造类型(具有相同数量的类型参数)更具体,条件是至少有一个类型参数更具体,并且没有类型参数比另一个的对应类型参数更不具体。
    • 如果第一个数组类型比第二个数组类型在元素类型上更具体(且两者维度数相同),那么第一个数组类型更具体。
  • 否则,如果一个成员是非提升运算符,而另一个成员是提升运算符,那么非提升运算符更好。
  • 如果两个函数成员都找不到更好的参数,并且 Mᵥ 的所有参数都具有相应的参数,而默认参数需要在 Mₓ中替换至少一个可选参数,则 Mᵥ 优于 Mₓ
  • 如果 Mᵥ 中至少有一个参数使用了比 中相应参数更好的参数传递选择 (Mₓ),并且 Mₓ 中没有一个参数使用了比 Mᵥ 更好的参数传递选择,则 Mᵥ 优于 Mₓ
  • 否则,如果这两种方法都有参数集合,并且仅在其展开形式中才适用,那么如果同一组参数对应于两种方法的集合元素,并且以下条件之一成立(这与 https://github.com/dotnet/csharplang/blob/main/proposals/csharp-13.0/collection-expressions-better-conversion.md 相对应),则 Mᵢ 优于 Mₑ
    • 两个参数集合都不是 span_type,并且存在从 Mᵢ 的参数集合到 Mₑ 的参数集合的隐式转换
    • params 集合的 MᵢSystem.ReadOnlySpan<Eᵢ>,且 params 集合的 MₑSystem.Span<Eₑ>,并且存在从 EᵢEₑ 的标识转换
    • Mᵢ 的参数集合是 System.ReadOnlySpan<Eᵢ>System.Span<Eᵢ>Mₑ 的参数集合是具有元素类型Eₑarray_or_array_interface__type,并且存在从 EᵢEₑ 的标识转换
  • 否则,没有哪个函数成员更好。

将新的决胜规则放在列表末尾的原因是因为最后一个子项

  • 这两个参数集合都不是 span_type,并且存在从 Mᵢ 参数集合到 Mₑ 参数集合的隐式转换

它适用于数组,因此,提前进行最终决定将会导致现有方案的行为产生变化。

例如:

class Program
{
    static void Main()
    {
        Test(1);
    }

    static void Test(in int x, params C2[] y) {} // There is an implicit conversion from `C2[]` to `C1[]`
    static void Test(int x, params C1[] y) {} // Better candidate because of "better parameter-passing choice"
}

class C1 {}
class C2 : C1 {}

如果前面的任何决胜规则适用(包括“更好的参数转换”规则),则与显式集合表达式用作参数时的情况相比,重载解析结果可能会不同。

例如:

class Program
{
    static void Test1()
    {
        M1(['1', '2', '3']); // IEnumerable<char> overload is used because `char` is an exact match
        M1('1', '2', '3');   // IEnumerable<char> overload is used because `char` is an exact match
    }

    static void M1(params IEnumerable<char> value) {}
    static void M1(params System.ReadOnlySpan<MyChar> value) {}

    class MyChar
    {
        private readonly int _i;
        public MyChar(int i) { _i = i; }
        public static implicit operator MyChar(int i) => new MyChar(i);
        public static implicit operator char(MyChar c) => (char)c._i;
    }

    static void Test2()
    {
        M2([1]); // Span overload is used
        M2(1);   // Array overload is used, not generic
    }

    static void M2<T>(params System.Span<T> y){}
    static void M2(params int[] y){}

    static void Test3()
    {
        M3("3", ["4"]); // Ambiguity, better-ness of argument conversions goes in opposite directions.
        M3("3", "4");   // Ambiguity, better-ness of argument conversions goes in opposite directions.
                        // Since parameter types are different ("object, string" vs. "string, object"), tie-breaking rules do not apply
    }

    static void M3(object x, params string[] y) {}
    static void M3(string x, params Span<object> y) {}
}

但是,我们主要关注的方案是重载仅因参数集合类型而异,但集合类型具有相同的元素类型。 此行为应与这些情况的显式集合表达式保持一致。

如果同一组参数对应于这两种方法的集合元素”条件对于以下方案非常重要:

class Program
{
    static void Main()
    {
        Test(x: 1, y: 2); // Ambiguous
    }

    static void Test(int x, params System.ReadOnlySpan<int> y) {}
    static void Test(int y, params System.Span<int> x) {}
}

“比较”从不同元素生成的集合并不合理。

本节在LDM进行了审查,并获批准。

这些规则产生的一个效果是,当公开不同类型的元素 params 时,如果调用的是空参数列表,这些元素将会变得不明确。 例如:

class Program
{
    static void Main()
    {
        // Old scenarios
        C.M1(); // Ambiguous since params arrays were introduced
        C.M1([]); // Ambiguous since params arrays were introduced

        // New scenarios
        C.M2(); // Ambiguous in C# 13
        C.M2([]); // Ambiguous in C# 13
        C.M3(); // Ambiguous in C# 13
        C.M3([]); // Ambiguous in C# 13
    }

    public static void M1(params int[] a) {
    }
    
    public static void M1(params int?[] a) {
    }
    
    public static void M2(params ReadOnlySpan<int> a) {
    }
    
    public static void M2(params Span<int?> a) {
    }
    
    public static void M3(params ReadOnlySpan<int> a) {
    }
    
    public static void M3(params ReadOnlySpan<int?> a) {
    }
}

鉴于我们将元素类型优先于其他所有事情,这似乎是合理的;语言无法判断用户在这种情况下是更喜欢int?还是int

动态绑定

当前 C# 运行时联编程序不会将使用非数组参数集合的候选项扩展形式视为有效候选项。

如果primary_expression没有编译时类型dynamic,则将对方法调用进行一个受限的编译时检查,如《§12.6.5 编译时动态成员调用检查》中所述。

如果只有一个候选项通过测试,并且满足以下所有条件,则该候选项的调用将被静态绑定:

  • 候选项是本地函数
  • 候选项不是泛型参数,或者显式指定了其类型参数;
  • 在编译时无法解析的候选项的正常形式和扩展形式之间没有歧义。

否则,将动态绑定 invocation_expression

如果只有一个候选者通过了上述测试:

  • 如果候选项是本地函数,则会发生编译时错误;
  • 如果该候选项仅适用于使用非数组参数集合的扩展形式,则会发生编译时错误。

我们还应该考虑恢复/修复当前影响本地函数的规范违规,请参阅 https://github.com/dotnet/roslyn/issues/71399

LDM 确认我们想要修复此规范冲突。

表达式树

表达式树不支持集合表达式。 同样,表达式树中不支持扩展的非数组参数集合形式。 我们不会改变编译器对表达式树中 lambda 的绑定方式,目的是避免使用采用非数组参数集合的扩展形式的 API。

复杂场景中非数组集合的求值顺序

本节在 LDM 中经过了审查,并获得了批准。 尽管数组事例与其他集合不同,但官方语言规范不必为数组指定不同的规则。 偏差只能被视为实现项目。 同时,我们并不打算更改数组周围的现有行为。

命名参数

在对词法上的前一个参数进行求值之后,但在对词法上的后一个参数进行求值之前,系统会创建并填充集合实例。

例如:

class Program
{
    static void Main()
    {
        Test(b: GetB(), c: GetC(), a: GetA());
    }

    static void Test(int a, int b, params MyCollection c) {}

    static int GetA() => 0;
    static int GetB() => 0;
    static int GetC() => 0;
}

评估顺序如下:

  1. 调用 GetB
  2. MyCollection 被创建并填充,然后在过程中调用 GetC
  3. 调用 GetA
  4. 调用 Test

请注意,在参数数组事例中,在调用目标方法之前,将在按词法顺序计算所有参数之后创建该数组。

复合赋值

在对词法上的前一个索引进行求值之后,但在对词法上的后一个索引进行求值之前,系统会创建并填充集合实例。 该实例用于调用目标索引器的 getter 和 setter。

例如:

class Program
{
    static void Test(Program p)
    {
        p[GetA(), GetC()]++;
    }

    int this[int a, params MyCollection c] { get => 0; set {} }

    static int GetA() => 0;
    static int GetC() => 0;
}

评估顺序如下:

  1. 调用并缓存 GetA
  2. 创建、填充和缓存 MyCollection,在进程中调用 GetC
  3. 使用索引的缓存值调用索引器的 getter
  4. 递增结果值
  5. 使用索引的缓存值和增量的结果调用索引器的 setter

包含空集合的示例:

class Program
{
    static void Test(Program p)
    {
        p[GetA()]++;
    }

    int this[int a, params MyCollection c] { get => 0; set {} }

    static int GetA() => 0;
}

评估顺序如下:

  1. GetA 被调用并缓存
  2. 创建并缓存一个空 MyCollection
  3. 使用索引的缓存值调用索引器的 getter
  4. 递增结果值
  5. 使用索引的缓存值和增量的结果调用索引器的 setter

对象初始值设定项

在对词法上的前一个索引进行求值之后,但在对词法上的后一个索引进行求值之前,系统会创建并填充集合实例。 实例用于根据需要尽可能多次调用索引器的 getter(如果有)。

例如:

class C1
{
    public int F1;
    public int F2;
}

class Program
{
    static void Test()
    {
        _ = new Program() { [GetA(), GetC()] = { F1 = GetF1(), F2 = GetF2() } };
    }

    C1 this[int a, params MyCollection c] => new C1();

    static int GetA() => 0;
    static int GetC() => 0;
    static int GetF1() => 0;
    static int GetF2() => 0;
}

评估顺序如下:

  1. GetA 被调用并被缓存
  2. 创建、填充和缓存 MyCollection,在进程中调用 GetC
  3. 使用索引的缓存值调用索引器的 getter
  4. 计算 GetF1 并将其分配给上一步中返回的 C1F1 字段
  5. 使用索引的缓存值调用索引器的 getter
  6. 计算 GetF2 并将其分配给上一步中返回的 C1F2 字段

请注意,在参数数组情况中,会计算和缓存其元素,但每次调用索引器的 getter 时都会改用数组的新实例(内部具有相同的值)。 对于上面的示例,评估顺序如下:

  1. 调用并缓存 GetA
  2. 调用并缓存 GetC
  3. 使用缓存的 GetA 调用索引器的 getter,并使用缓存的 GetC 填充新数组
  4. 计算 GetF1 并将其分配给上一步中返回的 C1F1 字段
  5. 使用缓存的 GetA 调用索引器的 getter,并使用缓存的 GetC 填充新数组
  6. 计算 GetF2 并将其分配给上一步中返回的 C1F2 字段

包含空集合的示例:

class C1
{
    public int F1;
    public int F2;
}

class Program
{
    static void Test()
    {
        _ = new Program() { [GetA()] = { F1 = GetF1(), F2 = GetF2() } };
    }

    C1 this[int a, params MyCollection c] => new C1();

    static int GetA() => 0;
    static int GetF1() => 0;
    static int GetF2() => 0;
}

评估顺序如下:

  1. GetA 被调用并缓存
  2. 创建并缓存一个空 MyCollection
  3. 使用索引的缓存值调用索引器的 getter
  4. 计算 GetF1 并将其分配给上一步中返回的 C1F1 字段
  5. 索引器的 getter 是用缓存的索引值来调用的。
  6. 计算 GetF2 并将其分配给上一步中返回的 C1F2 字段

Ref 安全性

以扩展形式调用 API 时,集合表达式引用安全部分适用于参数集合的构造。

参数类型为 ref 结构时,参数是 scoped 隐式的。 UnscopedRefAttribute 可用于替代它。

元数据

在元数据中,我们可以使用System.ParamArrayAttribute来标记非数组params参数,正如今天标记数组params的方式。 但是,对于非数组 params 参数,我们看起来会更安全地使用其他属性。 例如,当前的 VB 编译器无法使用以 ParamArrayAttribute 装饰的项,无论是通过正常形式还是通过扩展形式。 因此,添加“参数”修饰符可能会中断 VB 使用者,并且很可能会中断其他语言或工具的使用者。

鉴于这一点,非数组 params 参数用新的 System.Runtime.CompilerServices.ParamCollectionAttribute标记。

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

本节在 LDM 中经过了审查,并获得了批准。

开放性问题

堆栈分配

以下是引自 https://github.com/dotnet/csharplang/blob/main/proposals/csharp-12.0/collection-expressions.md#unresolved-questions 的内容:“大型集合的堆栈分配可能会破坏堆栈。 编译器是否应该采用启发式方法将这些数据放在堆上? 是否应该不作具体说明,以便实现这种灵活性? 我们应该遵循 params Span<T> 的规范。”这听起来像是我们必须根据该提案来回答这些问题。

[已解决] 隐式 scoped 参数

有人建议,当params修改ref struct参数时,应该将其视为声明scoped。 有人认为,当查看 BCL 案例时,希望对参数进行范围限定的案例数量几乎是 100%。 在少数需要的情况下,可以用 [UnscopedRef] 覆盖默认值。

然而,仅仅因为有params修饰符存在,就更改默认设置可能并不可取。 尤其是在替代/实现方案中,params 修饰符不必匹配。

解决方法:

Params 参数被隐式限定 - https://github.com/dotnet/csharplang/blob/main/meetings/2023/LDM-2023-11-15.md#params-improvements

[已解决] 考虑跨替代强制实施 scopedparams

我们之前曾经说明params参数应该默认为scoped。 但是,由于我们现有的规则与重述 params 相关,因此这会在替代中导致奇怪的行为:

class Base
{
    internal virtual Span<int> M1(scoped Span<int> s1, params Span<int> s2) => throw null!;
}

class Derived : Base
{
    internal override Span<int> M1(Span<int> s1, // Error, missing `scoped` on override
                                   Span<int> s2  // Proposal: Error: parameter must include either `params` or `scoped`
                                  ) => throw null!;
}

在各项替代中,携带 params 与携带 scoped 的行为存在差异:params 是隐式继承的,并且通过它 scoped,而通过自身 scoped 不是隐式继承的,并且必须在每个级别重复。

建议:我们应该强制规定,如果原始定义为scoped 参数,替代 params 参数时必须显式说明 paramsscoped。 换句话说,Derived中的s2必须具有paramsscoped,或者两者兼有。

解决方法:

当需要非 params 参数来执行显式声明时,我们将要求在替代 params 参数时显式声明 scopedparams - https://github.com/dotnet/csharplang/blob/main/meetings/2024/LDM-2024-02-21.md#params-and-scoped-across-overrides

[已解决] 必需成员的状态是否应阻止声明 params 参数?

请看下面的示例:

using System.Collections;
using System.Collections.Generic;

public class MyCollection1 : IEnumerable<long>
{
    IEnumerator<long> IEnumerable<long>.GetEnumerator() => throw null;
    IEnumerator IEnumerable.GetEnumerator() => throw null;
    public void Add(long l) => throw null;

    public required int F; // Collection has required member and constructor doesn't initialize it explicitly
}

class Program
{
    static void Main()
    {
        Test(2, 3); // error CS9035: Required member 'MyCollection1.F' must be set in the object initializer or attribute constructor.
    }

    // Proposal: An error is reported for the parameter indicating that the constructor that is required
    // to be available doesn't initialize required members. In other words, one is able
    // to declare such a parameter under the specified conditions.
    static void Test(params MyCollection1 a)
    {
    }
}

解决方法:

我们将根据构造函数验证 required 成员,该构造函数用于确定是否有资格在声明站点成为 params 参数 - https://github.com/dotnet/csharplang/blob/main/meetings/2024/LDM-2024-02-21.md#required-members-and-params-parameters

替代方案

有一个替代建议,用于仅为 ReadOnlySpan<T> 扩展 params

此外,人们可能会说,由于 集合表达式 现在已在语言中,无需扩展对 params 的支持。 对于任何集合类型。 要使用集合类型的 API,开发人员只需添加两个字符:在扩展的参数列表前添加 [,在其后添加 ]。 鉴于此,扩展 params 支持可能是过度的,尤其是其他语言不太可能很快支持使用非数组 params 参数。