Partilhar via


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

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 .

Estamos considerando um pequeno punhado de melhorias na correspondência de padrões para C# 9.0 que têm sinergia natural e funcionam bem para resolver vários problemas comuns de programação:

Padrões entre parênteses

Os padrões entre 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 em C# 8.0, no entanto, os novos combinadores de padrões 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 padrão:

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

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

Uma questão 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 ainda mais estendida para ser capaz de ligar um padrão constante (sintático) (por exemplo, uma expressão pontilhada) como um tipo, a fim de tratá-lo como um padrão de tipo ligado, a fim de suportar esta construção.

Após essa alteração, você seria capaz de 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 ao programador expressar que um valor de entrada deve satisfazer 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 suportam os operadores relacionais <, <=, >e >= em todos os tipos internos que suportam esses operadores relacionais binários com dois operandos do mesmo tipo em uma expressão. Especificamente, suportamos 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
    ;

A expressão é necessária para avaliar até um valor constante. É um erro se o valor constante for double.NaN ou float.NaN. É um erro se a expressão for uma constante nula.

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

Combinadores de Padrões

Os combinadores de padrão permitem corresponder a ambos de dois padrões diferentes usando and (isto pode ser estendido para qualquer número de padrões através do uso repetido de and), a qualquer um de dois padrões diferentes usando or (idem), ou a negação de um padrão usando com not.

Um uso comum de um combinador será a expressão idiomática

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, ligará mais estreitamente) do que or. O programador pode usar o padrão parênteses para tornar a precedência explícita:

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

Como todos os padrões, esses combinadores podem ser usados em qualquer contexto em que um padrão é esperado, incluindo padrões aninhados, a expressão é-padrão , a expressão de switch e o padrão do rótulo de um caso numa 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 §6.2.5 Ambiguidades gramaticais para permitir a desambiguação do < que inicia a lista de argumentos de tipo. Ver também https://github.com/dotnet/roslyn/issues/47614.

Problemas em aberto com alterações propostas

Sintaxe para operadores relacionais

Are and, ore not algum tipo de palavra-chave contextual? Em caso afirmativo, existe uma mudança disruptiva (por exemplo, quando comparado à sua utilização como designador num padrão de declaração ).

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

Esperamos suportar 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 tão primitivo, para 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 de 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 suportar 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.

O fluxo das informações de tipo da esquerda para a direita do and

Foi sugerido que, ao escrever um combinador and, as informações de tipo aprendidas à esquerda sobre o tipo de nível superior 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 é restringido pelos requisitos de estreitamento do tipo à esquerda do and. Definiríamos a semântica de estreitamento de tipo para todos os padrões da seguinte forma. O tipo estreito de um padrão P é definido da seguinte forma:

  1. Se for um padrão de tipo, o tipo reduzido de é o tipo do padrão de tipo.
  2. Se P for um padrão de declaração, o tipo restrito é o tipo associado ao padrão de declaração.
  3. Se P é um padrão recursivo que dá um tipo explícito, o tipo estreito é esse tipo.
  4. Se P for associado através das regras paraITuple, o tipo mais especializado é 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 tem conversão de expressão constante para o tipo de entrada , o tipo estreito é o tipo da constante.
  6. Se for um padrão relacional em que a expressão constante não possui a conversão da expressão constante para o tipo de entrada , o tipo limitado é o tipo da constante.
  7. Se P é um padrão or, o tipo estreito é o tipo comum do tipo estreito dos subpadrões, se tal tipo comum existir. Para este propósito, o algoritmo de tipo comum considera apenas identidade, encaixotamento e conversões de referência implícitas, e considera todos os subpadrões numa sequência de padrões or (ignorando padrões entre parênteses).
  8. Se P é um padrão and, o tipo reduzido é o tipo reduzido do padrão à direita. Além disso, a de tipo restrita do padrão esquerdo é o tipo de entrada do padrão direito.
  9. Caso contrário, o tipo restringido do de é o tipo de entrada de .

Resultado: A semântica de estreitamento acima foi implementada.

Definições de variáveis e atribuição definida

A adição de padrões or e not cria alguns novos problemas interessantes em torno de variáveis de padrão e atribuição definida. Como as variáveis normalmente podem ser declaradas no máximo uma vez, parece que qualquer variável de padrão declarada em um lado de um padrão or não seria definitivamente atribuída quando o padrão corresponder. Da mesma forma, não se espera que uma variável declarada dentro de um padrão not 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, isto pode ser demasiado restritivo. Há outras abordagens a considerar.

Um cenário que vale a pena considerar é este

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

Isto não funciona atualmente porque, para uma expressão de padrão, as variáveis de padrão são consideradas definitivamente atribuídas apenas quando a expressão de padrão é verdadeira ("definitivamente atribuídas quando for verdadeira").

Apoiar isso seria mais simples (do ponto de vista do programador) do que adicionar suporte para uma instrução if com condição negada. Mesmo se adicionarmos esse suporte, os programadores se perguntariam por que o trecho acima não funciona. Por outro lado, o mesmo cenário em um switch faz menos sentido, pois não há nenhum ponto correspondente no programa onde definitivamente atribuído quando falso seria significativo. Permitiríamos isto num is-pattern-expression mas não noutros contextos onde padrões são permitidos? Isso parece irregular.

Relacionado com isto está o problema da atribuição definida num padrão disjuntivo .

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

Só expectamos que o i seja 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 i ser declarada 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 toma seu valor do outro elemento da tupla quando um elemento zero é encontrado.

Também foi sugerido permitir que as variáveis sejam definidas múltiplas vezes em cada caso 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 definições múltiplas são permitidas e em que condições tal variável é considerada definitivamente atribuída.

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

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

Então, teríamos tempo para desenvolver alguma experiência que ofereça uma compreensão sobre o possível benefício de relaxar essa regra mais tarde.

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

Diagnóstico, subsunção e exaustividade

Estas novas formas de padrão introduzem muitas novas oportunidades para o erro diagnosticável do programador. Teremos de decidir que tipos de erros iremos diagnosticar e como fazê-lo. Eis alguns exemplos:

case >= 0 and <= 100D:

Este caso nunca pode coincidir (porque a entrada não pode ser simultaneamente um int e um double). Já temos um erro quando detetamos um caso que nunca pode corresponder, mas sua redação ("O caso do switch já foi tratado por um caso anterior" e "O padrão já foi manipulado por um braço anterior da expressão do switch") pode ser enganosa em novos cenários. Podemos ter que 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:

Este caso é possível de combinar, mas o or 1 no final não acrescenta significado ao padrão. Sugiro que devemos procurar produzir um erro sempre que alguma conjunção ou disjunção de um padrão composto não definir uma variável de padrão ou não 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 nada acrescenta ao segundo caso, uma vez que esses valores teriam sido tratados pelo primeiro caso. Também isto merece um erro.

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

Uma expressão de comutador como esta deve ser considerada exaustiva (lida com todos os valores de entrada possíveis).

Em C# 8.0, uma expressão de switch com uma entrada do tipo byte só é considerada exaustiva se contiver um braço final cujo padrão corresponda a tudo (um padrão de descarte ou um padrão variável ). Mesmo uma expressão de switch que tem um braço para cada valor de byte distinto não é considerada exaustiva em C# 8. A fim de lidar adequadamente com a exaustividade dos padrões relacionais, teremos que lidar com este caso também. Esta será tecnicamente uma mudança de rutura, mas, provavelmente, nenhum utilizador notará.