用于 lambdas 和方法组的可选参数和参数数组参数

注意

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

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

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

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

总结

为了进一步推进 C# 10 中引入的 lambda 改进(请参阅 相关背景信息),我们建议在 lambda 中添加对默认参数值和 params 数组的支持。 这样,用户就能实现以下 lambda 表达式:

var addWithDefault = (int addTo = 2) => addTo + 1;
addWithDefault(); // 3
addWithDefault(5); // 6

var counter = (params int[] xs) => xs.Length;
counter(); // 0
counter(1, 2, 3); // 3

同样,我们将允许方法组的相同的行为方式:

var addWithDefault = AddWithDefaultMethod;
addWithDefault(); // 3
addWithDefault(5); // 6

var counter = CountMethod;
counter(); // 0
counter(1, 2); // 2

int AddWithDefaultMethod(int addTo = 2) {
  return addTo + 1;
}
int CountMethod(params int[] xs) {
  return xs.Length;
}

相关背景

C# 10 中的 Lambda 改进

方法组转换规范 §10.8

动机

.NET 生态系统中的应用框架利用 lambda 来让用户快速编写与终结点关联的业务逻辑。

var app = WebApplication.Create(args);

app.MapPost("/todos/{id}", (TodoService todoService, int id, string task) => {
  var todo = todoService.Create(id, task);
  return Results.Created(todo);
});

Lambda 目前不支持为参数设置默认值,因此,如果开发人员想要构建一个在用户未提供数据时仍能正常工作的应用程序,他们只能使用本地函数或在 lambda 主体中手动设置默认值,而无法使用更简洁的建议语法。

var app = WebApplication.Create(args);

app.MapPost("/todos/{id}", (TodoService todoService, int id, string task = "foo") => {
  var todo = todoService.Create(id, task);
  return Results.Created(todo);
});

建议的语法还有一个好处,能够减少 lambda 和本地函数之间的混淆差异,使构造更容易推理,并在不损害功能的情况下将 lambda “升级”到函数,特别是在其它情境下使用 lambda 的 API 中,其中方法组也可以作为引用提供。 这也是支持上述用例方案未涵盖的 params 数组的主要动机。

例如:

var app = WebApplication.Create(args);

Result TodoHandler(TodoService todoService, int id, string task = "foo") {
  var todo = todoService.Create(id, task);
  return Results.Created(todo);
}

app.MapPost("/todos/{id}", TodoHandler);

以前的行为

在 C# 12 之前,当用户使用可选或 params 参数实现 lambda 时,编译器将引发错误。

var addWithDefault = (int addTo = 2) => addTo + 1; // error CS1065: Default values are not valid in this context.
var counter = (params int[] xs) => xs.Length; // error CS1670: params is not valid in this context

当用户尝试使用底层方法具有可选或 params 参数的方法组时,该信息不会被传播,因此由于预期的参数数量不匹配,导致对方法的调用无法通过类型检查。

void M1(int i = 1) { }
var m1 = M1; // Infers Action<int>
m1(); // error CS7036: There is no argument given that corresponds to the required parameter 'obj' of 'Action<int>'

void M2(params int[] xs) { }
var m2 = M2; // Infers Action<int[]>
m2(); // error CS7036: There is no argument given that corresponds to the required parameter 'obj' of 'Action<int[]>'

新行为

按照此建议(C# 12 的一部分),默认值和 params 可以应用于具有以下行为的 lambda 参数:

var addWithDefault = (int addTo = 2) => addTo + 1;
addWithDefault(); // 3
addWithDefault(5); // 6

var counter = (params int[] xs) => xs.Length;
counter(); // 0
counter(1, 2, 3); // 3

通过专门定义此类方法组,可以将默认值和 params 应用于方法组参数:

int AddWithDefault(int addTo = 2) {
  return addTo + 1;
}

var add1 = AddWithDefault; 
add1(); // ok, default parameter value will be used

int Counter(params int[] xs) {
  return xs.Length;
}

var counter1 = Counter;
counter1(1, 2, 3); // ok, `params` will be used

中断性变更

在 C# 12 之前,方法组的推断类型是 ActionFunc,因此以下代码能够编译:

void WriteInt(int i = 0) {
  Console.Write(i);
}

var writeInt = WriteInt; // Inferred as Action<int>
DoAction(writeInt, 3); // Ok, writeInt is an Action<int>

void DoAction(Action<int> a, int p) {
  a(p);
}

int Count(params int[] xs) {
  return xs.Length;
}
var counter = Count; // Inferred as Func<int[], int>
DoFunction(counter, 3); // Ok, counter is a Func<int[], int>

int DoFunction(Func<int[], int> f, int p) {
  return f(new[] { p });
}

在此更改(C# 12 的一部分)之后,此性质的代码不再在 .NET SDK 7.0.200 或更高版本中编译。

void WriteInt(int i = 0) {
  Console.Write(i);
}

var writeInt = WriteInt; // Inferred as anonymous delegate type
DoAction(writeInt, 3); // Error, cannot convert from anonymous delegate type to Action

void DoAction(Action<int> a, int p) {
  a(p);
}

int Count(params int[] xs) {
  return xs.Length;
}
var counter = Count; // Inferred as anonymous delegate type
DoFunction(counter, 3); // Error, cannot convert from anonymous delegate type to Func

int DoFunction(Func<int[], int> f, int p) {
  return f(new[] { p });
}

需要考虑这一破坏性变更的影响。 幸运的是,只有 C# 10 版本以来才支持使用 var 推断方法组的类型,因此,只有在此之后编写的、明确依赖此行为的代码才会出现问题。

详细设计

语法和解析器更改

此增强功能需要对 lambda 表达式的语法进行以下更改。

 lambda_expression
   : modifier* identifier '=>' (block | expression)
-  | attribute_list* modifier* type? lambda_parameters '=>' (block | expression)
+  | attribute_list* modifier* type? lambda_parameter_list '=>' (block | expression)
   ;

+lambda_parameter_list
+  : lambda_parameters (',' parameter_array)?
+  | parameter_array
+  ;

 lambda_parameter
   : identifier
-  | attribute_list* modifier* type? identifier
+  | attribute_list* modifier* type? identifier default_argument?
   ;

请注意,这仅允许 lambda 的默认参数值和 params 数组,而不允许使用 delegate { } 语法声明的匿名方法。

与方法参数(§15.6.2)相同的规则适用于 lambda 参数:

  • 具有 refoutthis 修饰符的参数不能具有 default_argument
  • parameter_array 可以出现在可选参数之后,但不能有默认值,parameter_array 的参数缺失会导致创建一个空数组。

方法组无需更改语法,因为此建议只会更改其语义。

匿名函数转换需要以下加法(粗体)(§10.7):

具体而言,匿名函数 F 在以下条件下与委托类型 D 兼容:

  • [...]
  • 如果 F 具有显式类型化参数列表,D 中的每个参数的类型和修饰符与 F忽略 params 修饰符和默认值中的相应参数相同。

先前建议的更新

之前提案中的 函数类型 规范需要增加以下内容(即粗体部分):

如果方法组中的所有候选方法都有共同的签名(包括默认值和 params 修饰符),那么方法组就具有自然类型。 (如果方法组可能包含扩展方法,则候选项包括包含类型和所有扩展方法范围。

匿名函数表达式或方法组的自然类型是 function_typefunction_type 表示方法签名:参数类型、默认值、ref 类型、params 修饰符,以及返回类型和 ref 类型。 具有相同签名的匿名函数表达式或方法组具有相同的 function_type

在之前的提议中,委托类型规范需要添加以下内容(粗体):

参数类型为 P1, ..., Pn 和返回类型为 R 的匿名函数或方法组的委托类型为:

  • 如果任何参数或返回值不是按值,或任何参数是可选的或 params,或者参数超过 16 个,或者任何参数类型或返回不是有效的类型参数(例如,(int* p) => { }),则委托是与匿名函数或方法组匹配签名的合成的internal匿名委托类型,参数名称为 arg1, ..., argnarg如果是单个参数;[...]

活页夹更改

合成新的委托类型

与使用 refout 参数的委托的行为一样,委托类型也是为使用可选参数或 params 参数定义的 lambdas 或方法组合成的。 请注意,在下面的示例中,表示法 a'b'等用于表示这些匿名委托类型。

var addWithDefault = (int addTo = 2) => addTo + 1;
// internal delegate int a'(int arg = 2);
var printString = (string toPrint = "defaultString") => Console.WriteLine(toPrint);
// internal delegate void b'(string arg = "defaultString");
var counter = (params int[] xs) => xs.Length;
// internal delegate int c'(params int[] arg);
string PathJoin(string s1, string s2, string sep = "/") { return $"{s1}{sep}{s2}"; }
var joinFunc = PathJoin;
// internal delegate string d'(string arg1, string arg2, string arg3 = " ");

转换和统一行为

如果同一参数(基于位置)具有相同的默认值(无论参数名称如何),则具有可选参数的匿名委托将统一。

int E(int j = 13) {
  return 11;
}

int F(int k = 0) {
  return 3;
}

int G(int x = 13) {
  return 4;
}

var a = (int i = 13) => 1;
// internal delegate int b'(int arg = 13);
var b = (int i = 0) => 2;
// internal delegate int c'(int arg = 0);
var c = (int i = 13) => 3;
// internal delegate int b'(int arg = 13);
var d = (int c = 13) => 1;
// internal delegate int b'(int arg = 13);

var e = E;
// internal delegate int b'(int arg = 13);
var f = F;
// internal delegate int c'(int arg = 0);
var g = G;
// internal delegate int b'(int arg = 13);

a = b; // Not allowed
a = c; // Allowed
a = d; // Allowed
c = e; // Allowed
e = f; // Not Allowed
b = f; // Allowed
e = g; // Allowed

d = (int c = 10) => 2; // Warning: default parameter value is different between new lambda
                       // and synthesized delegate b'. We won't do implicit conversion

当最后一个参数具有相同的 params 修饰符和数组类型(无论参数名称如何)时,以数组作为最后一个参数的匿名委托将被统一。

int C(int[] xs) {
  return xs.Length;
}

int D(params int[] xs) {
  return xs.Length;
}

var a = (int[] xs) => xs.Length;
// internal delegate int a'(int[] xs);
var b = (params int[] xs) => xs.Length;
// internal delegate int b'(params int[] xs);

var c = C;
// internal delegate int a'(int[] xs);
var d = D;
// internal delegate int b'(params int[] xs);

a = b; // Not allowed
a = c; // Allowed
b = c; // Not allowed
b = d; // Allowed

c = (params int[] xs) => xs.Length; // Warning: different delegate types; no implicit conversion
d = (int[] xs) => xs.Length; // OK. `d` is `delegate int (params int[] arg)`

同样,与已支持可选参数和 params 参数的已命名委托当然也是兼容的。 当默认值或 params 修饰符在转换中出现差异时,如果源值在 lambda 表达式中,则源值将不会被使用,因为不能以任何其他方式调用 lambda。 这可能对用户来说显得不合常理,因此当源默认值或 params 修饰符存在且不同于目标修饰符时,将发出警告。 如果源是方法组,则可以自行调用它,因此不会发出任何警告。

delegate int DelegateNoDefault(int x);
delegate int DelegateWithDefault(int x = 1);

int MethodNoDefault(int x) => x;
int MethodWithDefault(int x = 2) => x;
DelegateNoDefault d1 = MethodWithDefault; // no warning: source is a method group
DelegateWithDefault d2 = MethodWithDefault; // no warning: source is a method group
DelegateWithDefault d3 = MethodNoDefault; // no warning: source is a method group
DelegateNoDefault d4 = (int x = 1) => x; // warning: source present, target missing
DelegateWithDefault d5 = (int x = 2) => x; // warning: source present, target different
DelegateWithDefault d6 = (int x) => x; // no warning: source missing, target present

delegate int DelegateNoParams(int[] xs);
delegate int DelegateWithParams(params int[] xs);

int MethodNoParams(int[] xs) => xs.Length;
int MethodWithParams(params int[] xs) => xs.Length;
DelegateNoParams d7 = MethodWithParams; // no warning: source is a method group
DelegateWithParams d8 = MethodNoParams; // no warning: source is a method group
DelegateNoParams d9 = (params int[] xs) => xs.Length; // warning: source present, target missing
DelegateWithParams d10 = (int[] xs) => xs.Length; // no warning: source missing, target present

IL/运行时行为

默认参数值将发送到元数据。 该功能的 IL 在性质上与使用 refout 参数的 lambdas 的 IL 非常相似。 将生成一个继承自 System.Delegate 或类似项的类,Invoke 方法将包含 .param 指令以设置默认参数值或 System.ParamArrayAttribute,正如带有可选或 params 参数的标准命名委托的情况一样。

这些委托类型可在运行时进行正常检查。 在代码中,用户可以通过使用相关的 MethodInfo 来反省与 lambda 或方法组相关的 ParameterInfo 中的 DefaultValue

var addWithDefault = (int addTo = 2) => addTo + 1;
int AddWithDefaultMethod(int addTo = 2)
{
    return addTo + 1;
}

var defaultParm = addWithDefault.Method.GetParameters()[0].DefaultValue; // 2

var add1 = AddWithDefaultMethod;
defaultParm = add1.Method.GetParameters()[0].DefaultValue; // 2

开放性问题

这两者都没有实现。 这些提议仍然是开放性的。

开放性问题: 这如何与现有的 DefaultParameterValue 属性相互作用?

建议的答案: 为了一致性,允许在 lambda 上使用 DefaultParameterValue 属性,并确保委托生成行为与支持语法的默认参数值相匹配。

var a = (int i = 13) => 1;
// same as
var b = ([DefaultParameterValue(13)] int i) => 1;
b = a; // Allowed

公开问题: 首先,请注意,这超出了当前提案的范围,但将来可能值得讨论。 我们要支持隐式类型的 lambda 参数的默认值吗? 例如,

delegate void M1(int i = 3);
M1 m = (x = 3) => x + x; // Ok

delegate void M2(long i = 2);
M2 m = (x = 3.0) => ...; //Error: cannot convert implicitly from long to double

这种推理会导致一些棘手的转换问题,这些问题需要更多讨论。

此处还涉及解析性能方面的注意事项。 例如,今天术语 (x = 永远不能是 lambda 表达式的开头。 如果 lambda 缺省允许使用这种语法,那么解析器将需要更大的前瞻性(一直扫描到 => 令牌)才能确定一个术语是否是 lambda。

设计会议

  • LDM 2022-10-10:决定以与默认参数值相同的方式添加对 params 的支持。