Classes Imutáveis em Java
Projete classes imutáveis em Java com campos final, cópias defensivas e sem setters.
Uma classe imutável é aquela cujas instâncias não podem ser alteradas após a construção. String, Integer, LocalDate, BigDecimal, UUID — a biblioteca padrão do Java está cheia delas, e não por acidente. Objetos imutáveis são seguros para compartilhar entre threads, seguros para usar como chaves de HashMap, seguros para armazenar em cache e fáceis de raciocinar: uma vez que você os viu, você sabe o estado deles pelo restante de sua vida.
Tornar uma classe imutável não se trata de adicionar uma única palavra-chave — trata-se de seguir um conjunto de regras em conjunto. Perca uma e você terá uma classe que parece imutável, mas não é.
As cinco regras
Para tornar uma classe genuinamente imutável:
- Declare a classe como
final(ou use apenas construtores privados). Caso contrário, uma subclasse pode quebrar o contrato. - Torne cada campo
private final.finalimpede a reatribuição após a construção;privateimpede que os chamadores os toquem diretamente. - Não exponha setters. Qualquer método de mutação (
add,set,clear,reset) está fora. - Copie defensivamente entradas mutáveis no construtor. Se o chamador passar um
Dateou umaList, copie-o — caso contrário, ele pode mutá-lo de fora e seu objeto "imutável" muda por baixo de você. - Copie defensivamente retornos mutáveis nos getters — pela mesma razão, ao contrário.
Uma classe que faz as cinco é profundamente imutável. Perca qualquer uma delas e a garantia vaza.
final sozinho não é imutabilidade. Um campo final não pode ser reatribuído, mas se ele apontar para um objeto mutável — uma List, um array, um Date — esse objeto ainda pode mudar. final List<String> tags significa que você não pode trocar a lista por uma diferente, não que o conteúdo da lista esteja congelado. As regras 4 e 5 existem precisamente para fechar essa lacuna. Veja Java final keyword para o que final promete e não promete.
O exemplo mínimo
Para uma classe cujos campos são todos primitivos ou já imutáveis, as regras se reduzem a quase nada:
public final class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int x() { return x; }
public int y() { return y; }
}int é um primitivo, portanto não há nada a copiar defensivamente. A classe é final, os campos são private final, não existem setters. Pronto.
Campos mutáveis precisam de cópias defensivas
O problema começa quando um campo é em si mutável — um array, um Date, um ArrayList. Se você armazenar a referência do chamador diretamente, ele mantém um identificador para ela e pode mutar seus internos:
// Broken: the array is shared
public final class Trajectory {
private final double[] points;
public Trajectory(double[] points) { this.points = points; }
public double[] points() { return points; }
}
double[] arr = {1.0, 2.0, 3.0};
Trajectory t = new Trajectory(arr);
arr[0] = 999; // mutates the "immutable" object!
System.out.println(t.points()[0]); // 999A correção é copiar na entrada e na saída:
public final class Trajectory {
private final double[] points;
public Trajectory(double[] points) {
this.points = points.clone(); // copy in
}
public double[] points() {
return points.clone(); // copy out
}
}Para coleções, o equivalente é List.copyOf(other) (retorna uma lista não modificável suportada por uma cópia):
public final class Recipe {
private final String name;
private final List<String> steps;
public Recipe(String name, List<String> steps) {
this.name = name;
this.steps = List.copyOf(steps); // copy + unmodifiable view
}
public List<String> steps() { return steps; } // already unmodifiable
}Observe a assimetria com o exemplo do array: o clone() de um array produz uma cópia mutável, então você deve copiar novamente na saída. List.copyOf produz uma lista não modificável, então o getter pode entregá-la diretamente — qualquer chamador que tente mutá-la recebe uma UnsupportedOperationException. Prefira tipos de coleção imutáveis quando puder; eles eliminam toda uma classe de erros de cópia na saída.
"Modificações" retornam novas instâncias
Uma classe imutável ainda pode suportar mudanças — retornando uma nova instância:
public final class Money {
private final long cents;
public Money plus(Money other) { return new Money(cents + other.cents); }
public Money times(int factor) { return new Money(cents * factor); }
// constructor + accessors omitted
}A convenção nomeia o método with... quando ele produz uma cópia com um campo alterado: point.withX(5), user.withEmail("..."). A API de data/hora do Java usa esse padrão de forma consistente — LocalDate.plusDays(7), LocalDate.withYear(2026).
Por que isso importa
Objetos imutáveis oferecem:
- Thread safety de graça. Sem bloqueios, sem
volatile, sem surpresas de visibilidade — não há nada para sincronizar porque o estado não pode mudar. - Compartilhamento e cache seguros. Dois chamadores que têm o mesmo
Money(2000, "USD")não podem interferir um com o outro. - Chaves de hash confiáveis. Como os campos usados em
hashCodenão podem mudar, o bucket do objeto nunca fica desatualizado. Uma chave mutável cujo hash muda depois que ela é armazenada em umHashMapfica efetivamente perdida — veja Java equals and hashCode. - Raciocínio mais fácil. Uma vez que você viu um objeto imutável, você sabe o que ele fará pelo restante de sua vida. Sem arqueologia de "onde isso foi mutado?".
O custo é alocar novas instâncias para cada "modificação". Para objetos pequenos e frequentemente usados (String, Integer), isso raramente é um problema; a JVM é muito boa em alocações de curta duração. Para casos genuinamente caros, existem técnicas específicas (string builders, estruturas de dados persistentes) — mas recorra a elas apenas quando o profiling mostrar um problema real.
Records fazem a maior parte do trabalho
Um record é implicitamente final, tem campos private final, gera acessadores sem setters e oferece equals/hashCode/toString de graça:
public record Point(int x, int y) {}Isso é profundamente imutável, desde que os próprios componentes sejam imutáveis. Para records que contêm um componente mutável (uma List, um array), você ainda precisa de um construtor compacto que copie defensivamente:
public record Recipe(String name, List<String> steps) {
public Recipe {
steps = List.copyOf(steps);
}
}Quando records se encaixam, eles são o caminho mais curto para uma classe imutável correta.
Um exemplo prático
O que vem a seguir
Classes imutáveis são sobre controlar mudanças. O capítulo final da Parte 6 é sobre controlar quantidade — uma classe projetada para que apenas uma instância exista. Continue em Java singleton pattern.