Um Valor que Pode Não Existir — Optional e a Guerra contra o null

Um Valor que Pode Não Existir — Optional e a Guerra contra o null

Por que a referência nula é o erro de um bilhão de dólares e como o Optional do Java declara no tipo que um valor pode faltar: map, flatMap, orElse, orElseGet e orElseThrow. Inclui os abusos comuns do Optional e as outras defesas contra a NullPointerException, como o JSpecify.
Java

• • 16 min de leitura

Por que o null é chamado de erro de um bilhão de dólares, como o Optional torna a ausência de um valor explícita no tipo, e as outras armas para evitar a NullPointerException.

Desde "Moldes e Instâncias", a NullPointerException apareceu em várias aulas: na referência que não apontava para nada, no unboxing de um Integer nulo, no Map.get de uma chave inexistente. E nas duas últimas aulas, operações como findFirst e max devolveram um tal de Optional, usado sem explicação. Esta aula trata das duas coisas juntas. Você vai ver por que o null é tão problemático, como o Optional expressa no próprio tipo que um valor pode não existir, os usos corretos e os abusos mais comuns dele, e as outras técnicas que, juntas, tornam a NullPointerException rara num código bem escrito.

O erro de um bilhão de dólares

Em 1965, o cientista britânico Tony Hoare projetava a linguagem ALGOL W e incluiu nela a referência nula, uma referência que não aponta para nada. Em 2009, numa palestra famosa, ele a chamou de "o meu erro de um bilhão de dólares", pela quantidade de falhas, vulnerabilidades e quedas de sistemas que ela causou em quarenta anos. A ideia se espalhou por quase todas as linguagens, e o Java a herdou.

O problema não é a existência de um valor para "nada". Às vezes um valor realmente não existe: um cliente sem e-mail, uma busca sem resultado. O problema é que, em Java, qualquer referência pode ser null, e nada no tipo diz isso. Um método declarado como Cliente buscar(String cpf) pode devolver um cliente ou null, e a assinatura não diferencia os dois casos. Quem chama precisa lembrar de testar, ou ler a documentação, ou descobrir em produção. E um null esquecido não falha onde nasce: viaja pelo programa até alguém tentar usá-lo, às vezes muito longe, como a poluição do heap de "O Preço da Compatibilidade".

Optional: a ausência no tipo

Optional<T> é um recipiente que contém um valor ou nenhum. Um método que devolve Optional<Cliente> declara, na própria assinatura, que o cliente pode não existir, e o compilador obriga quem chama a lidar com o recipiente, em vez de usar o valor diretamente.

Um repositório de clientes, com uma busca que pode não encontrar nada:

record Endereco(String rua, String cidade, String cep) { }
record Cliente(String cpf, String nome, Endereco endereco, String email) { }

class Clientes {
    private final Map<String, Cliente> porCpf = new HashMap<>();

    void salvar(Cliente c) {
        porCpf.put(c.cpf(), c);
    }

    Optional<Cliente> buscarPorCpf(String cpf) {
        return Optional.ofNullable(porCpf.get(cpf));
    }
}

Três formas de criar um Optional:

  • Optional.of(valor) para um valor que certamente existe. Com null, lança NullPointerException imediatamente.
  • Optional.empty() para a ausência.
  • Optional.ofNullable(valor) para um valor que pode ser null: vira vazio se for, e cheio se não for. É a ponte entre o código que usa null, como o Map.get, e o código que usa Optional.
Clientes clientes = new Clientes();
clientes.salvar(new Cliente("111", "Ana", new Endereco("Rua A", "Recife", "50000-000"), "ana@x.com"));
clientes.salvar(new Cliente("222", "Bia", null, null));

Optional<Cliente> ana = clientes.buscarPorCpf("111");
Optional<Cliente> ninguem = clientes.buscarPorCpf("999");
IO.println(ana.isPresent() + " " + ninguem.isEmpty());   // true true
IO.println(ninguem);                                     // Optional.empty

Usando o valor sem abrir a caixa

A tentação é testar com isPresent() e chamar get(), como se fosse um if (x != null). Funciona, mas desperdiça o que o Optional oferece. Os métodos dele permitem trabalhar com o valor sem tirá-lo da caixa.

Um valor padrão, com orElse:

IO.println(ana.map(Cliente::nome).orElse("desconhecido"));       // Ana
IO.println(ninguem.map(Cliente::nome).orElse("desconhecido"));   // desconhecido

Uma transformação, com map. Se o Optional tem valor, aplica a função e devolve um Optional com o resultado. Se está vazio, devolve vazio sem chamar a função. Isso permite encadear acessos que, com null, exigiriam um if a cada passo:

for (String cpf : List.of("111", "222", "999")) {
    String cidade = clientes.buscarPorCpf(cpf)
        .map(Cliente::endereco)
        .map(Endereco::cidade)
        .orElse("cidade não informada");
    IO.println(cpf + " -> " + cidade);
}
111 -> Recife
222 -> cidade não informada
999 -> cidade não informada

Os três casos, cliente com endereço, cliente sem endereço e cliente inexistente, são tratados pela mesma cadeia, sem nenhum if. Quando Cliente::endereco devolve null, o map produz um Optional vazio, e o resto da cadeia é pulado até o orElse. A versão com null seria:

Cliente c = buscarComNull(cpf);
String cidade = "cidade não informada";
if (c != null && c.endereco() != null && c.endereco().cidade() != null) {
    cidade = c.endereco().cidade();
}

Uma ação, só se houver valor, com ifPresent ou ifPresentOrElse:

clientes.buscarPorCpf("111").ifPresent(c -> IO.println("encontrado: " + c.nome()));
clientes.buscarPorCpf("999").ifPresentOrElse(
    c -> IO.println("encontrado: " + c.nome()),
    () -> IO.println("CPF 999 não cadastrado"));

Um filtro, com filter, que esvazia o Optional se o valor não passar no teste:

clientes.buscarPorCpf("111").filter(c -> c.nome().startsWith("B")).isPresent();   // false

Uma exceção, quando a ausência é um erro, com orElseThrow:

Cliente c = clientes.buscarPorCpf(cpf)
    .orElseThrow(() -> new NoSuchElementException("Cliente " + cpf + " não encontrado"));

Se, naquele ponto do programa, o cliente precisa existir, porque foi validado antes, por exemplo, orElseThrow torna a expectativa explícita e produz uma mensagem clara. Sem argumentos, orElseThrow() lança uma NoSuchElementException genérica.

orElse ou orElseGet?

Os dois fornecem um valor padrão, com uma diferença importante:

String caro() {
    IO.println("  (calculando o valor padrão...)");
    return "padrão";
}

Optional<String> presente = Optional.of("valor");
IO.println(presente.orElse(caro()));          // chama caro() mesmo assim
IO.println(presente.orElseGet(this::caro));   // não chama
  (calculando o valor padrão...)
valor
valor

O argumento de orElse é avaliado antes da chamada, como todo argumento de método, mesmo quando o Optional tem valor e o padrão é descartado. orElseGet recebe um Supplier, que só é chamado se necessário. Para constantes, como orElse("desconhecido"), tanto faz. Se calcular o padrão for caro ou tiver efeitos, como consultar um banco de dados ou criar um registro, use orElseGet.

flatMap: quando a função também devolve um Optional

Se a função passada ao map já devolve um Optional, o resultado fica aninhado:

Optional<String> emailDe(Cliente c) {
    return Optional.ofNullable(c.email());
}

clientes.buscarPorCpf("111").map(this::emailDe);       // Optional[Optional[ana@x.com]]
clientes.buscarPorCpf("111").flatMap(this::emailDe);   // Optional[ana@x.com]
clientes.buscarPorCpf("222").flatMap(this::emailDe).orElse("sem e-mail");   // sem e-mail

É exatamente a mesma ideia do flatMap das streams, na aula anterior: aplicar uma função que devolve um recipiente e achatar o resultado. Para oferecer uma alternativa quando o Optional está vazio, sem abrir a caixa, existe or, que recebe outro Optional: buscarNoCache(cpf).or(() -> buscarNoBanco(cpf)).

Optional e streams

As duas APIs conversam. Optional.stream() transforma o Optional numa stream com zero ou um elemento, e isso permite descartar os vazios de uma stream de Optional com flatMap:

List<String> encontrados = Stream.of("111", "999", "222")
    .map(clientes::buscarPorCpf)
    .flatMap(Optional::stream)
    .map(Cliente::nome)
    .toList();
// [Ana, Bia]

Os CPFs sem cliente simplesmente desaparecem da stream. Para os tipos primitivos existem OptionalInt, OptionalLong e OptionalDouble, devolvidos por max, min e average das streams numéricas, com métodos como getAsInt().

Como não usar o Optional

O Optional foi projetado com um propósito limitado, e usá-lo fora dele traz mais problemas do que resolve. Brian Goetz, arquiteto da linguagem, resumiu a intenção: o Optional é um mecanismo para tipos de retorno de métodos que podem não ter resultado. Os abusos mais comuns:

Chamar get() sem verificar. ninguem.get() lança NoSuchElementException: No value present. Trocar um null sem teste por um get() sem teste não resolve nada. Se você está escrevendo if (opt.isPresent()) { ... opt.get() ... }, provavelmente map, ifPresent ou orElse expressam melhor a intenção.

Devolver null de um método que devolve Optional. Quem chama um método com retorno Optional confia que ele nunca é nulo. Devolver null ali é o pior dos mundos: o tipo promete segurança e entrega o problema original. Um método que devolve Optional deve devolver Optional.empty().

Usar Optional como campo ou parâmetro. Um campo Optional<String> email numa classe ou um parâmetro void enviar(Optional<String> assunto) complicam a interface, e o próprio campo ou parâmetro ainda pode ser null. Optional também não foi feito para ser guardado em coleções nem serializado. Para campos que podem faltar, use o campo comum, deixe claro que pode ser nulo, e ofereça um método de acesso que devolva Optional, como o emailDe acima. Para parâmetros opcionais, prefira a sobrecarga de "Dividir para Resolver".

Envolver coleções. Um método que devolve Optional<List<Pedido>> tem duas formas de dizer "nada": o Optional vazio e a lista vazia. Para coleções, a ausência é a coleção vazia. Devolva List.of(), como no método telefonesDe de "Listas, Conjuntos e Mapas".

Usar Optional para tudo. Um Optional é um objeto, alocado a cada uso. Em código que roda milhões de vezes por segundo, esse custo pode importar. Em métodos privados e em código interno, um null bem documentado, verificado perto de onde nasce, é aceitável.

As outras armas

O Optional resolve os retornos. A guerra contra o null tem outras frentes, e várias já apareceram no curso.

Validar na entrada. Um objeto que recusa null no construtor, como os records de "Quando o Contrato se Quebra" com Objects.requireNonNull, garante que os seus campos nunca são nulos. Todo o resto do código pode confiar nisso, sem testes espalhados.

Coleções vazias em vez de null. Um método que devolve uma lista nunca devolve null. Quem chama pode percorrer o resultado diretamente.

O objeto nulo. Em vez de null, usar um objeto que implementa a interface sem fazer nada. O notificador silencioso de "Contratos sem Implementação" é exatamente isso: o Cofre pode chamar notificador.enviar(...) sem nunca verificar se há um notificador. Esse padrão se chama Null Object.

Tipos selados para resultados. Quando a ausência tem motivos diferentes, como em "cliente não encontrado" e "cliente bloqueado", um tipo selado como o ResultadoSaque de "Hierarquias Fechadas" diz mais do que um Optional vazio.

Cuidado com as APIs ambíguas. Map.get devolve null tanto para uma chave inexistente quanto para uma chave que existe com valor null:

Map<String, String> config = new HashMap<>();
config.put("tema", null);
IO.println(config.get("tema") + " " + config.get("idioma"));   // null null
IO.println(config.containsKey("tema") + " " + config.containsKey("idioma"));   // true false

Os dois get são indistinguíveis. É uma boa razão para não guardar null em mapas, e as coleções criadas com List.of, Set.of e Map.of simplesmente o recusam.

Anotações de nulidade. O Java não tem, na linguagem, um jeito de declarar que uma referência nunca é nula, como Kotlin e C# têm. A comunidade criou anotações para isso, e o projeto JSpecify, apoiado por Google, JetBrains, Oracle, Spring e outros, padronizou um conjunto delas: @Nullable e @NonNull. Com ferramentas de análise como o NullAway, o compilador passa a acusar um null que chega onde não deveria. As anotações e a programação por contratos são o assunto de "Contratos à Vista".

Combinadas, essas técnicas mudam o padrão do código: o null deixa de ser uma possibilidade em toda parte e passa a existir só em pontos conhecidos e verificados, nas fronteiras do programa.

Com esta aula, as ferramentas do estilo funcional do Java estão completas: lambdas, streams, coletores e Optional. Resta uma pergunta sobre algo que o curso usou o tempo todo sem explicar a fundo: as anotações. @Override pede ao compilador que confira uma sobrescrita, @FunctionalInterface que confira uma interface, @SuppressWarnings que silencie avisos. Mas as anotações não servem só ao compilador: frameworks inteiros, como os da trilha de web, funcionam lendo anotações durante a execução, com o programa examinando a própria estrutura e agindo sobre ela.

Fontes e leituras recomendadas

  • Tony Hoare, "Null References: The Billion Dollar Mistake" (QCon, 2009): https://www.infoq.com/presentations/Null-References-The-Billion-Dollar-Mistake-Tony-Hoare/. A palestra em que o criador da referência nula conta a história e o custo da decisão.
  • Documentação de Optional (Java SE 25): https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/Optional.html. Todos os métodos, com a nota oficial de que o Optional se destina principalmente a tipos de retorno.
  • Stuart Marks, "Optional: The Mother of All Bikesheds" (Devoxx, 2016): uma palestra de um engenheiro do JDK com as regras de uso do Optional e os anti-padrões mais comuns. Fácil de encontrar pelo título.
  • JSpecify: https://jspecify.dev/. O projeto de padronização das anotações de nulidade, com a documentação das anotações e das ferramentas que as verificam.
  • Joshua Bloch, Effective Java, 3ª edição, itens 54 e 55: "Return empty collections or arrays, not nulls" e "Return optionals judiciously".

Exercícios

Exercício 1

O método abaixo devolve null quando não encontra o produto. Reescreva-o para devolver Optional, e reescreva o código que o chama para mostrar o preço ou "produto indisponível".

Produto buscarPorCodigo(String codigo) {
    for (Produto p : produtos) {
        if (p.codigo().equals(codigo)) {
            return p;
        }
    }
    return null;
}

// quem chama:
Produto p = buscarPorCodigo("X9");
if (p != null) {
    IO.println("R$ " + p.preco());
} else {
    IO.println("produto indisponível");
}
Ver resposta

✓ Resposta:

Optional<Produto> buscarPorCodigo(String codigo) {
    return produtos.stream()
        .filter(p -> p.codigo().equals(codigo))
        .findFirst();
}

// quem chama:
IO.println(buscarPorCodigo("X9")
    .map(p -> "R$ " + p.preco())
    .orElse("produto indisponível"));

O método agora declara na assinatura que pode não encontrar nada, e a stream com findFirst já produz o Optional, sem nenhum return null. Quem chama não pode mais esquecer o caso de ausência, porque recebe uma caixa, e não um Produto. A versão com map e orElse expressa numa só expressão as duas saídas possíveis. Um ifPresentOrElse também serviria, se a intenção fosse executar ações diferentes em vez de produzir um texto.

Exercício 2

Sem rodar, diga o que o trecho imprime e explique a diferença entre as duas linhas:

static String padrao() {
    IO.println("criando padrão");
    return "P";
}
// no main:
Optional<String> cheio = Optional.of("A");
IO.println(cheio.orElse(padrao()));
IO.println(cheio.orElseGet(() -> padrao()));
Ver resposta

✓ Resposta: Imprime "criando padrão", depois "A", depois "A". Na primeira linha, padrao() é um argumento comum e é avaliado antes de orElse ser chamado, então a mensagem aparece, embora o valor "P" seja descartado, porque o Optional está cheio. Na segunda, orElseGet recebe uma lambda, que só seria executada se o Optional estivesse vazio. Como não está, padrao() nunca é chamado. Se padrao() consultasse um banco de dados, a primeira versão faria essa consulta a cada chamada, inutilmente, e se criasse um registro, criaria registros indevidos.

Exercício 3

Aponte os problemas do código abaixo e diga como corrigir cada um:

class Pedido {
    private Optional<String> cupom;

    Optional<List<Item>> itens() { ... }

    Optional<Cliente> cliente() {
        if (clienteId == null) return null;
        return Optional.of(repositorio.buscar(clienteId));
    }

    double desconto(Optional<Double> percentual) { ... }
}
Ver resposta

✓ Resposta: (1) O campo Optional<String> cupom: Optional não foi feito para campos. Use private String cupom (possivelmente nulo, e documentado) e um método Optional<String> cupom() que devolva Optional.ofNullable(cupom). (2) Optional<List<Item>> itens(): uma coleção não deve ser embrulhada em Optional, porque a ausência de itens é a lista vazia. Devolva List<Item>, e List.of() quando não houver itens. (3) cliente() devolve null num método cujo tipo é Optional, o que desfaz a garantia inteira: deveria devolver Optional.empty(). Além disso, Optional.of(repositorio.buscar(clienteId)) lança NullPointerException se a busca não encontrar nada. O certo é Optional.ofNullable(...), ou melhor, o repositório já devolver Optional, e o método fazer repositorio.buscar(clienteId). (4) O parâmetro Optional<Double> percentual complica quem chama e ainda pode receber null. Use sobrecarga: desconto() e desconto(double percentual).

Exercício 4

Com o repositório de clientes da aula, escreva uma expressão que devolva o CEP do cliente de um CPF, sem o hífen, ou "sem CEP" se o cliente não existir, não tiver endereço ou o endereço não tiver CEP.

Ver resposta

✓ Resposta:

String cep = clientes.buscarPorCpf(cpf)
    .map(Cliente::endereco)
    .map(Endereco::cep)
    .map(c -> c.replace("-", ""))
    .orElse("sem CEP");

Para o CPF "111", o resultado é "50000000". Para "222", que não tem endereço, e para "999", que não existe, é "sem CEP". Cada map só é aplicado se a etapa anterior produziu um valor. Quando Cliente::endereco ou Endereco::cep devolvem null, o map transforma o resultado num Optional vazio, e as etapas seguintes são puladas. A transformação final, que remove o hífen, só roda quando há um CEP, sem nenhum teste explícito. A versão com null exigiria três verificações encadeadas, uma para cada nível.

Exercício 5

Explique por que config.get("tema") e config.get("idioma") devolveram o mesmo valor no exemplo da aula, embora a situação das duas chaves seja diferente. Como distinguir as duas situações, e como evitar o problema?

Ver resposta

✓ Resposta: Map.get devolve null em dois casos: quando a chave não existe e quando ela existe com o valor null. A chave "tema" foi gravada com null, e a chave "idioma" nunca foi gravada, mas as duas chamadas devolvem null, e quem lê não tem como saber qual é o caso. Para distinguir, usa-se containsKey, que dá true para "tema" e false para "idioma". Para evitar o problema, a melhor saída é não guardar null em mapas: se "tema" não tem valor definido, simplesmente não se grava a chave, ou se grava um valor padrão explícito. Assim, get devolvendo null passa a significar apenas "não existe", e getOrDefault resolve a leitura com um valor padrão. Os mapas imutáveis criados com Map.of impõem essa regra, recusando null com uma exceção, e muitos projetos adotam a mesma política em todos os mapas.

Comentários

Mais em Java

Listas, Conjuntos e Mapas — o Collections Framework
Listas, Conjuntos e Mapas — o Collections Framework

O Collections Framework do Java: interfaces e implementações, ArrayList e a…

A Segunda Pedra — Requisições e Respostas como Tipos: Records, Sealed e Rotas
A Segunda Pedra — Requisições e Respostas como Tipos: Records, Sealed e Rotas

Nesta aula, os textos soltos do Farol viram tipos: enums para métodos e…

Dividir para Resolver — Métodos, Parâmetros e Recursão
Dividir para Resolver — Métodos, Parâmetros e Recursão

Como escrever métodos em Java: tipo de retorno, parâmetros, return, passagem…