Classi immutabili in Java
Progetta classi immutabili in Java con campi final, copie difensive e nessun setter.
Una classe immutabile è quella le cui istanze non possono cambiare dopo la costruzione. String, Integer, LocalDate, BigDecimal, UUID — la libreria standard di Java ne è piena, e non per caso. Gli oggetti immutabili sono sicuri da condividere tra thread, sicuri da usare come chiavi di HashMap, sicuri da memorizzare nella cache e facili da ragionare: una volta visto uno, conosci il suo stato per il resto della sua vita.
Rendere una classe immutabile non significa aggiungere una singola parola chiave — significa seguire insieme una manciata di regole. Mancare anche solo una e si ottiene una classe che sembra immutabile ma non lo è.
Le cinque regole
Per rendere una classe genuinamente immutabile:
- Dichiara la classe
final(o usa solo costruttori privati). Altrimenti una sottoclasse può rompere il contratto. - Rendi ogni campo
private final.finalimpedisce la riassegnazione dopo la costruzione;privateimpedisce ai chiamanti di toccarli direttamente. - Non esporre setter. Qualsiasi metodo di mutazione (
add,set,clear,reset) è escluso. - Copia difensivamente gli input mutabili nel costruttore. Se il chiamante passa una
Dateo unaList, copiala — altrimenti può mutarla dall'esterno e il tuo oggetto "immutabile" cambia sotto di te. - Copia difensivamente i ritorni mutabili nei getter — per la stessa ragione al contrario.
Una classe che soddisfa tutti e cinque è profondamente immutabile. Mancare anche solo uno e la garanzia si incrina.
final da solo non è immutabilità. Un campo final non può essere riassegnato, ma se punta a un oggetto mutabile — una List, un array, una Date — quell'oggetto può ancora cambiare. final List<String> tags significa che non puoi sostituire la lista con un'altra, non che il contenuto della lista sia congelato. Le regole 4 e 5 esistono proprio per colmare questa lacuna. Vedi Java final keyword per quello che final promette e non promette.
L'esempio minimale
Per una classe i cui campi sono tutti primitivi o già immutabili, le regole si riducono a quasi nulla:
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 è un primitivo, quindi non c'è nulla da copiare difensivamente. La classe è final, i campi sono private final, non esistono setter. Fatto.
I campi mutabili richiedono copie difensive
Il problema inizia quando un campo è esso stesso mutabile — un array, una Date, un ArrayList. Se memorizzi direttamente il riferimento del chiamante, questi mantiene un handle su di esso e può mutare i tuoi interni:
// 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]); // 999La soluzione è copiare all'ingresso e all'uscita:
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
}
}Per le collezioni, l'equivalente è List.copyOf(other) (restituisce una lista non modificabile supportata da una copia):
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
}Nota l'asimmetria rispetto all'esempio con l'array: il clone() di un array produce una copia mutabile, quindi devi copiare di nuovo all'uscita. List.copyOf produce una lista non modificabile, quindi il getter può restituirla direttamente — qualsiasi chiamante che tenta di mutarla ottiene un UnsupportedOperationException. Preferisci i tipi di collezione immutabili quando puoi; eliminano un'intera categoria di errori di copia in uscita.
Le "modifiche" restituiscono nuove istanze
Una classe immutabile può ancora supportare il cambiamento — restituendo una nuova istanza:
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
}Per convenzione il metodo si chiama with... quando produce una copia con un campo cambiato: point.withX(5), user.withEmail("..."). L'API date/time di Java usa questo pattern in modo coerente — LocalDate.plusDays(7), LocalDate.withYear(2026).
Perché questo è importante
Gli oggetti immutabili ti offrono:
- Thread safety gratuita. Nessun lock, nessun
volatile, nessuna sorpresa di visibilità — non c'è nulla da sincronizzare perché lo stato non può cambiare. - Condivisione e caching sicuri. Due chiamanti che detengono lo stesso
Money(2000, "USD")non possono interferire tra loro. - Chiavi hash affidabili. Poiché i campi usati in
hashCodenon possono cambiare, il bucket dell'oggetto non diventa mai obsoleto. Una chiave mutabile il cui hash cambia dopo che è stata memorizzata in unaHashMapè di fatto persa — vedi Java equals and hashCode. - Ragionamento più semplice. Una volta visto un oggetto immutabile, sai cosa farà per il resto della sua vita. Nessuna "archeologia" del tipo "dove è stato mutato questo?".
Il costo è allocare nuove istanze per ogni "modifica". Per oggetti piccoli e usati frequentemente (String, Integer), raramente è un problema; la JVM è molto brava con le allocazioni di breve durata. Per i casi genuinamente costosi esistono tecniche specifiche (string builder, strutture dati persistenti) — ma ricorri a esse solo quando il profiling mostra un problema reale.
I record fanno la maggior parte del lavoro
Un record è implicitamente final, ha campi private final, genera accessor senza setter e ti fornisce gratuitamente equals/hashCode/toString:
public record Point(int x, int y) {}Questo è profondamente immutabile finché i componenti stessi sono immutabili. Per i record che contengono un componente mutabile (una List, un array), hai ancora bisogno di un costruttore compatto che copia difensivamente:
public record Recipe(String name, List<String> steps) {
public Recipe {
steps = List.copyOf(steps);
}
}Quando i record si adattano, sono il percorso più breve verso una classe immutabile corretta.
Un esempio pratico
Cosa c'è dopo
Le classi immutabili riguardano il controllo del cambiamento. Il capitolo finale della Parte 6 riguarda il controllo della quantità — una classe progettata in modo che esista solo una istanza. Continua con Java singleton pattern.