Best Practice Java sull'Immutabilità
Perché l'immutabilità è un buon default in Java e i pattern per costruire tipi immutabili in modo sicuro.
Un oggetto immutabile è un oggetto il cui stato non può cambiare dopo la costruzione. Questa singola proprietà elimina un'intera classe di bug: non ci sono mutazioni a sorpresa da un altro thread, nessuna sorpresa di aliasing in cui due variabili condividono lo stato, e nessuna necessità di copie difensive ad ogni lettura. In Java, l'immutabilità non è automatica — bisogna costruirla deliberatamente. Questo capitolo tratta i pattern che rendono un tipo veramente immutabile e le abitudini che lo mantengono tale.
Perché l'immutabilità è un buon default
Lo stato mutabile condiviso è la radice della maggior parte dei bug di concorrenza e di un numero sorprendente di quelli single-thread. Quando un oggetto non può cambiare, puoi passarlo liberamente, metterlo in cache e ragionare su di esso senza dover tracciare chi else tiene un riferimento.
| Proprietà | Oggetto mutabile | Oggetto immutabile |
|---|---|---|
| Thread safety | Richiede lock o attenzione | Intrinsecamente thread-safe |
| Sicuro da condividere | No — i chiamanti possono mutare | Sì — distribuisci la stessa istanza |
| Sicuro come chiave di mappa | Rischioso — hashCode può variare | Sì — l'identità è stabile |
| Caching | Bisogna invalidare al cambiamento | Cacheable indefinitamente |
| Ragionamento | Traccia ogni scrittura | Il valore è fisso alla costruzione |
Il costo è l'allocazione: cambiare un campo significa creare un nuovo oggetto. Per la maggior parte del codice questo costo è trascurabile e la sicurezza ne vale la pena. Ricorri alla mutabilità solo quando il profiling dimostra che ne hai bisogno.
Le cinque regole per una classe immutabile
Una classe è immutabile quando tutte le seguenti condizioni sono soddisfatte. Mancarne una e un chiamante potrà intervenire e modificare lo stato.
- La classe è
final(o tutti i costruttori sono privati) in modo che non possa essere sottoclassata con comportamento mutabile. - Tutti i campi sono
private final. - Non ci sono setter — né altri metodi che modificano un campo.
- I campi mutabili sono copiati difensivamente in ingresso affinché il riferimento del chiamante non possa essere usato per mutare il tuo stato.
- I getter non espongono mai direttamente un oggetto interno mutabile — restituiscono una copia o una vista non modificabile.
public final class Money {
private final long cents;
private final String currency;
public Money(long cents, String currency) {
this.cents = cents;
this.currency = currency;
}
public long cents() { return cents; }
public String currency() { return currency; }
// "Change" returns a new object instead of mutating this one.
public Money plus(Money other) {
return new Money(this.cents + other.cents, currency);
}
}Copie difensive per i campi mutabili
I primitivi e String sono già immutabili, quindi memorizzarli è sicuro. Il pericolo sono i campi mutabili — array, collezioni, date. Se memorizzi direttamente il riferimento del chiamante, questi mantiene un accesso alle tue strutture interne.
public final class Schedule {
private final List<String> slots;
public Schedule(List<String> slots) {
// Copy IN: the caller can't mutate our list later.
this.slots = List.copyOf(slots);
}
public List<String> slots() {
// copyOf already returns an unmodifiable list, so this is safe to hand out.
return slots;
}
}List.copyOf, Set.copyOf e Map.copyOf (Java 10+) svolgono entrambi i compiti contemporaneamente: copiano i dati e restituiscono una vista non modificabile. Per gli array, usa array.clone() in ingresso e clone() nuovamente in uscita, poiché gli array sono sempre mutabili e non hanno un wrapper di sola lettura.
Record: immutabilità per costruzione
Un record (Java 16+) è il modo più conciso per dichiarare un portatore immutabile di dati. Il compilatore genera campi private final, un costruttore canonico, accessor e equals/hashCode/toString basati sul valore.
public record Point(int x, int y) {
// Compact constructor for validation and defensive copying.
public Point {
if (x < 0 || y < 0) {
throw new IllegalArgumentException("coordinates must be non-negative");
}
}
}I record coprono il caso comune in modo eccellente, ma non sono uno scudo magico: se un componente di un record è di tipo mutabile (come List), devi comunque copiarlo difensivamente nel costruttore compatto, perché l'accessor generato restituisce il riferimento memorizzato così com'è.
Produrre copie modificate: il pattern "wither"
Poiché non puoi mutare un oggetto immutabile, crei una copia modificata. La convenzione è un metodo withX che restituisce una nuova istanza con un campo cambiato e il resto copiato.
public final class User {
private final String name;
private final String email;
public User(String name, String email) {
this.name = name;
this.email = email;
}
public User withEmail(String newEmail) {
return new User(this.name, newEmail); // new object, original untouched
}
}Questo mantiene l'originale sicuro da condividere mentre permette ai chiamanti di costruire varianti. È lo stesso modello che il JDK usa internamente — LocalDate.plusDays, String.replace e BigDecimal.add restituiscono tutti nuove istanze invece di mutare il ricevente.
Un esempio completo eseguibile
Il programma seguente costruisce un piccolo Account immutabile, poi tenta ogni trucco che un chiamante potrebbe usare per mutarlo — passando una lista e mutando l'originale, mutando la lista restituita e rinominando. Dimostra che ogni difesa regge, poi mostra perché i valori immutabili sono chiavi di mappa sicure.
Cosa ricavare dall'esecuzione:
Roles after mutating source: [read, write]dimostra che la copia difensiva ha funzionato — aggiungereadminalla lista originale non ha mai raggiunto l'Account.- L'
UnsupportedOperationExceptionsuacc.roles().add("hacker")mostra che il getter ha restituito una vista non modificabile, quindi i chiamanti non possono mutare gli interni attraverso di esso. Original name still: Adaaccanto aNew object name: Gracedimostra chewithNameha prodotto una copia lasciando l'originale intatto.Different instance: trueconferma che il wither ha restituito un oggetto genuinamente nuovo anziché lo stesso riferimento.Records equal by value: trueeKey still found: origin-ishmostrano che i valori immutabili si confrontano e si hashano per contenuto, rendendoli chiaviHashMapaffidabili.