Contratos à Vista — Imutabilidade, Programação Defensiva e JSpecify

Contratos à Vista — Imutabilidade, Programação Defensiva e JSpecify

Como uma classe se protege de quem a usa: objetos que não podem ser alterados depois de criados, cópias defensivas na entrada e na saída, validação de parâmetros que falha cedo e anotações do JSpecify que tornam explícito, e verificável, onde o null é permitido.
Java

• • 29 min de leitura

Como uma classe se protege de quem a usa: objetos que não podem ser alterados depois de criados, cópias defensivas na entrada e na saída, validação de parâmetros que falha cedo e anotações do JSpecify que tornam explícito, e verificável, onde o null é permitido.

"Lições de Quem Veio Antes" deixou três itens de Effective Java para esta aula: "minimize a mutabilidade", "faça cópias defensivas quando necessário" e "verifique a validade dos parâmetros". Os três respondem à mesma pergunta: o que uma classe promete a quem a usa, e como ela garante que a promessa continue valendo, mesmo quando o código de fora faz coisas inesperadas? Uma classe que só funciona se todos os que a usam forem cuidadosos é frágil. Uma classe que torna o uso errado impossível, ou pelo menos barulhento, é robusta. Esta aula percorre as três técnicas e termina com a mais recente delas, o JSpecify, que leva ao sistema de tipos uma parte do contrato que o Java sempre deixou implícita: quais valores podem ser null.

Por que imutável

Um objeto imutável é aquele cujo estado não muda depois de criado. String, Integer, LocalDate, BigDecimal e os records com componentes imutáveis são exemplos. A Fase 7 mostrou a vantagem mais visível: um objeto imutável pode ser compartilhado entre threads sem nenhuma trava, porque não há o que proteger. Mas há outras, que valem mesmo num programa de uma thread só.

Um objeto imutável pode ser compartilhado livremente: se dois pedidos têm o mesmo LocalDate de entrega, ambos podem apontar para o mesmo objeto, e nenhum dos dois consegue alterar a data do outro. Ele pode ser usado com segurança como chave de mapa ou elemento de conjunto: um objeto mutável guardado num HashSet que tem um campo alterado depois muda o seu hashCode, e o conjunto passa a procurá-lo no lugar errado, como "Iguais ou o Mesmo?" mostrou. Ele é fácil de raciocinar: se uma variável aponta para um objeto imutável, o valor que ela representa é o que foi criado, e não é preciso rastrear quem mais tem uma referência a ele. E ele tem atomicidade de falha: uma operação que falha no meio não deixa o objeto num estado inconsistente, porque o objeto nunca é alterado; no máximo, a operação deixa de criar um objeto novo.

O custo é que cada "alteração" cria um objeto novo. Na maioria dos casos, isso é barato, como "Quem Recolhe o Lixo" mostrou. Para os casos em que não é, como montar uma String peça por peça num laço, a solução clássica é um companheiro mutável, usado durante a construção e descartado no fim: o StringBuilder para a String, o Construtor para a Mensagem da aula anterior.

As cinco regras

O item 17 de Effective Java lista cinco regras para uma classe imutável:

  1. Não ofereça métodos que alterem o estado, como os setters.
  2. Impeça que a classe seja estendida, declarando-a final (ou com construtores privados e fábricas estáticas). Uma subclasse poderia acrescentar estado mutável ou sobrescrever métodos para se comportar como se o objeto tivesse mudado.
  3. Declare todos os campos final, o que documenta a intenção e, como "O Cadeado e a Memória" mostrou, garante que todas as threads vejam os valores corretos.
  4. Declare todos os campos private, para que ninguém de fora os altere diretamente.
  5. Garanta acesso exclusivo a qualquer componente mutável: se a classe tem um campo que aponta para um objeto mutável, nenhum código de fora pode ter uma referência a esse objeto.

Um record cumpre as quatro primeiras automaticamente: é final, seus campos são private final, e não tem setters. A quinta regra é a que exige trabalho, e é a mais fácil de esquecer.

O ataque ao Período

O exemplo clássico da quinta regra, também de Effective Java, usa java.util.Date, a classe de datas anterior ao java.time, que é mutável:

import java.time.*;
import java.util.Date;

// Converte "2026-09-01" num java.util.Date (meia-noite, horário do sistema)
static Date data(String texto) {
    return Date.from(LocalDate.parse(texto).atStartOfDay(ZoneId.systemDefault()).toInstant());
}

// Uma classe que PARECE imutável
static final class Periodo {
    private final Date inicio;
    private final Date fim;

    Periodo(Date inicio, Date fim) {
        if (inicio.after(fim)) throw new IllegalArgumentException(inicio + " depois de " + fim);
        this.inicio = inicio;
        this.fim = fim;
    }
    Date inicio() { return inicio; }
    Date fim() { return fim; }
    @Override public String toString() { return String.format("%tF → %tF", inicio, fim); }
}

void main() {
    Date inicio = data("2026-09-01");
    Date fim = data("2026-09-30");
    Periodo p = new Periodo(inicio, fim);
    IO.println("criado:          " + p);

    fim.setTime(0);                       // ataque 1: altera o objeto passado ao construtor
    IO.println("após ataque 1:   " + p);

    p.inicio().setTime(data("2030-01-01").getTime());   // ataque 2: altera o objeto devolvido pelo acessor
    IO.println("após ataque 2:   " + p);
}
criado:          2026-09-01 → 2026-09-30
após ataque 1:   2026-09-01 → 1969-12-31
após ataque 2:   2030-01-01 → 1969-12-31

A classe é final, os campos são private final, não há setters, e o construtor valida que o início não vem depois do fim. Mesmo assim, o período terminou antes de começar. O final impede que o campo passe a apontar para outro objeto, mas não impede que o objeto apontado seja alterado. No primeiro ataque, quem criou o período guardou uma referência ao Date que passou e o alterou depois. No segundo, o acessor entregou a própria referência interna, e quem a recebeu a alterou. (A data 1969-12-31 é o instante zero, meia-noite de 1º de janeiro de 1970 em UTC, que no fuso de São Paulo deste computador ainda é 31 de dezembro.)

A defesa são as cópias defensivas, na entrada e na saída:

static final class Periodo {
    private final Date inicio;
    private final Date fim;

    Periodo(Date inicio, Date fim) {
        this.inicio = new Date(inicio.getTime());   // 1. copiar...
        this.fim = new Date(fim.getTime());
        if (this.inicio.after(this.fim))            // 2. ...e só então validar as cópias
            throw new IllegalArgumentException(this.inicio + " depois de " + this.fim);
    }
    Date inicio() { return new Date(inicio.getTime()); }   // 3. devolver cópias
    Date fim() { return new Date(fim.getTime()); }
    @Override public String toString() { return String.format("%tF → %tF", inicio, fim); }
}
após os ataques: 2026-09-01 → 2026-09-30

A ordem no construtor é deliberada: primeiro copiar, depois validar as cópias. Se a validação viesse antes, haveria uma janela entre a verificação e a cópia em que outra thread poderia alterar o Date original, e o objeto guardaria um valor que nunca foi validado. Esse tipo de defeito tem nome, tempo de verificação versus tempo de uso (time-of-check to time-of-use, TOCTOU), e é uma fonte clássica de falhas de segurança. Repare também que a cópia usa new Date(...), e não clone(): como Date não é final, o objeto recebido poderia ser uma subclasse maliciosa cujo clone() guardasse uma referência para si mesmo.

A solução moderna, claro, é não usar Date. Com o java.time de "Tempo, Dinheiro e Padrões", cujas classes são imutáveis, as cópias desaparecem, e um record basta:

import java.time.LocalDate;
import java.util.Objects;

record Periodo(LocalDate inicio, LocalDate fim) {
    Periodo {
        Objects.requireNonNull(inicio, "inicio");
        Objects.requireNonNull(fim, "fim");
        if (inicio.isAfter(fim)) throw new IllegalArgumentException(inicio + " depois de " + fim);
    }
    long dias() { return java.time.temporal.ChronoUnit.DAYS.between(inicio, fim) + 1; }
}
Periodo[inicio=2026-09-01, fim=2026-09-30] tem 30 dias
recusado: 2026-09-30 depois de 2026-09-01
recusado: inicio

A lição geral: a forma mais barata de defender um objeto é compô-lo com objetos imutáveis. Cópias defensivas são necessárias apenas nas fronteiras com componentes mutáveis, e cada tipo imutável que você usa é uma cópia a menos para lembrar.

A imutabilidade rasa dos records

"Dados sem Cerimônia" avisou que a imutabilidade de um record é rasa: os campos não podem ser reatribuídos, mas os objetos para os quais eles apontam podem ser alterados. Com listas e arrays, isso aparece de três formas:

import java.util.*;

record Turma(String nome, List<String> alunos) {}
record TurmaSegura(String nome, List<String> alunos) {
    TurmaSegura { alunos = List.copyOf(alunos); }
}
record Leitura(String sensor, double[] valores) {}
record LeituraSegura(String sensor, double[] valores) {
    LeituraSegura { valores = valores.clone(); }
    @Override public double[] valores() { return valores.clone(); }
    @Override public boolean equals(Object o) {
        return o instanceof LeituraSegura(String s, double[] v)
                && sensor.equals(s) && Arrays.equals(valores, v);
    }
    @Override public int hashCode() { return 31 * sensor.hashCode() + Arrays.hashCode(valores); }
    @Override public String toString() { return "LeituraSegura[" + sensor + ", " + Arrays.toString(valores) + "]"; }
}

void main() {
    List<String> nomes = new ArrayList<>(List.of("Ana", "Bruno"));
    Turma t = new Turma("1A", nomes);
    TurmaSegura s = new TurmaSegura("1A", nomes);
    nomes.add("Intruso");
    IO.println(t.alunos() + " | " + s.alunos());

    double[] v = {1.5, 2.5};
    Leitura a = new Leitura("t1", v), b = new Leitura("t1", new double[]{1.5, 2.5});
    IO.println("Leitura iguais? " + a.equals(b) + "  " + a);
    a.valores()[0] = 99;
    IO.println("depois de alterar pelo acessor: " + a.valores()[0]);

    double[] w = {1.5, 2.5};
    LeituraSegura c = new LeituraSegura("t1", w), d = new LeituraSegura("t1", new double[]{1.5, 2.5});
    w[1] = 0;
    c.valores()[0] = -1;
    IO.println("LeituraSegura iguais? " + c.equals(d) + "  " + c);
}
[Ana, Bruno, Intruso] | [Ana, Bruno]
Leitura iguais? false  Leitura[sensor=t1, valores=[D@4802796d]
depois de alterar pelo acessor: 99.0
LeituraSegura iguais? true  LeituraSegura[t1, [1.5, 2.5]]

O Turma recebeu um intruso pela lista original, e o TurmaSegura, com a cópia no construtor compacto, não. Para listas, List.copyOf resolve entrada e saída de uma vez: a cópia é imutável, então o acessor gerado pode devolvê-la diretamente, sem que ninguém consiga alterá-la.

Com arrays, a situação é pior, por três motivos que a saída mostra. O array é mutável e não existe um array imutável, então é preciso copiar na entrada e na saída. O equals gerado compara os componentes com equals, e para arrays isso é comparação de identidade: duas leituras com os mesmos valores são consideradas diferentes. E o toString gerado mostra [D@4802796d, o endereço do array, e não os valores. O LeituraSegura corrige os três, sobrescrevendo o acessor, o equals, o hashCode e o toString, e o equals usa o padrão de record de "Hierarquias Fechadas" para desmontar o outro objeto. É tanto código que a recomendação prática é outra: em records, prefira List<Double> ou um tipo imutável próprio a arrays. Arrays só compensam quando o desempenho foi medido e exige.

A diferença entre uma visão não modificável e uma cópia também merece atenção:

List<String> original = new ArrayList<>(List.of("java", "jvm"));
List<String> visao = Collections.unmodifiableList(original);
List<String> copia = List.copyOf(original);

try { visao.add("jdk"); } catch (UnsupportedOperationException e) { IO.println("visão recusa add"); }
original.add("jdk");                  // quem tem a lista original ainda pode alterá-la
IO.println("visão: " + visao + " | cópia: " + copia);

// copyOf de uma lista já imutável não copia nada
IO.println("copyOf(copia) é a mesma? " + (List.copyOf(copia) == copia));
try { List.copyOf(Arrays.asList("a", null)); }
catch (NullPointerException e) { IO.println("List.copyOf recusa null"); }
visão recusa add
visão: [java, jvm, jdk] | cópia: [java, jvm]
copyOf(copia) é a mesma? true
List.copyOf recusa null

O Collections.unmodifiableList não copia nada: devolve uma janela para a lista original que recusa alterações feitas por ela. Mas quem tem a lista original continua podendo alterá-la, e as mudanças aparecem pela janela. Ela serve para uma classe expor, sem risco, uma lista que ela mesma mantém e altera; não serve para proteger uma classe de quem lhe passou a lista. O List.copyOf faz uma cópia de verdade, e é esperto: se a lista recebida já for imutável, devolve a mesma, sem custo. E recusa null, o que costuma ser uma vantagem.

Mudar sem mutar

Objetos imutáveis ainda precisam representar mudanças. A técnica é o método que devolve uma cópia alterada, às vezes chamado de wither, por causa do prefixo with usado no java.time (data.withDayOfMonth(1)). O projeto de tarefas já faz isso desde "Do Arquivo Solto ao Projeto", com o concluir() que devolve uma nova Tarefa:

record Tarefa(int id, String titulo, LocalDate prazo, boolean concluida) {
    Tarefa concluir() { return new Tarefa(id, titulo, prazo, true); }
    Tarefa comPrazo(LocalDate novoPrazo) { return new Tarefa(id, titulo, novoPrazo, concluida); }
}

O construtor canônico, com suas validações, é chamado a cada cópia, então nenhuma "alteração" consegue produzir um objeto inválido. Para records com muitos componentes, escrever esses métodos à mão é repetitivo, e há uma proposta no OpenJDK, a JEP 468, para uma sintaxe de "criação derivada" que resolveria isso na linguagem. Ela ainda não faz parte do Java 25.

Programação defensiva: validar na fronteira

As cópias defensivas protegem o estado; a validação protege a entrada. O item 49 de Effective Java pede que cada método público verifique seus parâmetros no início e falhe imediatamente, com uma exceção clara, se forem inválidos. Isso se chama falhar cedo (fail fast):

import java.util.*;

record Pagina(List<String> itens, int numero, int tamanho) {
    Pagina {
        Objects.requireNonNull(itens, "itens");
        if (numero < 1) throw new IllegalArgumentException("numero deve ser >= 1: " + numero);
        if (tamanho < 1 || tamanho > 100) throw new IllegalArgumentException("tamanho fora de 1..100: " + tamanho);
        itens = List.copyOf(itens);
    }
    String item(int indice) {
        Objects.checkIndex(indice, itens.size());   // IndexOutOfBoundsException com mensagem clara
        return itens.get(indice);
    }
}

static int mediaInteira(int[] valores) {
    // Pré-condição de um método privado: verificada só com -ea
    assert valores.length > 0 : "chamado com array vazio";
    int soma = 0;
    for (int v : valores) soma += v;
    return soma / valores.length;
}

void main() {
    Pagina p = new Pagina(List.of("a", "b", "c"), 1, 10);
    IO.println(p.item(2));
    try { p.item(5); } catch (IndexOutOfBoundsException e) { IO.println(e.getMessage()); }
    try { new Pagina(List.of(), 0, 10); } catch (IllegalArgumentException e) { IO.println(e.getMessage()); }
    IO.println(Objects.requireNonNullElse(null, "padrão"));
    try {
        IO.println("média: " + mediaInteira(new int[0]));
    } catch (AssertionError e) {
        IO.println("AssertionError: " + e.getMessage());
    } catch (ArithmeticException e) {
        IO.println("sem -ea, o erro aparece longe da causa: " + e);
    }
}
$ java Validar.java
c
Index 5 out of bounds for length 3
numero deve ser >= 1: 0
padrão
sem -ea, o erro aparece longe da causa: java.lang.ArithmeticException: / by zero

$ java -ea Validar.java
...
AssertionError: chamado com array vazio

A classe Objects tem as ferramentas mais comuns: requireNonNull lança NullPointerException com o nome do parâmetro, checkIndex lança IndexOutOfBoundsException com uma mensagem padronizada, e requireNonNullElse troca um null por um valor padrão. Para os demais casos, a convenção do Java é usar IllegalArgumentException quando o valor do parâmetro é inválido e IllegalStateException quando o objeto não está num estado que permita a operação, como o iterator.remove() chamado duas vezes, ou o Subtask.get() antes do join() de "Tarefas em Família". A mensagem deve incluir o valor recebido: "numero deve ser >= 1: 0" poupa a quem lê o registro de erro o trabalho de reproduzir o problema.

O valor de falhar cedo aparece quando se compara com a alternativa. Uma página com número 0, aceita sem reclamação, provocaria mais adiante um cálculo de deslocamento negativo, e o erro surgiria como um IndexOutOfBoundsException numa linha que não tem nada a ver com a causa. Validar no construtor aponta o dedo para quem errou.

A palavra-chave assert é para outra coisa: verificar suposições internas que, se o código estiver correto, nunca deveriam falhar. As asserções vêm desligadas por padrão e só são verificadas com a opção -ea (enable assertions), então nunca devem ser usadas para validar parâmetros de métodos públicos, que precisam ser verificados sempre. Elas servem para métodos privados, cujas chamadas estão todas sob controle da própria classe, e para invariantes que documentam o raciocínio do código. Nos testes, é comum rodar com -ea, e o Surefire de "Construção Automatizada" já faz isso por padrão.

Programação defensiva não é paranoia. Validar na fronteira, nos construtores e métodos públicos, permite que o código interno confie nos dados que recebe, sem repetir as mesmas verificações em cada método privado. É a mesma ideia do construtor compacto dos records: depois que o objeto existe, ele é válido, e ninguém mais precisa perguntar.

O erro de um bilhão de dólares

Das entradas inválidas, a mais comum é o null. Em 2009, numa palestra famosa, Tony Hoare, que introduziu a referência nula na linguagem ALGOL W em 1965, chamou a invenção de "o meu erro de um bilhão de dólares", pelos defeitos, falhas e custos que ela causou em quarenta anos. No Java, o problema está no sistema de tipos: uma variável String pode conter uma String ou null, e nada na declaração diz qual das duas possibilidades o autor pretendia.

"Um Valor que Pode Não Existir" apresentou uma resposta parcial, o Optional, que torna explícita a ausência de um valor de retorno. Mas o Optional não foi feito para campos, parâmetros ou elementos de coleções, e não ajuda com o código que já existe. A resposta que a comunidade buscou por duas décadas foram as anotações de nulidade: marcar cada tipo com a informação de que ele pode ou não ser null, e deixar uma ferramenta verificar o programa.

O problema é que surgiram muitas anotações incompatíveis. A JSR 305, de 2006, tentou padronizá-las com javax.annotation.Nullable, mas foi abandonada sem nunca ser concluída. Enquanto isso, cada ferramenta criou a sua: a JetBrains com org.jetbrains.annotations.Nullable, o Android com androidx.annotation.Nullable, o Checker Framework, o Eclipse, o Spring, o FindBugs, cada um com semântica ligeiramente diferente. Uma biblioteca anotada para uma ferramenta era ignorada pelas outras.

JSpecify: um padrão, finalmente

O JSpecify é a resposta coletiva a essa confusão. É um grupo formado em 2019 por representantes de Google, JetBrains, Meta, Microsoft, Oracle, Uber, Broadcom (Spring), Sonar e outras organizações, que definiu uma especificação precisa para anotações de nulidade, com um artefato mínimo, org.jspecify:jspecify, que contém apenas quatro anotações. A versão 1.0.0 foi publicada em julho de 2024.

As quatro anotações são @Nullable (este tipo pode conter null), @NonNull (este tipo não pode conter null), @NullMarked (dentro deste escopo, todo tipo sem anotação é não nulo) e @NullUnmarked (desfaz o efeito de @NullMarked, para código ainda não revisado). A peça central é o @NullMarked: em vez de anotar cada parâmetro não nulo, que são a grande maioria, você declara uma vez que um pacote, classe ou módulo inteiro é "não nulo por padrão" e marca apenas as exceções com @Nullable.

No projeto de tarefas, a dependência entra no pom.xml:

<dependency>
    <groupId>org.jspecify</groupId>
    <artifactId>jspecify</artifactId>
    <version>1.0.0</version>
</dependency>

E o pacote é marcado no arquivo especial package-info.java, que existe para anotações e documentação de pacotes:

// Tudo neste pacote é não nulo, a menos que marcado com @Nullable
@NullMarked
package br.com.exemplo.tarefas.servico;

import org.jspecify.annotations.NullMarked;

A partir daí, os contratos ficam na assinatura:

package br.com.exemplo.tarefas.servico;

import java.util.*;
import org.jspecify.annotations.Nullable;

public class Catalogo {
    private final Map<String, String> apelidos = new HashMap<>();

    public void registrar(String nome, @Nullable String apelido) {
        if (apelido != null) apelidos.put(nome, apelido);
    }

    // Pode devolver null: a assinatura avisa
    public @Nullable String apelido(String nome) {
        return apelidos.get(nome);
    }

    public String saudacao(String nome) {
        String apelido = apelido(nome);
        // return "Oi, " + apelido.toUpperCase();   // um verificador acusaria: apelido pode ser null
        return "Oi, " + (apelido != null ? apelido.toUpperCase() : nome);
    }

    public List<@Nullable String> apelidosDe(List<String> nomes) {
        List<@Nullable String> resultado = new ArrayList<>();
        for (String n : nomes) resultado.add(apelido(n));
        return resultado;
    }
}
Oi, ANINHA / Oi, Bruno
[Aninha, null]

Lendo só as assinaturas, sem abrir a implementação, qualquer pessoa sabe que registrar exige um nome e aceita apelido nulo, que apelido pode devolver null e que saudacao nunca devolve. As anotações do JSpecify são anotações de uso de tipo, declaradas com o alvo TYPE_USE no @Target visto em "O Programa que se Examina", e por isso podem aparecer em qualquer lugar onde um tipo aparece: List<@Nullable String> é uma lista não nula cujos elementos podem ser nulos, algo que as anotações antigas não conseguiam expressar. A posição importa: @Nullable String[] é um array não nulo de strings anuláveis, e String @Nullable [] é um array anulável de strings não nulas.

Por si sós, as anotações não fazem nada em tempo de execução: o código acima compila e roda igual com ou sem elas. O valor delas está em quem as lê.

Quem lê as anotações

O primeiro leitor é o IDE. O IntelliJ IDEA entende o JSpecify e sublinha, enquanto você digita, a chamada apelido.toUpperCase() na linha comentada, avisando que o valor pode ser nulo.

O segundo é um verificador na compilação, que transforma o contrato em erro de compilação. O mais usado é o NullAway, criado pela Uber, que roda como um plugin do Error Prone, o analisador estático do Google que se acopla ao javac. Com o Maven, a configuração fica no plugin de compilação:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <configuration>
        <compilerArgs>
            <arg>-XDcompilePolicy=simple</arg>
            <arg>--should-stop=ifError=FLOW</arg>
            <arg>-Xplugin:ErrorProne -Xep:NullAway:ERROR -XepOpt:NullAway:OnlyNullMarked=true</arg>
        </compilerArgs>
        <annotationProcessorPaths>
            <path>
                <groupId>com.google.errorprone</groupId>
                <artifactId>error_prone_core</artifactId>
                <version>2.41.0</version>
            </path>
            <path>
                <groupId>com.uber.nullaway</groupId>
                <artifactId>nullaway</artifactId>
                <version>0.12.10</version>
            </path>
        </annotationProcessorPaths>
    </configuration>
</plugin>

A opção OnlyNullMarked faz o NullAway verificar apenas o código marcado com @NullMarked, o que permite adotar a verificação aos poucos, pacote por pacote, e o -Xep:NullAway:ERROR transforma os achados em erros: sem ele, o NullAway só emite avisos, e a compilação termina normalmente. O Error Prone precisa ainda de um arquivo .mvn/jvm.config com algumas opções --add-exports para acessar o interior do compilador, listadas na página de instalação do projeto. As versões acima são de referência, e mudam com frequência: confira as mais recentes antes de usar. Com tudo configurado, descomentar a linha da saudacao faz a compilação falhar com uma mensagem na forma [NullAway] dereferenced expression apelido is @Nullable, e passar null como nome para registrar produz [NullAway] passing @Nullable parameter 'null' where @NonNull is required. Um NullPointerException que apareceria em produção vira um erro que o compilador aponta antes de o programa existir.

O terceiro leitor é o Kotlin, que trata os tipos de bibliotecas Java anotadas com JSpecify como tipos anuláveis ou não anuláveis da própria linguagem, uma vantagem direta para a Trilha B, onde o Kotlin aparece no Android. E o ecossistema está aderindo: o Spring Framework 7, lançado em novembro de 2025, anotou toda a sua API com JSpecify, e bibliotecas do Google, como o Guava, seguem o mesmo caminho.

A pergunta natural é por que o Java não resolve isso na própria linguagem. Há um esforço nessa direção no Projeto Valhalla, com uma proposta de tipos restritos a não nulos, em que String! significaria uma string que não pode ser null, verificada pelo compilador e pela JVM. Até o Java 25, essa proposta ainda não chegou nem à fase de prévia. Enquanto isso, o JSpecify é o padrão, e a especificação foi escrita para que a migração futura, se vier, seja natural.

Quando usar Optional, e quando @Nullable

Com as duas ferramentas disponíveis, a divisão de trabalho recomendada é simples. Para o retorno de um método público que pode não ter resultado, como buscarPorId, o Optional continua sendo uma boa escolha, porque obriga quem chama a lidar com a ausência e oferece operações como map e orElse. Para campos, parâmetros e elementos de coleções, onde o Optional não deve ser usado, @Nullable é a ferramenta. E dentro de um escopo @NullMarked, a regra de ouro é que @Nullable seja raro: cada ocorrência é um lugar em que o código precisa tratar a ausência, e se elas se espalham por toda parte, o desenho provavelmente precisa de um valor padrão, um objeto vazio ou um tipo selado que represente os casos explicitamente, como em "Hierarquias Fechadas".

Imutabilidade, cópias defensivas, validação e anotações de nulidade têm um objetivo comum: fazer o código dizer o que promete e garantir que a promessa seja cumprida, de preferência antes de o programa rodar.

Fontes e leituras recomendadas

  • Joshua Bloch, Effective Java, 3ª edição (Addison-Wesley, 2018), itens 17, 49 e 50: "Minimize mutability", "Check parameters for validity" e "Make defensive copies when needed", incluindo o ataque ao Period que inspirou o exemplo desta aula.
  • JSpecify, "User Guide": o guia oficial das quatro anotações, com as regras de @NullMarked, anotações em genéricos e arrays, e a adoção gradual. https://jspecify.dev/docs/user-guide/
  • NullAway (Uber), repositório e documentação: como instalar com Maven e Gradle, as opções de configuração e o suporte ao JSpecify. https://github.com/uber/NullAway
  • Error Prone, "Installation": a configuração do analisador para Maven, Gradle e Bazel, com o jvm.config necessário para JDKs recentes. https://errorprone.info/docs/installation
  • Tony Hoare, "Null References: The Billion Dollar Mistake" (QCon London, 2009): a palestra em que o criador da referência nula fala sobre ela. https://www.infoq.com/presentations/Null-References-The-Billion-Dollar-Mistake-Tony-Hoare/

Exercícios

Exercício 1

Aponte todos os problemas de imutabilidade da classe abaixo e corrija-a.

public class Carrinho {
    private final List<String> itens;
    public LocalDateTime criadoEm;
    public Carrinho(List<String> itens) { this.itens = itens; this.criadoEm = LocalDateTime.now(); }
    public List<String> itens() { return itens; }
    public void setCriadoEm(LocalDateTime d) { criadoEm = d; }
}
Ver resposta

✓ Resposta: São cinco problemas. A classe não é final, e uma subclasse poderia acrescentar estado mutável ou sobrescrever itens(). O campo criadoEm é public, e qualquer código pode alterá-lo diretamente. Ele também não é final, e ainda existe um setter para ele. O construtor guarda a lista recebida sem copiá-la, e quem a passou pode alterá-la depois. E o acessor itens() devolve a referência interna, permitindo carrinho.itens().add(...). A versão corrigida, como record:

public record Carrinho(List<String> itens, LocalDateTime criadoEm) {
    public Carrinho {
        itens = List.copyOf(itens);                      // cópia imutável: protege entrada e saída
        Objects.requireNonNull(criadoEm, "criadoEm");
    }
    public static Carrinho novo(List<String> itens) {
        return new Carrinho(itens, LocalDateTime.now());
    }
    public Carrinho comItem(String item) {               // "alterar" devolve uma cópia
        var novos = new ArrayList<>(itens);
        novos.add(item);
        return new Carrinho(novos, criadoEm);
    }
}

O LocalDateTime é imutável e dispensa cópia. O List.copyOf resolve a entrada e a saída de uma vez, porque a lista guardada é imutável. A data de criação passou a ser um componente comum, e a fábrica novo preserva a conveniência de preenchê-la com a hora atual. Receber a data de fora tem um bônus: nos testes, é possível criar um carrinho com uma data fixa, como no Clock de "Dublês e Disciplina".

Exercício 2

No construtor do Periodo com cópias defensivas, explique com uma sequência concreta de eventos o que poderia dar errado se a validação inicio.after(fim) fosse feita antes das cópias.

Ver resposta

✓ Resposta: Suponha duas threads compartilhando os objetos Date inicio (01/09) e fim (30/09). A thread A chama new Periodo(inicio, fim). O construtor valida primeiro: inicio.after(fim) é falso, então a validação passa. Antes de o construtor executar new Date(fim.getTime()), o escalonador pausa a thread A. A thread B executa fim.setTime(0), levando o fim para 1969. A thread A volta e copia fim, agora com o valor de 1969. O período guardado tem início em 2026 e fim em 1969, um objeto inválido que passou pela validação, e que, por ser imutável dali em diante, vai ficar inválido para sempre. Copiando primeiro, o construtor trabalha com objetos que só ele conhece: a thread B pode alterar o original à vontade, mas a validação e o armazenamento usam as cópias, e o que foi validado é exatamente o que foi guardado. É o defeito TOCTOU: o valor verificado não é o valor usado. O mesmo padrão aparece em segurança de sistemas de arquivos, quando um programa verifica as permissões de um arquivo e depois o abre, e um atacante troca o arquivo entre as duas operações.

Exercício 3

Sem executar, diga o que o trecho imprime e explique.

List<String> base = new ArrayList<>(List.of("a"));
List<String> v = Collections.unmodifiableList(base);
List<String> c1 = List.copyOf(base);
List<String> c2 = List.copyOf(v);
base.add("b");
IO.println(v + " " + c1 + " " + c2 + " " + (List.copyOf(c1) == c1));
Ver resposta

✓ Resposta: Imprime [a, b] [a] [a] true. A visão v é uma janela para base, e o "b" acrescentado a base aparece por ela. O c1 é uma cópia feita quando base só tinha "a", e não muda. O c2 merece atenção: v é uma lista não modificável, mas não imutável, porque o conteúdo dela pode mudar pela lista original. O List.copyOf sabe disso e faz uma cópia de verdade, que também ficou com [a]. Ele só evita a cópia quando recebe uma lista criada pelas próprias fábricas imutáveis (List.of, List.copyOf, Stream.toList()), cujo conteúdo é garantidamente fixo. Por isso List.copyOf(c1) == c1 é true: c1 já é imutável, e copiá-la seria desperdício. A lição é que "não modificável" diz o que você pode fazer com a referência que tem, enquanto "imutável" diz o que ninguém pode fazer com o objeto.

Exercício 4

Um repositório tem o método Tarefa buscar(int id), que devolve null quando a tarefa não existe, num pacote @NullMarked. Proponha duas assinaturas melhores, uma com JSpecify e outra com Optional, e diga qual você escolheria para uma API pública.

Ver resposta

✓ Resposta: Num pacote @NullMarked, a assinatura atual é uma mentira: sem anotação, o tipo de retorno é não nulo, mas o método devolve null, e um verificador como o NullAway acusaria o return null como erro. A primeira alternativa é tornar o contrato honesto com @Nullable Tarefa buscar(int id): quem chama é obrigado pelo verificador a testar o resultado antes de usá-lo. A segunda é Optional<Tarefa> buscar(int id), que obriga quem chama a lidar com a ausência mesmo sem ferramenta nenhuma, e oferece map, orElse e orElseThrow para isso. Para uma API pública, eu escolheria o Optional: o contrato é visível para qualquer pessoa e verificado pelo próprio compilador, sem depender de o usuário da API ter configurado o NullAway, e é o estilo recomendado em "Um Valor que Pode Não Existir" para retornos que podem estar ausentes. O @Nullable seria preferível em código interno onde o desempenho importa muito, ou para manter compatibilidade com uma API existente que já devolve null. Uma terceira opção, dependendo do uso, é ter dois métodos: Optional<Tarefa> buscar(int id) e Tarefa obter(int id), que lança uma exceção se não encontrar, para os casos em que a ausência é um erro.

Exercício 5

Explique por que new Leitura("t1", v).equals(new Leitura("t1", w)) deu false mesmo com arrays de mesmo conteúdo, e por que, para corrigir, não basta sobrescrever o equals.

Ver resposta

✓ Resposta: O equals gerado para um record compara cada componente com o equals do tipo do componente, e arrays não sobrescrevem o equals de Object, que compara identidade. Dois arrays distintos com os mesmos valores são objetos diferentes, então o equals do record dá false. Sobrescrever só o equals, usando Arrays.equals, quebra o contrato de "Iguais ou o Mesmo?": dois objetos iguais precisam ter o mesmo hashCode, e o hashCode gerado usa o hashCode de identidade dos arrays, diferente para arrays diferentes. Duas leituras iguais cairiam em posições diferentes de um HashSet, e o conjunto guardaria as duas. Por isso o hashCode também precisa ser sobrescrito, com Arrays.hashCode. E mesmo com os dois corrigidos, o objeto ainda não é confiável sem as cópias defensivas: se o array guardado puder ser alterado por fora, o hashCode de uma leitura muda depois de ela entrar num HashSet, e ela se perde lá dentro. Ainda falta o toString, que mostraria o endereço do array. São quatro métodos para compensar um componente, o que é o melhor argumento para usar List<Double> ou um tipo imutável em records, e reservar arrays para quando medições mostrarem que são necessários.

Comentários

Mais em Java

Pare de Escrever Laços — a API de Streams
Pare de Escrever Laços — a API de Streams

Como trocar laços pela API de Streams do Java: fontes, operações…

Ponte para o Nativo — a Foreign Function & Memory API
Ponte para o Nativo — a Foreign Function & Memory API

Como chamar funções em C a partir do Java com a Foreign Function & Memory API…

Extensão — Um App Desktop que Consome a API (MVVM)
Extensão — Um App Desktop que Consome a API (MVVM)

Nesta aula a janela de catálogo passa a falar com a API REST das Trilhas da…