Optional and parameter array parameters for lambdas and method groups
Note
This article is a feature specification. The specification serves as the design document for the feature. It includes proposed specification changes, along with information needed during the design and development of the feature. These articles are published until the proposed spec changes are finalized and incorporated in the current ECMA specification.
There may be some discrepancies between the feature specification and the completed implementation. Those differences are captured in the pertinent language design meeting (LDM) notes.
You can learn more about the process for adopting feature speclets into the C# language standard in the article on the specifications.
Summary
To build on top of the lambda improvements introduced in C# 10 (see relevant background), we propose adding support for default parameter values and params
arrays in lambdas. This would enable users to implement the following lambdas:
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
Similarly, we will allow the same kind of behavior for method groups:
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;
}
Relevant background
Method group conversion specification §10.8
Motivation
App frameworks in the .NET ecosystem leverage lambdas heavily to allow users to quickly write business logic associated with an endpoint.
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);
});
Lambdas don't currently support setting default values on parameters, so if a developer wanted to build an application that was resilient to scenarios where users didn't provide data, they're left to either use local functions or set the default values within the lambda body, as opposed to the more succinct proposed syntax.
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);
});
The proposed syntax also has the benefit of reducing confusing differences between lambdas and local functions, making it easier to reason about constructs and "grow up" lambdas to functions without compromising features, particularly in other scenarios where lambdas are used in APIs where method groups can also be provided as references.
This is also the main motivation for supporting the params
array which is not covered by the aforementioned use-case scenario.
For example:
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);
Previous behavior
Before C# 12, when a user implements a lambda with an optional or params
parameter, the compiler raises an error.
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
When a user attempts to use a method group where the underlying method has an optional or params
parameter, this information isn't propagated, so the call to the method doesn't typecheck due to a mismatch in the number of expected arguments.
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[]>'
New behavior
Following this proposal (part of C# 12), default values and params
can be applied to lambda parameters with the following behavior:
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
Default values and params
can be applied to method group parameters by specifically defining such method group:
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
Breaking change
Before C# 12, the inferred type of a method group is Action
or Func
so the following code compiles:
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 });
}
Following this change (part of C# 12), code of this nature ceases to compile in .NET SDK 7.0.200 or later.
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 });
}
The impact of this breaking change needs to be considered. Fortunately, the use of var
to infer the type of a method group has
only been supported since C# 10, so only code which has been written since then which explicitly relies on this behavior would break.
Detailed design
Grammar and parser changes
This enhancement requires the following changes to the grammar for lambda expressions.
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?
;
Note that this allows default parameter values and params
arrays only for lambdas, not for anonymous methods declared with delegate { }
syntax.
Same rules as for method parameters (§15.6.2) apply for lambda parameters:
- A parameter with a
ref
,out
orthis
modifier cannot have a default_argument. - A parameter_array may occur after an optional parameter, but cannot have a default value – the omission of arguments for a parameter_array would instead result in the creation of an empty array.
No changes to the grammar are necessary for method groups since this proposal would only change their semantics.
The following addition (in bold) is required to anonymous function conversions (§10.7):
Specifically, an anonymous function
F
is compatible with a delegate typeD
provided:
- [...]
- If
F
has an explicitly typed parameter list, each parameter inD
has the same type and modifiers as the corresponding parameter inF
ignoringparams
modifiers and default values.
Updates of prior proposals
The following addition (in bold) is required to the function types specification in a prior proposal:
A method group has a natural type if all candidate methods in the method group have a common signature including default values and
params
modifiers. (If the method group may include extension methods, the candidates include the containing type and all extension method scopes.)
The natural type of an anonymous function expression or method group is a function_type. A function_type represents a method signature: the parameter types, default values, ref kinds,
params
modifiers, and return type and ref kind. Anonymous function expressions or method groups with the same signature have the same function_type.
The following addition (in bold) is required to the delegate types specification in a prior proposal:
The delegate type for the anonymous function or method group with parameter types
P1, ..., Pn
and return typeR
is:
- if any parameter or return value is not by value, or any parameter is optional or
params
, or there are more than 16 parameters, or any of the parameter types or return are not valid type arguments (say,(int* p) => { }
), then the delegate is a synthesizedinternal
anonymous delegate type with signature that matches the anonymous function or method group, and with parameter namesarg1, ..., argn
orarg
if a single parameter; [...]
Binder changes
Synthesizing new delegate types
As with the behavior for delegates with ref
or out
parameters, delegate types are synthesized for lambdas or method groups defined with optional or params
parameters.
Note that in the below examples, the notation a'
, b'
, etc. is used to represent these anonymous delegate types.
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 = " ");
Conversion and unification behavior
Anonymous delegates with optional parameters will be unified when the same parameter (based on position) has the same default value, regardless of parameter name.
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
Anonymous delegates with an array as the last parameter will be unified when the last parameter has the same params
modifier and array type, regardless of parameter name.
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)`
Similarly, there is of course compatibility with named delegates that already support optional and params
parameters.
When default values or params
modifiers differ in a conversion, the source one will be unused if it's in a lambda expression, since the lambda cannot be called in any other way.
That might seem counter-intuitive to users, hence a warning will be emitted when the source default value or params
modifier is present and different from the target one.
If the source is a method group, it can be called on its own, hence no warning will be emitted.
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/runtime behavior
The default parameter values will be emitted to metadata. The IL for this feature will be very similar in nature to the IL emitted for lambdas with ref
and out
parameters. A class which
inherits from System.Delegate
or similar will be generated, and the Invoke
method will include .param
directives to set default parameter values or System.ParamArrayAttribute
–
just as would be the case for a standard named delegate with optional or params
parameters.
These delegate types can be inspected at runtime, as normal.
In code, users can introspect the DefaultValue
in the ParameterInfo
associated with the lambda or method group by using the associated MethodInfo
.
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
Open questions
Neither of these have been implemented. They remain open proposals.
Open question: how does this interact with the existing DefaultParameterValue
attribute?
Proposed answer: For parity, permit the DefaultParameterValue
attribute on lambdas and ensure that the delegate generation behavior matches for default parameter values supported via the syntax.
var a = (int i = 13) => 1;
// same as
var b = ([DefaultParameterValue(13)] int i) => 1;
b = a; // Allowed
Open question: First, note that this is outside the scope of the current proposal but it might be worth discussing in the future. Do we want to support defaults with implicitly typed lambda parameters? I.e.,
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
This inference leads to some tricky conversion issues which would require more discussion.
There are also parsing performance considerations here. For instance, today the term
(x =
could never be the start of a lambda expression. If this syntax was allowed for lambda defaults, then the parser
would need a larger lookahead (scanning all the way until a =>
token) in order to determine whether a term is a lambda or not.
Design meetings
- LDM 2022-10-10: decision to add support for
params
in the same way as default parameter values.