Partilhar via


Parâmetros opcionais e de matriz de parâmetros para lambdas e grupos de métodos

Observação

Este artigo é uma especificação de recurso. A especificação serve como o documento de design para o recurso. Ele inclui mudanças de especificação propostas, juntamente com as informações necessárias durante o design e desenvolvimento do recurso. Estes artigos são publicados até que as alterações de especificações propostas sejam finalizadas e incorporadas na especificação ECMA atual.

Pode haver algumas discrepâncias entre a especificação do recurso e a implementação concluída. Essas diferenças são capturadas nas notas pertinentes da Language Design Meeting (LDM).

Você pode saber mais sobre o processo de adoção de especificações de recursos no padrão de linguagem C# no artigo sobre as especificações .

Resumo

Para tirar partido das melhorias de lambda introduzidas no C# 10 (consulte para informações antecedentes relevantes), propomos adicionar suporte para valores de parâmetros padrão e matrizes em lambdas params. Isso permitiria que os usuários implementassem as seguintes 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

Da mesma forma, permitiremos o mesmo tipo de comportamento para grupos de métodos:

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

Contexto relevante

Melhorias do Lambda no C# 10

Especificação de conversão de grupo de métodos §10.8

Motivação

Os frameworks de aplicações no ecossistema .NET aproveitam fortemente os lambdas para permitir que os utilizadores escrevam rapidamente a lógica de negócio associada a um 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);
});

Atualmente, o Lambdas não oferece suporte à definição de valores padrão em parâmetros, portanto, se um desenvolvedor quiser criar um aplicativo resiliente a cenários em que os usuários não forneceram dados, ele terá que usar funções locais ou definir os valores padrão dentro do corpo lambda, em oposição à sintaxe proposta mais sucinta.

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

A sintaxe proposta também tem o benefício de reduzir as diferenças confusas entre lambdas e funções locais, tornando mais fácil raciocinar sobre construções e transformar lambdas em funções sem comprometer funcionalidades, especialmente noutros cenários onde lambdas são usados em APIs onde grupos de métodos também podem ser usados como referências. Esta é também a principal motivação para apoiar o array params, que não é abrangido pelo cenário de caso de uso acima mencionado.

Por exemplo:

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

Comportamento anterior

Antes do C# 12, quando um usuário implementa um lambda com um parâmetro opcional ou params, o compilador gera um erro.

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

Quando um usuário tenta usar um grupo de métodos em que o método subjacente tem um parâmetro opcional ou params, essas informações não são propagadas, portanto, a chamada para o método não verifica o tipo devido a uma incompatibilidade no número de argumentos esperados.

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

Novo comportamento

Seguindo esta proposta (parte do C# 12), os valores padrão e params podem ser aplicados a parâmetros lambda com o seguinte comportamento:

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

Os valores e params padrão podem ser aplicados aos parâmetros do grupo de métodos definindo especificamente esse grupo de métodos:

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

Quebrando a mudança

Antes do C# 12, o tipo inferido de um grupo de métodos é Action ou Func portanto, o código a seguir é compilado:

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

Após essa alteração (parte do C# 12), o código dessa natureza deixa de ser compilado no .NET SDK 7.0.200 ou posterior.

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

O impacto desta mudança revolucionária deve ser considerado. Felizmente, o uso de var para inferir o tipo de um grupo de métodos só tem sido suportado desde C# 10, portanto, apenas o código que foi escrito desde então que se baseia explicitamente nesse comportamento seria quebrado.

Projeto detalhado

Alterações na gramática e no analisador de sintaxe

Esse aprimoramento requer as seguintes alterações na gramática para expressões 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?
   ;

Observe que isso permite valores de parâmetros padrão e matrizes params somente para lambdas, não para métodos anônimos declarados com sintaxe delegate { }.

Aplicam-se aos parâmetros lambda as mesmas regras que para os parâmetros do método (§15.6.2):

  • Um parâmetro com um modificador ref, out ou this não pode ter um default_argument.
  • Uma parameter_array pode ocorrer após um parâmetro opcional, mas não pode ter um valor padrão – a omissão de argumentos para um parameter_array resultaria, em vez disso, na criação de uma matriz vazia.

Não são necessárias alterações gramaticais para grupos de métodos, uma vez que esta proposta apenas alteraria a sua semântica.

É necessário o seguinte aditamento (a negrito) às conversões de funções anónimas (§10.7):

Especificamente, uma função anónima F é compatível com um tipo de delegado D desde que:

  • [...]
  • Se F tiver uma lista de parâmetros explicitamente tipada, cada parâmetro no D terá o mesmo tipo e modificadores que o parâmetro correspondente em F, ignorando os modificadores e valores padrão de params.

Atualizações de propostas anteriores

É necessária a seguinte adição (a negrito) à especificação dos tipos de função numa proposta anterior:

Um grupo de métodos tem um tipo natural se todos os métodos candidatos no grupo de métodos tiverem uma assinatura comum , incluindo valores padrão e modificadores params. (Se o grupo de métodos puder incluir métodos de extensão, os candidatos incluem o tipo que os contém e todos os âmbitos dos métodos de extensão.)

O tipo natural de uma expressão de função anônima ou grupo de método é um function_type. Um function_type representa uma assinatura de método: os tipos de parâmetro, valores padrão, tipos ref, modificadores paramse tipo de retorno e tipo ref. Expressões de função anónimas ou grupos de métodos com a mesma assinatura têm o mesmo function_type.

É necessária a seguinte adição (a negrito) à especificação dos tipos de delegados na proposta anterior:

O tipo de delegado para a função anônima ou grupo de métodos com tipos de parâmetro P1, ..., Pn e tipo de retorno R é:

  • se qualquer parâmetro ou valor de retorno não for por valor, ou qualquer parâmetro for opcional ou params, ou se houver mais de 16 parâmetros, ou qualquer um dos tipos de parâmetro ou retorno não forem argumentos de tipo válidos (digamos, (int* p) => { }), então o delegado é um tipo de delegado sintetizado internal anônimo com assinatura que corresponde à função anônima ou grupo de métodos, e com nomes de parâmetros arg1, ..., argn ou arg se um único parâmetro; [...]

Alterações no fichário

Sintetizando novos tipos de delegados

Assim como acontece com o comportamento de delegados com parâmetros ref ou out, os tipos de delegados são sintetizados para lambdas ou grupos de métodos definidos com parâmetros opcionais ou com parâmetros params. Observe que, nos exemplos abaixo, a notação a', b', etc. é usada para representar esses tipos de delegados anônimos.

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

Comportamento de conversão e unificação

Os delegados anônimos com parâmetros opcionais serão unificados quando o mesmo parâmetro (com base na posição) tiver o mesmo valor padrão, independentemente do nome do parâmetro.

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

Os delegados anônimos com uma matriz como o último parâmetro serão unificados quando o último parâmetro tiver o mesmo modificador de params e tipo de matriz, independentemente do nome do parâmetro.

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

Da mesma forma, é claro que há compatibilidade com delegados nomeados que já suportam parâmetros opcionais e params. Quando os valores padrão ou modificadores de params diferem em uma conversão, o de origem não será usado se estiver em uma expressão lambda, já que o lambda não pode ser chamado de outra maneira. Isso pode parecer contraintuitivo para os usuários, portanto, um aviso será emitido quando o valor padrão de origem ou params modificador estiver presente e diferente do de destino. Se a fonte for um grupo de métodos, ele pode ser chamado por conta própria, portanto, nenhum aviso será emitido.

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

Comportamento de tempo de execução da IL

Os valores dos parâmetros padrão serão emitidos para metadados. A IL para esta característica será de natureza muito semelhante à IL emitida para lambdas com parâmetros ref e out. Uma classe que herda de System.Delegate ou similar será gerada, e o método Invoke incluirá diretivas .param para definir valores de parâmetros padrão ou System.ParamArrayAttribute – assim como seria o caso de um delegado nomeado padrão com parâmetros opcionais ou params.

Esses tipos de delegados podem ser inspecionados em tempo de execução, normalmente. No código, os usuários podem introspeccionar o DefaultValue no ParameterInfo associado ao lambda ou grupo de método usando o MethodInfoassociado.

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

Perguntas abertas

Nenhuma delas foi implementada. Continuam a ser propostas em aberto.

Pergunta aberta: como isso interage com o atributo DefaultParameterValue existente?

Resposta proposta: Para paridade, permita o atributo DefaultParameterValue em lambdas e certifique-se de que o comportamento de geração de delegados corresponda aos valores de parâmetros padrão suportados por meio da sintaxe.

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

Pergunta em aberto: Em primeiro lugar, note-se que isto está fora do âmbito da atual proposta, mas que poderá valer a pena discuti-lo no futuro. Queremos oferecer suporte a padrões com parâmetros lambda digitados implicitamente? Ou seja,

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

Esta inferência leva a algumas questões de conversão complicadas que exigiriam mais discussão.

Existem também considerações sobre o desempenho da análise aqui. Por exemplo, hoje o termo (x = nunca poderia ser o início de uma expressão lambda. Se essa sintaxe fosse permitida para padrões lambda, o analisador precisaria de um lookahead mais amplo (varrendo até um token =>) para determinar se um termo é um lambda ou não.

Reuniões de design

  • LDM 2022-10-10: decisão de adicionar suporte para params de maneira semelhante aos valores padrão dos parâmetros.