Compartilhar via


Análise de atribuição definitiva melhorada

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 reunião de design de linguagem (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

A atribuição definitiva §9.4, conforme especificado, tem algumas lacunas que causaram incómodo aos utilizadores. Em particular, cenários envolvendo comparação com constantes booleanas, acesso condicional e coalescência nula.

Discussão de Csharplang desta proposta: https://github.com/dotnet/csharplang/discussions/4240

Provavelmente uma dúzia de relatórios de usuários podem ser encontrados através desta ou de consultas semelhantes (ou seja, procure por "atribuição definida" em vez de "CS0165", ou pesquise em csharplang). https://github.com/dotnet/roslyn/issues?q=is%3Aclosed+is%3Aissue+label%3A%22Resolution-By+Design%22+cs0165

Incluí questões relacionadas nos cenários abaixo para dar uma noção do impacto relativo de cada cenário.

Cenários

Como ponto de referência, vamos começar com um conhecido "caso feliz" que funciona em atribuição definida e em anulável.

#nullable enable

C c = new C();
if (c != null && c.M(out object obj0))
{
    obj0.ToString(); // ok
}

public class C
{
    public bool M(out object obj)
    {
        obj = new object();
        return true;
    }
}

Comparação com a constante de bool

if ((c != null && c.M(out object obj1)) == true)
{
    obj1.ToString(); // undesired error
}

if ((c != null && c.M(out object obj2)) is true)
{
    obj2.ToString(); // undesired error
}

Comparação entre um acesso condicional e um valor constante

Este cenário é provavelmente o maior. Damos suporte a isso em casos anuláveis, mas não em atribuições definitivas.

if (c?.M(out object obj3) == true)
{
    obj3.ToString(); // undesired error
}

Acesso condicional coalesceu em uma constante de booleano

Este cenário é muito semelhante ao anterior. Isso também é suportado em atribuição anulável, mas não em atribuição definitiva.

if (c?.M(out object obj4) ?? false)
{
    obj4.ToString(); // undesired error
}

Expressões condicionais em que um braço é uma constante booleana

Vale ressaltar que já temos um comportamento especial para quando a expressão da condição é constante (ou seja, true ? a : b). Apenas visitamos incondicionalmente o braço indicado pela condição constante e ignoramos o outro braço.

Observe também que não lidamos com esse cenário em anulável.

if (c != null ? c.M(out object obj4) : false)
{
    obj4.ToString(); // undesired error
}

Especificação

?. Expressões de operador condicional nulo

Introduzimos uma nova secção para expressões com o operador condicional nulo ?.. Consulte a especificação do operador condicional nulo (§12.8.8) e Regras precisas para determinar a atribuição definida §9.4.4 para o contexto.

Como nas regras de atribuição definidas ligadas acima, referimo-nos a uma dada variável inicialmente não atribuída como v.

Introduzimos o conceito de "contém de forma direta". Diz-se que uma expressão E "contém diretamente" uma subexpressão E1 se não estiver sujeita a uma conversão definida pelo utilizador §10.5 cujo parâmetro não seja de um tipo de valor não anulável, e uma das seguintes condições se mantenha:

  • E é E1. Por exemplo, a?.b() contém diretamente a expressão a?.b().
  • Se E é uma expressão em parêntesis (E2), e E2 contiver diretamente E1.
  • Se E for uma expressão de operador de perdão nulo E2!, e E2 contiver diretamente E1.
  • Se E for uma expressão cast (T)E2, e o cast não sujeitar E2 a uma conversão não levantada definida pelo usuário cujo parâmetro não seja de um tipo de valor não anulável, e E2 contenha diretamente E1.

Para uma expressão E da forma primary_expression null_conditional_operations, deixe E0 ser a expressão obtida removendo textualmente o ponto de interrogação inicial. de cada um dos null_conditional_operations de E que tenham um, como na especificação vinculada acima.

Nas seções subsequentes, nos referiremos a E0 como a contraparte não condicional à expressão condicional nula. Observe que algumas expressões nas seções subsequentes estão sujeitas a regras adicionais que só se aplicam quando um dos operandos contém diretamente uma expressão condicional nula.

  • O estado de atribuição definida de v em qualquer ponto dentro E é o mesmo que o estado de atribuição definida no ponto correspondente dentro E0.
  • O estado de atribuição definida de v após E é o mesmo que o estado de atribuição definida de v após expressão_primária.

Comentários

Usamos o conceito de "contém diretamente" para nos permitir pular expressões relativamente simples de "wrapper" ao analisar acessos condicionais que são comparados a outros valores. Por exemplo, espera-se que ((a?.b(out x))!) == true resulte no mesmo estado de fluxo que a?.b == true em geral.

Também queremos permitir que a análise funcione na presença de uma série de conversões possíveis em um acesso condicional. No entanto, a propagação de "estado quando não nulo" não é possível quando a conversão é definida pelo usuário, uma vez que não podemos contar com conversões definidas pelo usuário para honrar a restrição de que a saída não é nula somente se a entrada não for nula. A única exceção é quando a entrada da conversão definida pelo usuário é um tipo de valor não anulável. Por exemplo:

public struct S1 { }
public struct S2 { public static implicit operator S2?(S1 s1) => null; }

Isso também inclui conversões suspensas como as seguintes:

string x;

S1? s1 = null;
_ = s1?.M1(x = "a") ?? s1.Value.M2(x = "a");

x.ToString(); // ok

public struct S1
{
    public S1 M1(object obj) => this;
    public S2 M2(object obj) => new S2();
}
public struct S2
{
    public static implicit operator S2(S1 s1) => default;
}

Quando consideramos se uma variável é atribuída em um determinado ponto dentro de uma expressão condicional nula, simplesmente assumimos que quaisquer operações condicionais nulas anteriores dentro da mesma expressão condicional nula foram bem-sucedidas.

Por exemplo, dada uma expressão condicional a?.b(out x)?.c(x), a contrapartida não condicional é a.b(out x).c(x). Se quisermos saber o estado de atribuição definido de x antes de ?.c(x), por exemplo, então realizamos uma análise "hipotética" de a.b(out x) e usamos o estado resultante como uma entrada para ?.c(x).

Expressões constantes booleanas

Introduzimos uma nova seção "Expressões constantes booleanas":

Para uma expressão expr onde expr é uma expressão constante com um valor bool:

  • O estado de atribuição definitiva de v após a expressão é determinado por:
    • Se expr é uma expressão constante com valor verdadeiro, e o estado de v antes de expr "não é definitivamente atribuído", então o estado de v após expr é "definitivamente atribuído quando falso".
    • Se expr é uma expressão constante com valor falso, e o estado de v antes de expr é "não definitivamente atribuído", então o estado de v após expr é "definitivamente atribuído quando verdadeiro".

Comentários

Assumimos que se uma expressão tem um valor constante bool false, por exemplo, é impossível alcançar qualquer ramo que exija que a expressão retorne true. Portanto, as variáveis são assumidas como definitivamente atribuídas em tais ramos. Isso acaba combinando bem com as mudanças de especificação para expressões como ?? e ?: e permitindo muitos cenários úteis.

Também vale a pena notar que nunca esperamos estar em um estado condicional antes de visitar uma expressão constante. É por isso que não contabilizamos cenários como "expr é uma expressão constante com valor verdadeiro, e o estado de v antes de expr é "definitivamente atribuído quando verdadeiro".

?? (expressões de coalescência nulas) aumentam

Ampliamos a secção §9.4.4.29 da seguinte forma:

Para uma expressão expr do formulário expr_first ?? expr_second:

  • ...
  • O estado de atribuição definido de v após a expressão é determinado por:
    • ...
    • Se expr_first contiver diretamente uma expressão condicional nula E, e v for definitivamente atribuído após a contraparte não condicional E0, então o estado de atribuição definida de v após expr é o mesmo que o estado de atribuição definida de v após expr_second.

Comentários

A regra acima formaliza que, para uma expressão como a?.M(out x) ?? (x = false), ou o a?.M(out x) foi totalmente avaliado e produziu um valor não nulo, caso em que x foi atribuído, ou o x = false foi avaliado, caso em que x também foi atribuído. Portanto, x é sempre atribuído após esta expressão.

Isso também lida com o cenário dict?.TryGetValue(key, out var value) ?? false, observando que v é definitivamente atribuído após dict.TryGetValue(key, out var value), e v é "definitivamente atribuído quando verdadeiro" após false, e concluindo que v deve ser "definitivamente atribuído quando verdadeiro".

A formulação mais geral também nos permite lidar com alguns cenários mais inusitados, tais como:

  • if (x?.M(out y) ?? (b && z.M(out y))) y.ToString();
  • if (x?.M(out y) ?? z?.M(out y) ?? false) y.ToString();

?: expressões (condicionais)

Aumentamos a secção §9.4.4.30 seguinte forma:

Para uma expressão expr do formulário expr_cond ? expr_true : expr_false:

  • ...
  • O estado de atribuição definido de v após de expiração é determinado por:
    • ...
    • Se o estado de v após expr_true é "definitivamente atribuído quando verdadeiro", e o estado de v após expr_false é "definitivamente atribuído quando verdadeiro", então o estado de v após expr é "definitivamente atribuído quando verdadeiro".
    • Se o estado de v após expr_true é "definitivamente atribuído quando falso", e o estado de v após expr_false é "definitivamente atribuído quando falso", então o estado de v após expr é "definitivamente atribuído quando falso".

Comentários

Isso faz com que, quando ambos os braços de uma expressão condicional resultem em um estado condicional, juntemos os estados condicionais correspondentes e o propagamos para fora em vez de desdividir o estado e permitir que o estado final seja não condicional. Isso permite cenários como os seguintes:

bool b = true;
object x = null;
int y;
if (b ? x != null && Set(out y) : x != null && Set(out y))
{
  y.ToString();
}

bool Set(out int x) { x = 0; return true; }

Este é um cenário reconhecidamente de nicho, que compila sem erro no compilador nativo, mas foi alterado no Roslyn para corresponder à especificação naquela altura.

Expressões ==/!= (operador de igualdade relacional)

Introduzimos uma nova seção ==/!= (operador de igualdade relacional) expressões.

As regras gerais para expressões com expressões incorporadas §9.4.4.23 aplicam-se, exceto para os cenários descritos abaixo.

Para uma expressão expr da forma expr_first == expr_second, em que == é um operador de comparação predefinido (§12.12) ou um operador levantado (§12.4.8), o estado de atribuição definido de v após expr é determinado por:

  • Se expr_first contém diretamente uma expressão nula condicional E e expr_second é uma expressão constante com valor null, e o estado de v após a contraparte não condicional E0 é "definitivamente atribuído", então o estado de v após a expressão é "definitivamente atribuído quando falso".
  • Se expr_first contém diretamente uma expressão condicional nula E e expr_second é uma expressão de um tipo de valor não anulável, ou uma expressão constante com um valor não nulo, e o estado de v após a contraparte não condicional E0 é "definitivamente atribuído", então o estado de v após expr é "definitivamente atribuído quando verdadeiro".
  • Se expr_first é do tipo booleano , e expr_second é uma expressão constante com valor verdadeiro , então o estado de atribuição definida após expr é o mesmo que o estado de atribuição definida após expr_first.
  • Se expr_first é do tipo booleano , e expr_second é uma expressão constante com valor falso, então o estado de atribuição definida após expr é o mesmo que o estado de atribuição definida de v após a expressão de negação lógica !expr_first.

Para uma expressão expr da forma expr_first != expr_second, em que != é um operador de comparação predefinido (§12.12) ou um operador levantado (§12.4.8)), o estado de atribuição definido de v após expr é determinado por:

  • Se expr_first contém diretamente uma expressão condicional com valor nulo E e expr_second é uma expressão constante com valor nulo, e o estado de v após a contraparte não condicional E0 é "definitivamente atribuído", então o estado de v após expr é "definitivamente atribuído quando verdadeiro".
  • Se expr_first contém diretamente uma expressão condicional nula E e expr_second é uma expressão de um tipo de valor não anulável, ou uma expressão constante com um valor não nulo, e o estado de v após a contraparte não condicional E0 é "definitivamente atribuído", então o estado de v após expr é "definitivamente atribuído quando falso".
  • Se expr_first é do tipo booleano , e expr_second é uma expressão constante com valor verdadeiro , então o estado de atribuição definida após expr é o mesmo que o estado de atribuição definida de v após a expressão de negação lógica !expr_first.
  • Se expr_first é do tipo booleano , e expr_second é uma expressão constante com valor falso, então o estado de atribuição definida após expr é o mesmo que o estado de atribuição definida após expr_first.

Todas as regras acima nesta seção são comutativas, o que significa que, se uma regra se aplica quando avaliada na forma expr_second op expr_first, ela também se aplica na forma expr_first op expr_second.

Comentários

A ideia geral expressa por estas regras é:

  • Se um acesso condicional é comparado com null, então sabemos que as operações definitivamente ocorreram se o resultado da comparação for false
  • Se um acesso condicional é comparado a um tipo de valor não anulável ou a uma constante não nula, então sabemos que as operações definitivamente ocorreram se o resultado da comparação for true.
  • Como não podemos confiar em operadores definidos pelo usuário para fornecer respostas confiáveis no que diz respeito à segurança de inicialização, as novas regras só se aplicam quando um operador de ==/!= predefinido está em uso.

Eventualmente, podemos querer refinar essas regras para percorrer o estado condicional que está presente no final de um acesso ou chamada de membro. Tais cenários realmente não acontecem em atribuição definida, mas acontecem em anulável na presença de [NotNullWhen(true)] e atributos semelhantes. Isso exigiria um tratamento especial para constantes bool, além de um tratamento apenas para constantes null/non-null.

Algumas consequências destas regras:

  • if (a?.b(out var x) == true)) x() else x(); erro no ramo 'else'
  • if (a?.b(out var x) == 42)) x() else x(); irá apresentar erro no ramo 'else'
  • if (a?.b(out var x) == false)) x() else x(); terá um erro no ramo 'else'
  • if (a?.b(out var x) == null)) x() else x(); irá ter um erro no ramo 'então'
  • if (a?.b(out var x) != true)) x() else x(); vai dar erro no ramo 'então'
  • if (a?.b(out var x) != 42)) x() else x(); dará erro no ramo 'então'
  • if (a?.b(out var x) != false)) x() else x(); irá falhar no bloco 'then'
  • if (a?.b(out var x) != null)) x() else x(); provocará erro no ramo 'else'

is operador e is expressões de padrão

Apresentamos uma nova seção operadoris e expressões de padrão is.

Para uma expressão expr do formulário E is T, onde T é qualquer tipo ou padrão

  • O estado de atribuição definida de v antes de E é o mesmo que o estado de atribuição definida de v antes de expr.
  • O estado de atribuição definido de v após a expressão é determinado por:
    • Se E contém diretamente uma expressão condicional nula, e o estado de v após a contraparte não condicional E0 é "definitivamente atribuído", e T é qualquer tipo ou um padrão que não corresponde a uma entrada null, então o estado de v após expr é "definitivamente atribuído quando verdadeiro".
    • Se E contém diretamente uma expressão condicional nula, e o estado de v após a contraparte não condicional E0 é "definitivamente atribuído", e T é um padrão que corresponde a uma entrada null, então o estado de v após expr é "definitivamente atribuído quando falso".
    • Se E é do tipo booleano e T é um padrão que só corresponde a uma entrada true, então o estado de atribuição definida de v após expr é o mesmo que o estado de atribuição definida de v depois de E.
    • Se E é do tipo booleano e T é um padrão que só corresponde a uma entrada false, então o estado de atribuição definida de v após expr é o mesmo que o estado de atribuição definida de v após a expressão de negação lógica !expr.
    • Caso contrário, se o estado de atribuição definida de v depois de E é "definitivamente atribuído", então o estado de atribuição definida de v após expr é "definitivamente atribuído".

Comentários

Esta secção destina-se a abordar cenários semelhantes aos da secção ==/!= acima. Esta especificação não aborda padrões recursivos, por exemplo, (a?.b(out x), c?.d(out y)) is (object, object). Esse apoio pode chegar mais tarde, se o tempo o permitir.

Cenários adicionais

Atualmente, esta especificação não aborda cenários que envolvem expressões de desvio de padrão e declarações de desvio. Por exemplo:

_ = c?.M(out object obj4) switch
{
    not null => obj4.ToString() // undesired error
};

Parece que o apoio a isso pode vir mais tarde, se o tempo permitir.

Têm havido várias categorias de bugs arquivados para tipos anuláveis que exigem essencialmente que aumentemos a sofisticação na análise de padrões. É provável que qualquer decisão que façamos que melhore a atribuição definitiva também seja transitada para anulável.

https://github.com/dotnet/roslyn/issues/49353
https://github.com/dotnet/roslyn/issues/46819
https://github.com/dotnet/roslyn/issues/44127

Desvantagens

Parece estranho que a análise desça e tenha um reconhecimento especial de acessos condicionais, quando normalmente o estado de análise de fluxo deveria propagar-se para cima. Estamos preocupados sobre como uma solução como essa poderia se cruzar dolorosamente com possíveis recursos futuros de linguagem que fazem verificações nulas.

Alternativas

Duas alternativas a esta proposta:

  1. Introduza os termos "estado quando nulo" e "estado quando não nulo" na linguagem e no compilador. Isso foi considerado um esforço excessivo para os cenários que estamos a tentar resolver, mas poderíamos implementar a proposta acima e, posteriormente, adotar um modelo de "estado de nulo/não nulo" sem causar problemas aos utilizadores.
  2. Não faça nada.

Questões por resolver

Há impactos nas expressões de switch que devem ser especificados: https://github.com/dotnet/csharplang/discussions/4240#discussioncomment-343395

Reuniões de design

https://github.com/dotnet/csharplang/discussions/4243