Tipos de referência anuláveis

Tip

Novo no desenvolvimento de software? Começa pelos tutoriais para começar .

Experiente noutra língua? Se trabalhou com os tipos anuláveis de Kotlin, o strictNullChecks do TypeScript ou os opcionais de Swift, este modelo ser-lhe-á familiar. C# utiliza análise estática e diagnósticos de aviso em vez de um tipo separado. Consulte rapidamente Expresse a intenção com anotações e Análise do estado nulo e, em seguida, avance para o Tutorial: Expresse a sua intenção de conceção com tipos de referência anuláveis e não anuláveis para aplicar esta funcionalidade.

Os tipos de referência anuláveis são um conjunto de funcionalidades que minimizam a probabilidade de o seu código causar System.NullReferenceException. Declaras quais as variáveis que devem ser válidas null e quais não, e o compilador avisa quando essas declarações não correspondem à forma como o teu código as utiliza. O comportamento em tempo de execução do seu programa mantém-se inalterado. Os tipos de referência anuláveis são inteiramente uma funcionalidade em tempo de compilação.

Três blocos de construção funcionam em conjunto:

  • Anotações de variáveis (string vs. string?) indicam que referências se destinam a permitir null.
  • A análise de estado nulo acompanha se o valor de uma expressão é não-nulo ou talvez-nulo em cada ponto do seu código.
  • Os atributos nas APIs descrevem contratos mais nuançados, como "este argumento pode ser null, mas o valor de retorno é nulo apenas quando o argumento é nulo."

O compilador combina estes sinais para produzir diagnósticos. Avisos sobre uma variável não anulável significam que a variável pode receber null. Avisos sobre uma variável anulável significam que o código pode desreferenceá-la sem uma verificação nula. Desreferência significa usar o valor a que a variável se refere. Por exemplo, para chamar um método nele (variable.Method()), ler uma propriedade (variable.Property), ou indexar nela (variable[0]). Desreferenciar uma variável que tenha valor de null lança uma exceção em tempo de execução. Qualquer tipo de aviso significa que o comportamento do código não corresponde ao design declarado.

Contexto anulável

Projetos criados a partir de modelos recentes de .NET definidos <Nullable>enable</Nullable> no ficheiro do projeto, pelo que a orientação deste artigo aplica-se tal como está escrita. Se estiver a trabalhar num projeto mais antigo, abra o .csproj e verifique se contém <PropertyGroup> a seguinte linha; adicione-a se estiver em falta:

<Nullable>enable</Nullable>

Para mais informações sobre migração de uma aplicação grande, consulte o artigo sobre estratégias de migração anulável para mais definições e diretivas.

Expressar intenção com anotações

Todas as variáveis de tipo de referência são não anuláveis por defeito. Anexar ? para declarar um tipo de referência anulável :

public static void Annotations()
{
    string required = "always set";   // non-nullable: assigning null produces a warning
    string? optional = null;          // nullable: holding null is allowed

    Console.WriteLine(required.Length);

    if (optional is not null)
    {
        Console.WriteLine(optional.Length);
    }
}

A anotação não altera o tipo de execução. string e string? são ambos System.String. O ? informa o compilador sobre a sua intenção de conceção. Essa intenção molda os avisos que o compilador produz:

  • Uma variável não anulável tem, por defeito, um estado de nulidadenão nulo. O compilador avisa se atribuir um valor que possa ser null.
  • Uma variável anulável tem um estado nulo padrão de talvez-nulo. O compilador avisa se desreferenciares a variável sem a verificar primeiro.

Use a anotação para tornar visíveis os valores necessários e opcionais no sistema de tipos. O seguinte Person tipo declara FirstName e LastName como não anulável — toda a pessoa deve ter ambos — e MiddleName como anulável, porque nem todos têm um:

public sealed class Person(string firstName, string lastName)
{
    public string FirstName { get; } = firstName;
    public string? MiddleName { get; init; }
    public string LastName { get; } = lastName;

    public override string ToString() => MiddleName is null
        ? $"{FirstName} {LastName}"
        : $"{FirstName} {MiddleName} {LastName}";
}

public static void DesignIntent()
{
    Person p1 = new("Ada", "Lovelace") { MiddleName = "King" };
    Console.WriteLine(p1);
    // Output: Ada King Lovelace

    Person p2 = new("Grace", "Hopper");
    Console.WriteLine(p2);
    // Output: Grace Hopper
}

As anotações orientam a implementação de ToString. Como FirstName e LastName não admitem valores nulos, a substituição usa-os diretamente numa string interpolada (a sintaxe $"...", que incorpora expressões em {} marcadores de posição) sem qualquer verificação de valor nulo. MiddleName é anulável, por isso o override verifica-o primeiro em relação a null e só o inclui quando este está presente. O compilador faz cumprir a diferença: código que passa um valor talvez nulo onde se espera um valor não anulável produz um aviso, e um construtor que deixa um membro não anulável sem inicializar também produz um aviso.

Análise de estado nulo

O compilador acompanha o estado nulo de cada expressão. O estado é um de dois valores:

  • não nulo: sabe-se que a expressão não é null.
  • Talvez-nulo: a expressão pode ser null.

O estado nulo de uma variável local é atualizado à medida que o compilador analisa o seu código. Duas coisas o alteram: atribuições e verificações de valor nulo. Após uma atribuição, o estado nulo da variável corresponde à expressão do lado direito. Se a expressão for nula ou anulável, a variável torna-se talvez-nula. Se a expressão for um literal não nulo, a variável torna-se não-nula. Após uma verificação nula, o estado nulo da variável reflete o ramo que for tomado.

public static void NullStateTracking()
{
    string? message = null;

    // Warning: dereference of a possibly null reference.
    Console.WriteLine(message.Length);

    message = "Hello, World!";

    // No warning: the compiler tracks that message is now not-null.
    Console.WriteLine(message.Length);
}

No exemplo anterior, a primeira dereferência produz um aviso porque message é talvez-nulo. Após a atribuição a um literal não nulo, o compilador sabe message que não é nulo, pelo que a segunda desreferência é segura.

A análise de estado nulo funciona em if verificações, correspondência de padrões (expressões como is null ou is { } que testam a forma de um valor) e fluxo de controlo que faz ciclos ou termina antecipadamente:

 public sealed class Node(string name)
 {
     public string Name { get; } = name;
     public Node? Parent { get; init; }
 }

 public static void FlowAnalysis(Node start)
 {
     Node? current = start;
     while (current is not null)
     {
         // Inside the loop, the compiler knows current is not-null.
         Console.WriteLine(current.Name);

         current = current.Parent;
     }
}

A análise não se enquadra nos corpos dos métodos. Se precisar de um método para comunicar o estado nulo aos seus chamadores, use atributos de análise nula na sua assinatura.

Anule os avisos com !

Por vezes sabes mais do que o compilador. O operador ! declara que uma expressão não é nula, mesmo quando a análise diz o contrário:

public static void NullForgiving()
{
    // "ada" matches a switch arm that returns a non-null string,
    // but the return type is string? so the compiler treats the
    // result as maybe-null.
    string? maybeName = LookUpName("ada");

    // The ! tells the compiler "trust me, this isn't null." We just
    // passed "ada", which the switch maps to "Ada Lovelace".
    int length = maybeName!.Length;
    Console.WriteLine(length); // => 12
}

// Returns string? because the wildcard arm yields null.
private static string? LookUpName(string id) => id switch
{
    "ada" => "Ada Lovelace",
    _ => null,
};

Utilize ! com moderação. Cada ocorrência é um ponto em que o compilador já não o pode proteger. Prefira adicionar uma verificação de null, reestruturar o código ou anotar a API relevante para que o compilador chegue à conclusão correta por si próprio.

Atributos que descrevem contratos API

As anotações num parâmetro ou tipo de retorno nem sempre são suficientemente expressivas. Um método pode aceitar um argumento possivelmente nulo, mas garantir um resultado não nulo. Um método de teste pode regressar true apenas quando o seu argumento não é nulo. Use os atributos de análise anulável para transmitir estes contratos:

public static bool IsPresent([NotNullWhen(true)] string? value) =>
    !string.IsNullOrEmpty(value);

public static void NullAnalysisAttributes()
{
    string? input = ReadInput();

    if (IsPresent(input))
    {
        // No null-forgiving operator needed: the attribute tells the compiler
        // input is not-null when IsPresent returns true.
        Console.WriteLine(input.Length);
    }
}

private static string? ReadInput() => "hello";

O NotNullWhenAttribute informa o compilador que quando IsPresent retorna true, o argumento não é nulo. Dentro do bloco if, o compilador trata value como não nulo, sem ser necessário o operador de supressão de nulidade. A partir do .NET 5, todas as APIs de runtime do .NET estão anotadas, pelo que a análise beneficia qualquer código que as chame.

Armadilhas conhecidas

Dois padrões podem fazer com que uma referência não anulável contenha null sem emitir um aviso. Ambos os padrões são limitações da análise estática, não bugs no teu código.

Estruturas padrão

Pode criar uma struct com campos de referência não anuláveis usando default ou new(). Esta abordagem deixa os campos do struct sem inicializar:

public struct Student
{
    public string FirstName;
    public string? MiddleName;
    public string LastName;
}

public static void DefaultStructPitfall()
{
    Student s = default;            // No warning, but FirstName and LastName are null.
    Console.WriteLine(s.FirstName?.Length ?? -1);
}

Os campos contêm null durante a execução, mas o compilador não emite um aviso. Se tiver de usar um struct, prefira os membros obrigatórios, que são membros que o chamador deve inicializar através de um inicializador de objetos, ou de um construtor parametrizado que os chamadores devem invocar.

Conjuntos de referências e estruturas

Um novo array de tipo de referência não anulável contém todos os null elementos até que se atribua a cada um:

public static void ArrayPitfall()
{
    string[] values = new string[3];      // Elements are null at run time.
    Console.WriteLine(values[0]?.Length ?? -1);

    string[] initialized = ["a", "b", "c"]; // Collection expression initializes every slot.
    Console.WriteLine(initialized[0].Length);
}

A mesma armadilha também se aplica a arrays de estruturas: cada elemento é inicializado com o valor predefinido da estrutura, pelo que os campos de referência não anuláveis de cada elemento começam como null.

Inicialize os elementos da matriz ao criar a matriz. Expressões de coleção (a sintaxe literal [1, 2, 3]) e com tipo de destino new (escrever new() quando o compilador consegue inferir o tipo) permitem uma inicialização integral concisa.