Freigeben über


Optionale Parameter und Parameter-Arrays für Lambdas und Methodengruppen

Hinweis

Dieser Artikel ist eine Feature-Spezifikation. Die Spezifikation dient als Designdokument für das Feature. Es enthält vorgeschlagene Spezifikationsänderungen sowie Informationen, die während des Entwurfs und der Entwicklung des Features erforderlich sind. Diese Artikel werden veröffentlicht, bis die vorgeschlagenen Spezifikationsänderungen abgeschlossen und in die aktuelle ECMA-Spezifikation aufgenommen werden.

Es kann einige Abweichungen zwischen der Feature-Spezifikation und der abgeschlossenen Implementierung geben. Diese Unterschiede werden in den entsprechenden Hinweisen zum Language Design Meeting (LDM) erfasst.

Sie erfahren mehr über den Prozess der Adaption von Funktionen in den C#-Sprachstandard in dem Artikel über die Spezifikationen.

Champion Issue: https://github.com/dotnet/csharplang/issues/6051

Zusammenfassung

Um auf den in C# 10 eingeführten Lambda-Verbesserungen (siehe relevanter Hintergrund) aufzubauen, schlagen wir vor, die Unterstützung für Standard-Parameterwerte und params-Arrays in Lambdas hinzuzufügen. Dies würde es den Benutzenden ermöglichen, die folgenden Lambdas zu implementieren:

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

Die gleiche Möglichkeit werden wir auch für Methodengruppen zulassen:

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;
}

Relevanter Hintergrund

Lambda-Verbesserungen in C# 10

Methodengruppen-Konvertierungsspezifikation §10.8

Motivation

App Frameworks in der .NET Infrastruktur nutzen Lambdas in hohem Maße, um Benutzern die Möglichkeit zu bieten, schnell Geschäftslogik für einen Endpunkt zu schreiben.

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 unterstützen aktuell nicht die Festlegung von Standardwerten für Parameter. Wenn ein Entwickler also eine Anwendung erstellen möchte, die resilient gegenüber Szenarien ist, in denen Benutzer keine Daten zur Verfügung stellen, muss er entweder lokale Funktionen verwenden oder die Standardwerte im Body des Lambdas festlegen, im Gegensatz zu der vorgeschlagenen, prägnanteren 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);
});

Die vorgeschlagene Syntax hat auch den Vorteil, dass sie die verwirrenden Unterschiede zwischen Lambdas und lokalen Funktionen verringert und es einfacher macht, über Konstrukte nachzudenken und Lambdas zu Funktionen "heranwachsen" zu lassen, ohne Funktionen zu kompromittieren, insbesondere in anderen Szenarien, in denen Lambdas in APIs verwendet werden, in denen Methodengruppen auch als Referenzen bereitgestellt werden können. Dies ist auch die Hauptmotivation für die Unterstützung des params-Arrays, das nicht durch das oben erwähnte Anwendungsszenario abgedeckt wird.

Zum Beispiel:

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);

Vorheriges Verhalten

Wenn ein Benutzer vor C# 12 ein Lambda mit einem optionalen oder params Parameter implementierte, gab der Compiler einen Fehler aus.

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

Wenn ein Benutzer versucht, eine Methodengruppe zu verwenden, bei der die zugrundeliegende Methode einen optionalen oder params-Parameter hat, wird diese Information nicht weitergegeben, so dass der Aufruf der Methode keine Typüberprüfung aufgrund einer Unstimmigkeit bei der Anzahl der erwarteten Argumente durchführt.

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[]>'

Neues Verhalten

Nach diesem Vorschlag, der Teil von C# 12 ist, können Standardwerte und params mit dem folgenden Verhalten auf Lambda-Parameter angewendet werden:

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

Standardwerte und params können auf Methodengruppenparameter angewendet werden, indem die Methodengruppe speziell definiert wird.

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

Wichtige Änderung

Vor C# 12 ist der abgeleitete Typ einer Methodengruppe Action oder Func, sodass der folgende Code kompilierbar ist:

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 });
}

Nach dieser Änderung (Element von C# 12) lässt sich Code dieser Art in .NET SDK 7.0.200 oder später nicht mehr kompilieren.

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 });
}

Die Auswirkungen dieser fehlerhaften Änderung müssen bedacht werden. Glücklicherweise wird die Verwendung von var zur Ableitung des Typs einer Methodengruppe erst seit C# 10 unterstützt, so dass nur Code, der seither geschrieben wurde und sich explizit auf dieses Verhalten verlässt, brechen würde.

Detailliertes Design

Änderungen der Grammatik und des Parsers

Diese Verbesserung erfordert die folgenden Änderungen an der Grammatik für Lambda-Ausdrücke.

 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?
   ;

Beachten Sie, dass dies die Möglichkeit von Standard-Parameterwerten und params Arrays nur für Lambdas bietet, nicht aber für anonyme Methoden, die mit delegate { } Syntax deklariert sind.

Für Lambda-Parameter gelten die gleichen Regeln wie für Methodenparameter (§15.6.2):

  • Ein Parameter mit einem ref-, out- oder this-Modifikator kann kein default_argument haben.
  • Ein -Parameterfeld kann nach einem optionalen Parameter auftreten, darf aber keinen Standardwert haben – das Auslassen von Argumenten für ein -Parameterfeld würde stattdessen zur Erstellung eines leeren Arrays führen.

Für Methodengruppen sind keine Änderungen an der Grammatik erforderlich, da dieser Vorschlag nur ihre Semantik ändern würde.

Der folgende Zusatz (fett gedruckt) ist für anonyme Funktionsumwandlungen erforderlich (§10.7):

Insbesondere ist eine anonyme Funktion F mit einem Delegattyp D kompatibel, sofern:

  • [...]
  • Wenn F über eine explizit eingegebene Parameterliste verfügt, weist jeder Parameter in D denselben Typ und dieselben Modifizierer wie der entsprechende Parameter in Fauf, wobei die params Modifizierer und Standardwerteignoriert werden.

Aktualisierungen von früheren Vorschlägen

Der folgende Zusatz (fett gedruckt) ist für die Funktionstypen-Spezifikation in einem früheren Vorschlag erforderlich:

Eine Methodengruppe hat einen natürlichen Typ, wenn alle Kandidatenmethoden in der Methodengruppe eine gemeinsame Signatur inklusive Standardwerte und params-Modifikatoren haben. (Wenn die Methodengruppe Erweiterungsmethoden enthalten kann, gehören zu den Kandidaten auch der enthaltende Typ und alle Bereiche der Erweiterungsmethoden.)

Der natürliche Typ eines anonymen Funktionsausdrucks oder einer Methodengruppe ist ein function_type. Ein function_type repräsentiert eine Methodensignatur: die Parametertypen, Standardwerte, ref types, params-Modifikatoren sowie return type und ref kind. Anonyme Funktionsausdrücke oder Methodengruppen mit der gleichen Signatur haben den gleichen function_type.

Der folgende Zusatz (fett gedruckt) ist für die Delegatetypen-Spezifikation in einem früheren Vorschlag erforderlich:

Der Delegatentyp für die anonyme Funktion oder Methodengruppe mit Parametertypen P1, ..., Pn und Rückgabetyp R ist:

  • wenn ein Parameter oder ein Rückgabewert nicht von Wert ist, oder ein Parameter optional oder params ist, oder es mehr als 16 Parameter gibt, oder einer der Parametertypen oder Rückgabewerte keine gültigen Typargumente sind (z.B. (int* p) => { }), dann ist der Delegat ein synthetisierter internal anonymer Delegattyp mit einer Signatur, die mit der anonymen Funktion oder Methodengruppe übereinstimmt, und mit Parameternamen arg1, ..., argn oder arg, wenn es sich um einen einzelnen Parameter handelt; [...]

Binder-Änderungen

Synthetisierung neuer Delegatentypen

Wie bei dem Verhalten für Delegaten mit ref- oder out-Parametern werden auch für Lambdas oder Methodengruppen, die mit optionalen oder params Parametern definiert sind, Delegatetypen synthetisiert. Beachten Sie, dass in den folgenden Beispielen die Notation a', b', usw. verwendet wird, um diese anonymen Delegatentypen darzustellen.

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 = " ");

Verhalten bei Umwandlung und Vereinheitlichung

Anonyme Delegaten mit optionalen Parametern werden vereinheitlicht, wenn derselbe Parameter (basierend auf der Position) unabhängig vom Parameternamen denselben Standardwert aufweist.

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

Anonyme Delegaten mit einem Array als letztem Parameter werden vereinheitlicht, wenn der letzte Parameter den gleichen params-Modifikator und Array-Typ hat, unabhängig vom Parameternamen.

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)`

In ähnlicher Weise besteht natürlich Kompatibilität mit benannten Delegaten, die bereits optionale und params-Parameter unterstützen. Wenn sich Standardwerte oder params-Modifikatoren in einer Konvertierung unterscheiden, wird der Quellwert nicht verwendet, wenn er in einem Lambda-Ausdruck enthalten ist, da der Lambda-Ausdruck auf keine andere Weise aufgerufen werden kann. Das mag den Benutzern kontraintuitiv erscheinen, daher wird eine Warnung ausgegeben, wenn der Standardwert der Quelle oder der params-Modifizierer vorhanden ist und sich vom Zielwert unterscheidet. Wenn es sich bei der Quelle um eine Methodengruppe handelt, kann diese selbständig aufgerufen werden, so dass keine Warnung ausgegeben wird.

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/Laufzeitverhalten

Die Standardparameterwerte werden an Metadaten ausgegeben. Die AWL für diese Funktion ist der für Lambdas mit ref- und out-Parametern ausgegebenen AWL von Natur aus sehr ähnlich. Eine Klasse, die von System.Delegate oder ähnlichem erbt, wird generiert, und die Invoke-Methode enthält .param-Direktiven, um Standardparameterwerte oder System.ParamArrayAttribute festzulegen – genauso wie es bei einem standardmäßig benannten Delegaten mit optionalen oder params Parametern der Fall wäre.

Diese Delegatetypen können zur Laufzeit wie gewohnt überprüft werden. Im Code können die Benutzenden die DefaultValue in der ParameterInfo, die mit der Lambda- oder Methodengruppe verbunden ist, durch die Verwendung der zugehörigen MethodInfo einsehen.

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

Offene Fragen

Keiner dieser Vorschläge wurde implementiert. Sie bleiben offene Vorschläge.

Offene Frage: Wie interagiert dies mit dem bestehenden DefaultParameterValueAttribut?

Antwortvorschlag: Aus Gründen der Gleichheit sollten Sie das DefaultParameterValue-Attribut für Lambdas zulassen und sicherstellen, dass das Verhalten bei der Generierung von Delegaten mit den über die Syntax unterstützten Standardparameterwerten übereinstimmt.

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

Offene Frage: Beachten Sie zunächst, dass dies nicht in den Bereich des aktuellen Vorschlags fällt, aber es könnte sich lohnen, in Zukunft darüber zu diskutieren. Sollen wir Standardwerte mit implizit typisierten Lambda-Parametern unterstützen? D. h.,

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

Diese Ableitung führt zu einigen kniffligen Konversionsproblemen, die eine weitere Diskussion erfordern würden.

Es gibt hier auch Überlegungen zur Parsing-Leistung. Beispielsweise könnte heute die Bezeichnung (x = niemals der Anfang eines Lambda-Ausdrucks sein. Wenn diese Syntax für Lambda-Vorgaben zugelassen wäre, müsste der Parser einen größeren Lookahead (bis zu einem =>-Token) durchführen, um festzustellen, ob ein Begriff ein Lambda ist oder nicht.

Design-Treffen

  • LDM 2022-10-10: Entscheidung, die Unterstützung für params auf die gleiche Weise wie für Standardparameterwerte hinzuzufügen.