Nullable Reference Types: o Fim Anunciado do NullReferenceException

Nullable Reference Types: o Fim Anunciado do NullReferenceException

Tipos de referência anuláveis no C#: string e string?, a análise de fluxo que acompanha cada verificação, os operadores ?., ??, ??= e !, o que o sistema não garante em execução, int? como Nullable, o atributo NotNullWhen no padrão Try e a armadilha do T? nos genéricos.
Linguagem C#

• • 15 min de leitura

O próprio criador da referência nula chamou-a de "erro de um bilhão de dólares": o C# moderno faz o compilador vigiar cada lugar onde esse erro pode acontecer.

Em "LINQ, Parte II" usamos FirstOrDefault, que pode devolver null, e MaxBy(...)!, que afirma que o resultado não é nulo. Esses sinais pequenos (? e !) aparecem desde a Fase 1: em string? no Console.ReadLine, no ?? que fornecia um valor substituto, no <Nullable>enable</Nullable> do .csproj. Eles fazem parte de um sistema introduzido no C# 8 para atacar o erro mais comum de qualquer programa em linguagens com referências: acessar um membro através de uma referência que não aponta para nada. Nesta aula entendemos o sistema por inteiro: o que ele garante, o que ele não garante e como conversar com o compilador sobre nulidade.

O erro de um bilhão de dólares

Em 1965, Tony Hoare, um dos pioneiros da computação, projetava a linguagem ALGOL W e incluiu a referência nula "simplesmente porque era fácil de implementar". Em 2009, numa palestra famosa, ele pediu desculpas e estimou que a decisão causou "um bilhão de dólares em dor e prejuízo" ao longo de quarenta anos. Praticamente todas as linguagens seguintes herdaram a ideia, e o C# não foi exceção: até o C# 7, qualquer variável de tipo de referência podia ser null, e o compilador não tinha como distinguir "este texto nunca é nulo" de "este texto pode faltar".

O resultado é o NullReferenceException, que você já viu em "Classes e Objetos": o programa tenta acessar cliente.Nome quando cliente não aponta para objeto algum. O pior dele não é a exceção em si. É que ela explode longe da causa: o nulo entrou no sistema num ponto, viajou por vários métodos e só estourou lá na frente.

A ideia: declarar a intenção

Com <Nullable>enable</Nullable>, que está ligado em todos os nossos projetos desde "Do Código-Fonte ao Processo", o significado dos tipos muda:

string titulo = "Dom Casmurro";   // NÃO deveria ser nulo, nunca
string? subtitulo = null;         // PODE ser nulo: é uma informação opcional

string passa a significar "uma string, sempre presente". string? significa "uma string ou nada". A partir daí, o compilador verifica o uso:

titulo = null;                          // aviso CS8600: atribuindo nulo a um tipo não anulável
Console.WriteLine(subtitulo.Length);    // aviso CS8602: possível desreferência de nulo

O segundo aviso é o mais valioso: o compilador percebeu que subtitulo pode ser nulo e que você está acessando .Length sem verificar. É exatamente o NullReferenceException que aconteceria em execução, apontado antes de o programa rodar.

Análise de fluxo: o compilador acompanha as verificações

O compilador não olha só a declaração. Ele acompanha o que acontece com a variável ao longo do código, e cada verificação muda o que ele sabe:

static int TamanhoDoSubtitulo(string? subtitulo)
{
    if (subtitulo is null)
        return 0;

    return subtitulo.Length;   // sem aviso: aqui o compilador SABE que não é nulo
}

Depois do if com return, só chega à última linha quem passou pela verificação. O compilador entende isso, e o aviso some. O mesmo vale para is not null, para string.IsNullOrEmpty e para as exceções de validação que usamos desde "Quando Algo Dá Errado": depois de ArgumentNullException.ThrowIfNull(cliente);, o compilador sabe que cliente não é nulo nas linhas seguintes.

Os operadores de nulidade

Quatro operadores trabalham com valores possivelmente nulos, e três deles você já conhece.

?., acesso condicional. Acessa o membro se o objeto não for nulo. Se for, o resultado inteiro é nulo:

int? tamanho = subtitulo?.Length;         // null se subtitulo for null
string? cidade = cliente?.Endereco?.Cidade;   // encadeável

??, coalescência. Fornece um substituto para o nulo, como em "Operadores, Texto e Conversa com o Usuário":

string exibicao = subtitulo ?? "(sem subtítulo)";
int caracteres = subtitulo?.Length ?? 0;

??=, atribuição por coalescência. Atribui só se a variável for nula. É útil para inicialização preguiçosa:

_cache ??= CarregarCatalogo();   // carrega apenas na primeira vez

!, supressão. Diz ao compilador "eu sei que isto não é nulo, pare de avisar":

Produto maisCaro = catalogo.MaxBy(p => p.Preco)!;

O ! não faz nada em execução: não verifica, não converte, não protege. Ele só silencia a análise. Se você estiver errado, o NullReferenceException acontece do mesmo jeito, e agora sem aviso. Use-o apenas quando você realmente sabe algo que o compilador não consegue deduzir, como no MaxBy sobre um catálogo que você acabou de preencher. Cada ! no código é uma afirmação que um leitor pode querer conferir. Se eles se multiplicam, é sinal de que os tipos estão mal declarados.

O que o sistema não garante

Aqui mora a parte mais importante, e a que mais confunde. Os tipos de referência anuláveis são anotações para o compilador, e não uma mudança no runtime. Em execução, string e string? são exatamente o mesmo tipo, e nada impede que um nulo apareça numa variável string. Isso acontece de três formas:

  • Dados vindos de fora. Um JSON recebido por uma API, uma linha lida do banco ou um arquivo de configuração podem ter um campo ausente, e o desserializador preenche a propriedade string Nome com null, apesar da declaração.
  • Código antigo. Bibliotecas escritas antes do C# 8, ou sem as anotações ligadas, não informam o que pode ser nulo.
  • Supressões erradas. Um ! colocado por engano, ou o default! que vimos na Pilha<T> de "Genéricos".

E os avisos são avisos: o programa compila mesmo com eles. É por isso que ligamos TreatWarningsAsErrors no Directory.Build.props em "Do Arquivo Único à Solução": sem ele, avisos de nulidade tendem a se acumular até ninguém mais lê-los.

A consequência prática: valide nas fronteiras do sistema. Dentro do domínio, confie nos tipos. Nos pontos de entrada (métodos públicos de uma biblioteca, dados recebidos pela API, registros lidos do banco), continue verificando com ArgumentNullException.ThrowIfNull e validações explícitas. O sistema de nulidade não substitui essas verificações: ele indica onde elas são necessárias.

Tipos de valor anuláveis: int? é outra história

Existe um segundo mecanismo, mais antigo (C# 2.0), que usa o mesmo símbolo e funciona de forma completamente diferente. Tipos de valor, como int e decimal, nunca podem ser nulos: um int sempre tem algum número. Para representar "um número que pode estar ausente", existe int?, abreviação de Nullable<int>, uma struct real que embrulha o valor e um indicador de presença:

int? avaliacao = null;              // o cliente ainda não avaliou

if (avaliacao.HasValue)
    Console.WriteLine(avaliacao.Value);

int nota = avaliacao ?? 0;
int outraNota = avaliacao.GetValueOrDefault();   // 0 se nulo

Diferente de string?, int? é um tipo diferente de int em execução, e acessar .Value com o valor ausente lança InvalidOperationException. As operações aritméticas se propagam: qualquer operação envolvendo null dá null:

int? a = null;
int? b = a + 5;         // null
bool c = a > 3;         // false
bool d = a < 3;         // false: comparações com null são sempre falsas
bool e = a == null;     // true

Repare em c e d: um valor ausente não é nem maior nem menor que 3. É um comportamento lógico, mas surpreendente, e vale lembrar dele quando escrevermos consultas a bancos de dados, onde valores nulos se comportam de forma parecida.

Conversando com o compilador: atributos de nulidade

Às vezes a nulidade depende de outra coisa, e o tipo sozinho não consegue expressar isso. O caso clássico é o padrão Try. Veja este método do nosso repositório:

public bool TentarObter(int id, out T? entidade) => _dados.TryGetValue(id, out entidade);

Quem chama sabe que, se o método devolver true, a entidade existe. Mas o compilador não sabe, e continua avisando:

if (repositorio.TentarObter(3, out var produto))
    Console.WriteLine(produto.Titulo);   // aviso: produto pode ser nulo

O atributo [NotNullWhen(true)], do namespace System.Diagnostics.CodeAnalysis, ensina a regra ao compilador:

using System.Diagnostics.CodeAnalysis;

public bool TentarObter(int id, [NotNullWhen(true)] out T? entidade) =>
    _dados.TryGetValue(id, out entidade);

Agora, dentro do if, o aviso desaparece: o compilador sabe que o true garante o valor. É assim que o int.TryParse e o Dictionary.TryGetValue da biblioteca padrão funcionam, e agora o seu método se comporta igual. Há uma família desses atributos: [NotNullWhen(false)] para métodos como string.IsNullOrEmpty, [MemberNotNull] para métodos que inicializam campos, [return: NotNullIfNotNull(...)] para retornos que só são nulos se a entrada for. Você vai usá-los em bibliotecas, e raramente em código de aplicação.

Os colchetes antes do parâmetro são a sintaxe de atributos: informações extras que o código carrega sobre si mesmo, lidas pelo compilador ou pelo próprio programa em execução. Já vimos [Flags] em "Enums e Pattern Matching", e eles ganham uma aula própria em seguida.

Uma armadilha dos genéricos

Em "Genéricos" declaramos T? ObterPorId(int id) com a restrição where T : class, e o ? significava "pode devolver null". Sem essa restrição, o significado muda. Para um T sem restrição, T? quer dizer "o valor padrão de T", e, se T for int, o valor padrão é 0, e não nulo:

static T? PrimeiroOuPadrao<T>(IEnumerable<T> itens)
{
    foreach (T item in itens)
        return item;
    return default;
}

Console.WriteLine(PrimeiroOuPadrao(new List<string>()) ?? "(nulo)");   // (nulo)
Console.WriteLine(PrimeiroOuPadrao(new List<int>()));                  // 0

É o mesmo comportamento do FirstOrDefault sobre inteiros que vimos no exercício 2 de "LINQ, Parte I". Quando a ausência precisa ser distinguível para tipos de valor, a restrição where T : struct permite devolver um T? que é realmente um Nullable<T>.

Juntando tudo: nulidade como parte do modelo

Na Livraria, a nulidade passa a documentar decisões de negócio. Um pedido pode ter um cupom, ou não. Um cliente pode ter um telefone, ou não. Um pedido pode estar associado a um cliente cadastrado ou ser uma compra avulsa:

public sealed class Cliente
{
    public required string Nome { get; init; }
    public required string Email { get; init; }
    public string? Telefone { get; init; }
}

public sealed class Pedido
{
    // ... itens, status e demais membros de antes ...

    public Cliente? Cliente { get; private set; }
    public string? CodigoCupom { get; private set; }

    public void IdentificarCliente(Cliente cliente)
    {
        ArgumentNullException.ThrowIfNull(cliente);   // fronteira: método público
        Cliente = cliente;
    }

    public void AplicarCupom(string? codigo)
    {
        CodigoCupom = string.IsNullOrWhiteSpace(codigo) ? null : codigo.Trim().ToUpperInvariant();
    }

    public string Destinatario => Cliente?.Nome ?? "Cliente avulso";

    public string Contato => Cliente switch
    {
        null => "sem contato",
        { Telefone: { } telefone } => $"{Cliente.Email} / {telefone}",
        _ => Cliente.Email
    };
}

Três detalhes. Em AplicarCupom, depois de string.IsNullOrWhiteSpace(codigo) ser falso, o compilador sabe que codigo não é nulo, porque esse método é anotado com [NotNullWhen(false)] na biblioteca padrão. Por isso codigo.Trim() não gera aviso. Em Contato, o padrão { Telefone: { } telefone } casa quando Telefone não é nulo (o padrão de propriedade vazio { } casa com qualquer valor não nulo) e já o captura numa variável. E os tipos contam a história do negócio sem nenhum comentário: quem lê Cliente? sabe que existem pedidos sem cliente, e quem lê string Nome sabe que todo cliente tem nome.

Com as nulidades explícitas, o foreach e o LINQ que usamos em todas as aulas continuam funcionando sobre listas, arrays, dicionários e resultados de consultas, sem nunca termos perguntado como eles funcionam. Toda coleção que o foreach percorre implementa uma interface pequena, e o C# tem uma palavra-chave que permite escrever sequências próprias, inclusive infinitas, em poucas linhas. É ela que está por trás da execução adiada que tanto intrigou na aula anterior.

Fontes e leituras recomendadas

Exercícios

Exercício 1

O código abaixo gera avisos de nulidade. Identifique cada um e corrija sem usar o operador !.

string? entrada = Console.ReadLine();
string nome = entrada;
Console.WriteLine($"Olá, {nome.ToUpper()}! Seu nome tem {entrada.Length} letras.");
Ver resposta

✓ Resposta: São três avisos. string nome = entrada; atribui algo que pode ser nulo a um tipo não anulável (CS8600). Depois disso, nome carrega o mesmo estado "talvez nulo" da origem, e nome.ToUpper() recebe um CS8602. E entrada.Length acessa um membro de algo possivelmente nulo (outro CS8602). A declaração string não torna o valor seguro: o compilador acompanha o que de fato foi atribuído. A correção decide o que fazer com a ausência:

string? entrada = Console.ReadLine();
if (string.IsNullOrWhiteSpace(entrada))
{
    Console.WriteLine("Nenhum nome informado.");
    return;
}

string nome = entrada.Trim();
Console.WriteLine($"Olá, {nome.ToUpper()}! Seu nome tem {nome.Length} letras.");

Depois do if com return, o compilador sabe que entrada não é nula, e os avisos somem. A correção não foi silenciar o compilador. Foi responder à pergunta que ele fazia: o que acontece se não vier nada? Um entrada!.Length também eliminaria o aviso, mas quebraria com a entrada redirecionada de um arquivo vazio, exatamente o cenário descrito em "Métodos".

Exercício 2

Preveja o resultado de cada linha:

int? a = null;
int? b = a + 10;
int c = a ?? -1;
bool d = a >= 0;
bool e = a < 0;
bool f = a == null;
int g = b.GetValueOrDefault(99);
Ver resposta

✓ Resposta: b é null, porque operações aritméticas com um operando nulo produzem nulo. c é -1, porque ?? fornece o substituto. d é false e e também é false: comparações de ordem com nulo são sempre falsas, então um valor ausente não é nem maior, nem menor, nem igual a zero nesse sentido. f é true: a igualdade com null é a forma de testar a ausência. g é 99, porque b é nulo e GetValueOrDefault recebe o valor substituto. O caso de d e e merece atenção: um if (a >= 0) ... else ... mandaria o valor ausente para o else, como se fosse negativo. Trate o nulo antes de comparar.

Exercício 3

Escreva um método string Iniciais(string? nomeCompleto) que devolva as iniciais em maiúsculas ("Machado de Assis" → "MDA") e devolva "?" para entrada nula ou em branco. Não use !.

Ver resposta

✓ Resposta:

static string Iniciais(string? nomeCompleto)
{
    if (string.IsNullOrWhiteSpace(nomeCompleto))
        return "?";

    var partes = nomeCompleto.Split(' ', StringSplitOptions.RemoveEmptyEntries);
    return string.Concat(partes.Select(p => char.ToUpperInvariant(p[0])));
}

Console.WriteLine(Iniciais("Machado de Assis"));   // MDA
Console.WriteLine(Iniciais("   "));                // ?
Console.WriteLine(Iniciais(null));                 // ?

string.IsNullOrWhiteSpace é anotado com [NotNullWhen(false)], então, depois do if, o compilador trata nomeCompleto como não nulo, e o Split não gera aviso. RemoveEmptyEntries garante que nenhuma parte é vazia, o que torna p[0] seguro mesmo com espaços duplos. Sem essa opção, "Machado de" produziria uma string vazia no meio, e p[0] lançaria IndexOutOfRangeException. string.Concat sobre uma sequência de caracteres junta-os numa string.

Exercício 4

Mostre um exemplo em que o uso de ! esconde um bug real, e outro em que ele é legítimo.

Ver resposta

✓ Resposta: Um bug escondido:

Produto p = catalogo.FirstOrDefault(p => p.Autor == autorDigitado)!;
Console.WriteLine(p.Titulo);   // NullReferenceException quando o autor não existe

O FirstOrDefault devolve null justamente quando nada é encontrado, o que é um caso normal numa busca digitada pelo usuário. O ! silenciou o aviso que apontava exatamente esse problema. A correção honesta é tratar a ausência: if (p is null) { ... }. Um uso legítimo:

List<Produto> catalogo = [ /* sete produtos, preenchidos na linha acima */ ];
Produto maisCaro = catalogo.MaxBy(p => p.Preco)!;

MaxBy é declarado como possivelmente nulo porque devolve nulo para sequências vazias, mas este catálogo acabou de ser preenchido com sete itens, e o programador sabe algo que o compilador não consegue deduzir. Mesmo assim, se a lista viesse de fora, a verificação explícita voltaria a ser a escolha certa. A diferença entre os dois casos é se a ausência é possível no fluxo real do programa.

Exercício 5

Escreva o método TentarEncontrarPorIsbn(IEnumerable<LivroImpresso> livros, Isbn isbn, out LivroImpresso? livro) no padrão Try. Mostre o aviso que o código de quem chama recebe sem o atributo [NotNullWhen(true)] e como ele desaparece com o atributo. Considere que LivroImpresso ganhou uma propriedade Isbn? Isbn.

Ver resposta

✓ Resposta:

using System.Diagnostics.CodeAnalysis;

static bool TentarEncontrarPorIsbn(
    IEnumerable<LivroImpresso> livros,
    Isbn isbn,
    [NotNullWhen(true)] out LivroImpresso? livro)
{
    livro = livros.FirstOrDefault(l => l.Isbn == isbn);
    return livro is not null;
}

if (TentarEncontrarPorIsbn(impressos, new Isbn("9780306406157"), out var encontrado))
    Console.WriteLine(encontrado.Titulo);   // sem aviso graças ao atributo

Sem o atributo, a linha encontrado.Titulo gera o aviso CS8602: o parâmetro é declarado LivroImpresso?, e o compilador não tem como saber que o true do retorno garante um valor. Com [NotNullWhen(true)], a análise de fluxo passa a entender a relação entre o retorno e o parâmetro: dentro do if, encontrado é tratado como não nulo. No else ele continuaria possivelmente nulo, o que também está correto. A comparação l.Isbn == isbn funciona entre um Isbn? e um Isbn porque Isbn é um readonly record struct com == gerado automaticamente, e o C# estende o operador para a versão anulável.

Comentários

Mais em Linguagem C#

Interfaces: Contratos Que Qualquer Tipo Pode Assinar
Interfaces: Contratos Que Qualquer Tipo Pode Assinar

Interfaces em C#: contratos que tipos sem parentesco assinam, várias…

Projeto: a Biblioteca em Apuros completa, do documento de design ao export
Projeto: a Biblioteca em Apuros completa, do documento de design ao export

A Biblioteca em Apuros vira um jogo acabado: documento de design de uma…

O aplicativo do cliente em .NET MAUI: Shell, listas, login no celular e acompanhamento de pedidos
O aplicativo do cliente em .NET MAUI: Shell, listas, login no celular e acompanhamento de pedidos

O aplicativo do cliente para iPhone e Android em .NET MAUI: Shell com abas e…