Compartilhar via


Alterações de correspondência de padrões para C# 9.0

Nota

Este artigo é uma especificação de recurso. A especificação serve como o documento de design para o recurso. Ele inclui alterações de especificação propostas, juntamente com as informações necessárias durante o design e o desenvolvimento do recurso. Esses artigos são publicados até que as alterações de especificação 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 reunião de design de idioma (LDM).

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

Estamos considerando alguns aprimoramentos na correspondência de padrões para C# 9.0 que têm sinergia natural e funcionam bem para resolver uma série de problemas comuns de programação:

Padrões parênteses

Padrões parênteses permitem que o programador coloque parênteses em torno de qualquer padrão. Isso não é tão útil com os padrões existentes no C# 8.0, no entanto, os novos combinadores de padrão introduzem uma precedência que o programador pode querer substituir.

primary_pattern
    : parenthesized_pattern
    | // all of the existing forms
    ;
parenthesized_pattern
    : '(' pattern ')'
    ;

Padrões de tipo

Permitimos um tipo como um padrão:

primary_pattern
    : type-pattern
    | // all of the existing forms
    ;
type_pattern
    : type
    ;

Isso retrai a expressão is-type existente para ser uma expressão is-pattern na qual o padrão é um padrão de tipo, embora não alteremos a árvore de sintaxe produzida pelo compilador.

Um problema de implementação sutil é que essa gramática é ambígua. Uma cadeia de caracteres como a.b pode ser analisada como um nome qualificado (em um contexto de tipo) ou uma expressão pontilhada (em um contexto de expressão). O compilador já é capaz de tratar um nome qualificado da mesma forma que uma expressão pontilhada para lidar com algo como e is Color.Red. A análise semântica do compilador seria estendida ainda mais para ser capaz de associar um padrão de constante (sintática) (por exemplo, uma expressão com ponto) como um tipo, a fim de tratá-lo como um padrão de tipo associado para suportar esse constructo.

Após essa alteração, você poderá escrever

void M(object o1, object o2)
{
    var t = (o1, o2);
    if (t is (int, string)) {} // test if o1 is an int and o2 is a string
    switch (o1) {
        case int: break; // test if o1 is an int
        case System.String: break; // test if o1 is a string
    }
}

Padrões relacionais

Os padrões relacionais permitem que o programador expresse que um valor de entrada deve atender a uma restrição relacional quando comparado a um valor constante:

    public static LifeStage LifeStageAtAge(int age) => age switch
    {
        < 0 =>  LifeStage.Prenatal,
        < 2 =>  LifeStage.Infant,
        < 4 =>  LifeStage.Toddler,
        < 6 =>  LifeStage.EarlyChild,
        < 12 => LifeStage.MiddleChild,
        < 20 => LifeStage.Adolescent,
        < 40 => LifeStage.EarlyAdult,
        < 65 => LifeStage.MiddleAdult,
        _ =>    LifeStage.LateAdult,
    };

Os padrões relacionais dão suporte aos operadores relacionais <, <=, >e >= em todos os tipos internos que dão suporte a esses operadores relacionais binários com dois operandos do mesmo tipo em uma expressão. Especificamente, oferecemos suporte a todos esses padrões relacionais para sbyte, byte, short, ushort, int, uint, long, ulong, char, float, double, decimal, ninte nuint.

primary_pattern
    : relational_pattern
    ;
relational_pattern
    : '<' relational_expression
    | '<=' relational_expression
    | '>' relational_expression
    | '>=' relational_expression
    ;

É necessário que a expressão seja avaliada como um valor constante. É um erro se esse valor constante for double.NaN ou float.NaN. Será um erro se a expressão for uma constante nula.

Quando a entrada é um tipo para o qual está definido um operador relacional binário embutido adequado que é aplicável com a entrada como seu operando esquerdo e a constante fornecida como seu operando direito, a avaliação desse operador é considerada como o significado do padrão relacional. Caso contrário, converteremos a entrada para o tipo da expressão usando uma conversão anulável ou de unboxing explícita. É um erro de tempo de compilação se não houver essa conversão. O padrão é considerado não correspondente se a conversão falhar. Se a conversão for bem-sucedida, o resultado da operação de correspondência de padrões será o resultado da avaliação da expressão e OP v em que e é a entrada convertida, OP é o operador relacional e v é a expressão constante.

Combinadores de Padrões

Combinadores de padrão permitem a correspondência de dois padrões diferentes usando and (isso pode ser estendido para qualquer número de padrões pelo uso repetido de and), de qualquer um dos dois padrões diferentes usando or (idem), ou a negação de um padrão usando not.

Um uso comum de um combinador será o idioma

if (e is not null) ...

Mais legível do que o idioma atual e is object, esse padrão expressa claramente que se está verificando um valor não nulo.

Os combinadores and e or serão úteis para testar intervalos de valores

bool IsLetter(char c) => c is >= 'a' and <= 'z' or >= 'A' and <= 'Z';

Este exemplo ilustra que and terá uma prioridade de análise mais alta (ou seja, será associada mais de perto) do que or. O programador pode usar o padrão parêntese para tornar a precedência explícita:

bool IsLetter(char c) => c is (>= 'a' and <= 'z') or (>= 'A' and <= 'Z');

Assim como todos os padrões, esses combinadores podem ser usados em qualquer contexto em que um padrão seja esperado, incluindo padrões aninhados, a expressão is-pattern, a expressão switch e o padrão do rótulo de caso de uma instrução switch.

pattern
    : disjunctive_pattern
    ;
disjunctive_pattern
    : disjunctive_pattern 'or' conjunctive_pattern
    | conjunctive_pattern
    ;
conjunctive_pattern
    : conjunctive_pattern 'and' negated_pattern
    | negated_pattern
    ;
negated_pattern
    : 'not' negated_pattern
    | primary_pattern
    ;
primary_pattern
    : // all of the patterns forms previously defined
    ;

Alterar para 6.2.5 Ambiguidades gramaticais

Devido à introdução do padrão de tipo , é possível que um tipo genérico apareça antes do token =>. Portanto, adicionamos => ao conjunto de tokens listados em Ambiguidades gramaticais §6.2.5 para permitir a desambiguação do < que inicia a lista de argumentos de tipo. Consulte também https://github.com/dotnet/roslyn/issues/47614.

Problemas abertos com alterações propostas

Sintaxe para operadores relacionais

and, ore not algum tipo de palavra-chave contextual? Nesse caso, há uma alteração significativa (por exemplo, em comparação com seu uso como designador em um padrão de declaração).

Semântica (por exemplo, tipo) para operadores relacionais

Esperamos dar suporte a todos os tipos primitivos que podem ser comparados em uma expressão usando um operador relacional. O significado em casos simples é claro

bool IsValidPercentage(int x) => x is >= 0 and <= 100;

Mas quando a entrada não é um tipo primitivo, em que tipo tentamos convertê-la?

bool IsValidPercentage(object x) => x is >= 0 and <= 100;

Propusemos que, quando o tipo de entrada já é um primitivo comparável, esse é o tipo da comparação. No entanto, quando a entrada não é uma primitiva comparável, tratamos o relacional como incluindo um teste de tipo implícito para o tipo da constante no lado direito do relacional. Se o programador pretende dar suporte a mais de um tipo de entrada, isso deve ser feito explicitamente:

bool IsValidPercentage(object x) => x is
    >= 0 and <= 100 or    // integer tests
    >= 0F and <= 100F or  // float tests
    >= 0D and <= 100D;    // double tests

Resultado: o relacional inclui um teste de tipo implícito para o tipo da constante no lado direito do relacional.

Fluxo de informações de tipo da esquerda para a direita de and

Foi sugerido que, ao escrever um combinador and, as informações de tipo aprendidas à esquerda sobre o tipo de mais alto nível possam fluir para a direita. Por exemplo

bool isSmallByte(object o) => o is byte and < 100;

Aqui, o tipo de entrada para o segundo padrão é reduzido pelo requisitos de redução de tipo à esquerda do and. Definiríamos a semântica de restrição de tipo para todos os padrões da seguinte maneira. O tipo restrito de um padrão P é definido da seguinte maneira:

  1. Se P é um padrão de tipo, o tipo reduzido é o tipo do tipo do padrão de tipo.
  2. Se P é um padrão de declaração, o tipo reduzido é o tipo do tipo do padrão de declaração.
  3. Se P for um padrão recursivo que fornece um tipo explícito, o tipo restrito é esse tipo.
  4. Se P é combinado por meio das regras paraITuple, o tipo reduzido é o tipo System.Runtime.CompilerServices.ITuple.
  5. Se P é um padrão constante onde a constante não é a constante nula e onde a expressão não possui uma conversão de expressão constante para o tipo de entrada , o tipo restrito é o tipo da constante.
  6. Se P é um padrão relacional em que a expressão constante não possui uma conversão de expressão constante para o tipo de entrada, o tipo restrito é o tipo da constante.
  7. Se P é um or padrão, o tipo reduzido é o tipo comum de tipo reduzido dos subpadrões se esse tipo comum existir. Para essa finalidade, o algoritmo de tipo comum considera apenas as conversões de identidade, boxing e referência implícita, e considera todos os subpadrões de uma sequência de padrões or (ignorar padrões entre parênteses).
  8. Se P é um and padrão, o tipo restrito é o tipo restrito do padrão direito. Além disso, o tipo restrito do padrão esquerdo é o tipo de entrada do padrão direito.
  9. Caso contrário, o tipo restrito de P é o tipo de entrada de P.

Resultado: a semântica de restrição acima foi implementada.

Definições de variável e atribuição definitiva

A adição de padrões de or e not cria alguns problemas interessantes em torno de variáveis de padrão e atribuição definitiva. Como as variáveis normalmente podem ser declaradas no máximo uma vez, parece que qualquer variável padrão declarada em um lado de um padrão or não seria definitivamente atribuída quando o padrão correspondesse. Da mesma forma, uma variável declarada dentro de um padrão not não se espera que seja definitivamente atribuída quando o padrão corresponder. A maneira mais simples de resolver isso é proibir a declaração de variáveis de padrão nesses contextos. No entanto, isso pode ser muito restritivo. Há outras abordagens a serem consideradas.

Um cenário que vale a pena considerar é este

if (e is not int i) return;
M(i); // is i definitely assigned here?

Isso não funciona hoje porque, para uma expressão is-pattern, as variáveis padrão são consideradas definitivamente atribuídas somente onde a expressão is-pattern é verdadeira ("definitivamente atribuída quando verdadeira").

Dar suporte a isso seria mais simples (da perspectiva do programador) do que também adicionar suporte para uma declaração de condição negada if. Mesmo que adicionemos esse suporte, os programadores se perguntariam por que o snippet acima não funciona. Por outro lado, o mesmo cenário em uma switch faz menos sentido, pois não há nenhum ponto correspondente no programa onde definitivamente atribuída quando falsa seria significativo. Permitiríamos isso em uma expressão is-pattern, mas não em outros contextos em que os padrões são permitidos? Isso parece irregular.

Relacionado a isso está o problema da atribuição definida em um padrão disjuntivo.

if (e is 0 or int i)
{
    M(i); // is i definitely assigned here?
}

Só esperaríamos que i fosse definitivamente atribuído quando a entrada não for zero. Mas como não sabemos se a entrada é zero ou não dentro do bloco, i não é definitivamente atribuído. No entanto, e se permitirmos que i seja declarado em diferentes padrões mutuamente exclusivos?

if ((e1, e2) is (0, int i) or (int i, 0))
{
    M(i);
}

Aqui, a variável i é definitivamente atribuída dentro do bloco e usa o valor do outro elemento da tupla quando um elemento zero é encontrado.

Também foi sugerido permitir que variáveis sejam definidas múltiplas vezes em todos os casos de um bloco de casos.

    case (0, int x):
    case (int x, 0):
        Console.WriteLine(x);

Para fazer qualquer um desses trabalhos, teríamos que definir cuidadosamente onde essas várias definições são permitidas e sob quais condições essa variável é considerada definitivamente atribuída.

Se optarmos por adiar esse trabalho até mais tarde (o que eu aconselho), poderíamos dizer em C# 9

  • abaixo de um not ou or, as variáveis padrão podem não ser declaradas.

Então, teríamos tempo para desenvolver alguma experiência que fornecesse insights sobre o possível valor de relaxar isso mais tarde.

Resultado: variáveis de padrão não podem ser declaradas abaixo de um padrão not ou or.

Diagnósticos, subsunção e exaustividade

Essas novas formas de padrão introduzem muitas novas oportunidades de erros diagnosticáveis de programador. Precisaremos decidir quais tipos de erros diagnosticaremos e como fazer isso. Aqui estão alguns exemplos:

case >= 0 and <= 100D:

Esse caso nunca pode corresponder (porque a entrada não pode ser tanto um int e um double). Já temos um erro quando detectamos um caso que nunca pode corresponder, mas seu texto ("O caso de troca já foi tratado por um caso anterior" e "O padrão já foi tratado por um ramo anterior da expressão switch") pode ser enganoso em novos cenários. Talvez seja necessário modificar a redação para apenas dizer que o padrão nunca corresponderá à entrada.

case 1 and 2:

Da mesma forma, isso seria um erro porque um valor não pode ser 1 e 2.

case 1 or 2 or 3 or 1:

É possível encontrar uma correspondência para este caso, porém, o or 1 no final não acrescenta significado ao padrão. Sugiro que nosso objetivo seja produzir um erro sempre que algum conjunto ou disjunção de um padrão composto não definir uma variável de padrão ou afetar o conjunto de valores correspondentes.

case < 2: break;
case 0 or 1 or 2 or 3 or 4 or 5: break;

Aqui, 0 or 1 or não adiciona nada ao segundo caso, pois esses valores teriam sido tratados pelo primeiro caso. Isso também merece ser considerado um erro.

byte b = ...;
int x = b switch { <100 => 0, 100 => 1, 101 => 2, >101 => 3 };

Uma expressão de comutador como essa deve ser considerada exaustiva (ela manipula todos os valores de entrada possíveis).

No C# 8.0, uma expressão de switch com uma entrada do tipo byte só será considerada exaustiva se contiver um ramo final cujo padrão que corresponde a tudo (um padrão de descarte ou Padrão var). Até mesmo uma expressão de comutador que tem um braço para cada valor byte distinto não é considerada exaustiva em C# 8. Para lidar adequadamente com a exaustão de padrões relacionais, também teremos que lidar com esse caso. Tecnicamente, isso será uma alteração significativa, mas é provável que nenhum usuário observe.