Partager via


Paramètres optionnels et tableaux de paramètres pour les lambdas et les groupes de méthodes

Remarque

Cet article est une spécification de fonctionnalité. La spécification sert de document de conception pour la fonctionnalité. Elle inclut les changements de spécification proposés, ainsi que les informations nécessaires à la conception et au développement de la fonctionnalité. Ces articles sont publiés jusqu'à ce que les changements proposés soient finalisés et incorporés dans la spécification ECMA actuelle.

Il peut y avoir des différences entre la spécification de la fonctionnalité et l'implémentation réalisée. Ces différences sont consignées dans les notes pertinentes de la réunion de conception linguistique (LDM).

Pour en savoir plus sur le processus d'adoption des speclets de fonctionnalité dans la norme du langage C#, consultez l'article sur les spécifications.

Problème de champion : https://github.com/dotnet/csharplang/issues/6051

Récapitulatif

Pour compléter les améliorations apportées aux lambdas en C# 10 (voir le contexte pertinent), nous proposons d'ajouter la prise en charge des valeurs de paramètres par défaut et des tableaux params dans les lambdas. Cela permettrait aux utilisateurs d’implémenter les lambdas suivantes :

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

De même, nous autoriserons le même type de comportement pour les groupes de méthodes :

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

Arrière-plan pertinent

Améliorations lambda dans C# 10

Spécification de conversion de groupe de méthodes §10.8

Motivation

Les infrastructures d’application dans l’écosystème .NET tirent largement parti des lambdas pour permettre aux utilisateurs d’écrire rapidement une logique métier associée à un point de terminaison.

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

Actuellement, les lambdas ne prennent pas en charge la définition des valeurs par défaut sur les paramètres. Par conséquent, si un développeur souhaitait créer une application résiliente aux scénarios où les utilisateurs n’ont pas fourni de données, il devrait soit utiliser des fonctions locales, soit définir les valeurs par défaut dans le corps lambda, par opposition à la syntaxe proposée plus succincte.

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

La syntaxe proposée présente également l’avantage de réduire les différences déroutantes entre les fonctions lambdas et locales, ce qui facilite la compréhension des constructions et permet de faire évoluer les lambdas en fonctions sans compromettre les fonctionnalités, en particulier dans d’autres scénarios où les lambdas sont utilisées dans les API où les groupes de méthodes peuvent également être passés en tant que références. C’est aussi la principale motivation pour prendre en charge le tableau params qui n’est pas couvert par le scénario de cas d’utilisation mentionné ci-dessus.

Exemple :

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

Comportement précédent

Avant C# 12, lorsqu’un utilisateur implémente une lambda avec un paramètre facultatif ou params, le compilateur génère une erreur.

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

Lorsqu’un utilisateur tente d’utiliser un groupe de méthodes où la méthode sous-jacente a un paramètre facultatif ou params, ces informations ne sont pas propagées, de sorte que l’appel à la méthode ne passe pas la vérification de type en raison d’un décalage dans le nombre d’arguments attendus.

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

Nouveau comportement

Après cette proposition (faisant partie de C# 12), les valeurs par défaut et params peuvent être appliqués aux paramètres lambda avec le comportement suivant :

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

Les valeurs par défaut et params peuvent être appliqués aux paramètres de groupe de méthodes en définissant spécifiquement ce groupe de méthodes :

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

Modification avec rupture

Avant C# 12, le type déduit d’un groupe de méthodes est Action ou Func, donc le code suivant se compile :

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

Après cette modification (faisant partie de C# 12), le code de cette nature cesse de se compiler dans le Kit de développement logiciel (SDK) .NET 7.0.200 ou version ultérieure.

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

L’impact de cette modification majeure doit être pris en compte. Heureusement, l’utilisation de var pour déduire le type d’un groupe de méthodes n’est prise en charge que depuis C# 10, donc seul le code écrit depuis lors et qui repose explicitement sur ce comportement serait affecté.

Conception détaillée

Modifications de grammaire et d’analyseur

Cette amélioration nécessite les modifications suivantes de la grammaire pour les expressions 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?
   ;

Notez que cela autorise les valeurs par défaut des paramètres et les tableaux params uniquement pour les lambdas, et non pour les méthodes anonymes déclarées avec la syntaxe delegate { }.

Les mêmes règles que pour les paramètres de méthode (§15.6.2) s’appliquent aux paramètres lambda :

  • Un paramètre avec un modificateur ref, out ou this ne peut pas avoir un argument_par_défaut .
  • Un parameter_array peut apparaître après un paramètre facultatif, mais il ne peut pas avoir de valeur par défaut (l’omission d’arguments pour un parameter_array entraînerait plutôt la création d’un tableau vide).

Aucune modification de la grammaire n’est nécessaire pour les groupes de méthodes, car cette proposition ne modifierait que leur sémantique.

L’ajout suivant (en gras) est requis pour les conversions de fonctions anonymes (§10.7) :

Plus précisément, une fonction anonyme F est compatible avec un type de délégué D à condition que :

  • [...]
  • Si F a une liste de paramètres explicitement typée, chaque paramètre de D a le même type et les mêmes modificateurs que le paramètre correspondant dans F, en ignorant les modificateurs params et les valeurs par défaut.

Mises à jour des propositions antérieures

L'ajout suivant (en gras) est requis pour la spécification des types de fonctions dans une proposition antérieure :

Un groupe de méthodes a un type naturel si toutes les méthodes candidates du groupe de méthodes ont une signature commune, y compris les valeurs par défaut et les modificateurs params. (Si le groupe de méthodes peut inclure des méthodes d'extension, les candidats incluent le type contenant et toutes les portées des méthodes d'extension).

Le type naturel d’une expression de fonction anonyme ou d’un groupe de méthodes est un function_type. Un function_type représente la signature d'une méthode : les types de paramètres, les valeurs par défaut, les types de refparams, les modificateurs, le type de retour et le type de ref. Les expressions de fonctions anonymes ou les groupes de méthodes avec la même signature ont le même function_type.

L’ajout suivant (en gras) est requis à la spécification des types de délégués dans une proposition antérieure :

Le type de délégué pour la fonction anonyme ou le groupe de méthodes avec des types de paramètres P1, ..., Pn et le type de retour R est :

  • si un paramètre ou une valeur de retour n’est pas par valeur, , ou si n’importe quel paramètre est facultatif, ou params, ou s'il y a plus de 16 paramètres, ou si l’un des types de paramètres ou le retour n’est pas un argument de type valide (par exemple, (int* p) => { }), le délégué est un type de délégué anonyme synthétisé internal avec une signature qui correspond à la fonction anonyme ou au groupe de méthodes, et avec des noms de paramètres arg1, ..., argn ou arg si un paramètre unique.

Changements dans les classeurs

Synthèse des nouveaux types de délégués

Comme pour le comportement des délégués avec des paramètres ref ou out, les types de délégués sont synthétisés pour les lambdas ou les groupes de méthodes définis avec des paramètres facultatifs ou params. Notez que, dans les exemples ci-dessous, la notation a', b', etc. est utilisée pour représenter ces types de délégués anonymes.

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

Comportement de conversion et d’unification

Les délégués anonymes avec des paramètres facultatifs seront unifiés lorsque le même paramètre (basé sur la position) a la même valeur par défaut, quel que soit le nom du paramètre.

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

Les délégués anonymes avec un tableau comme dernier paramètre seront unifiés lorsque le dernier paramètre a le même modificateur params et le même type de tableau, quel que soit le nom du paramètre.

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

De même, il y a bien sûr compatibilité avec les délégués nommés qui supportent déjà les paramètres optionnels et params. Lorsque les valeurs par défaut ou les modificateurs params diffèrent dans une conversion, ceux de la source ne seront pas utilisés s’ils se trouvent dans une expression lambda, car l’expression lambda ne peut pas être appelée d’une autre manière. Cela peut sembler contre-intuitif pour les utilisateurs. Par conséquent, un avertissement sera émis lorsque la valeur par défaut de la source ou le modificateur params est présent et différent de celui de la cible. Si la source est un groupe de méthodes, elle peut être appelée seule. Par conséquent, aucun avertissement ne sera émis.

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

Comportement IL/runtime

Les valeurs par défaut des paramètres seront émises dans les métadonnées. L'IL pour cette fonctionnalité sera de nature très similaire à l'IL émise pour les lambdas avec des paramètres ref et out. Une classe qui hérite de System.Delegate ou similaire sera générée, et la méthode Invoke inclura des directives .param pour définir des valeurs par défaut des paramètres ou System.ParamArrayAttribute (tout comme c’est le cas pour un délégué nommé standard avec des paramètres facultatifs ou params).

Ces types de délégués peuvent être inspectés à l'exécution, comme d'habitude. Dans le code, les utilisateurs peuvent introspecter le DefaultValue dans le ParameterInfo associé à la lambda ou au groupe de méthodes en utilisant le MethodInfo associé.

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

Questions ouvertes

Aucune de ces propositions n’a été implémentée. Elle restent des propositions ouvertes.

Question ouverte : comment cela interagit-il avec l’attribut DefaultParameterValue existant ?

Réponse proposée : Pour la parité, autorisez l’attribut DefaultParameterValue sur les lambdas et assurez-vous que le comportement de génération de délégué correspond aux valeurs par défaut des paramètres prises en charge par la syntaxe.

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

Question ouverte : Premièrement, notez que cela dépasse le cadre de la proposition actuelle, mais il pourrait être intéressant de discuter à l'avenir. Voulons-nous soutenir des valeurs par défaut avec des paramètres lambda implicitement typés ? Autrement dit :

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

Cette inférence entraîne des problèmes de conversion délicats qui nécessiteraient une discussion plus approfondie.

Il y a également des considérations de performance d'analyse ici. Par exemple, aujourd’hui, le terme (x = ne pourrait jamais être le début d’une expression lambda. ** Si cette syntaxe était autorisée pour les valeurs par défaut des lambda, l'analyseur aurait besoin d'une anticipation plus importante en analysant jusqu'à rencontrer un jeton => afin de déterminer s'il s'agit ou non d'un terme lambda.

Réunions de conception

  • LDM 2022-10-10 : décision d'ajouter la prise en charge de params de la même manière que les valeurs de paramètres par défaut.