Classes immuables en Java
Concevez des classes immuables en Java avec des champs final, des copies défensives et sans setters.
Une classe immuable est une classe dont les instances ne peuvent pas changer après leur construction. String, Integer, LocalDate, BigDecimal, UUID — la bibliothèque standard de Java en regorge, et ce n'est pas un hasard. Les objets immuables peuvent être partagés entre threads en toute sécurité, utilisés comme clés de HashMap, mis en cache, et ils sont faciles à raisonner : une fois que vous en avez vu un, vous connaissez son état pour le reste de sa vie.
Rendre une classe immuable ne consiste pas à ajouter un seul mot-clé — il s'agit de suivre un ensemble de règles ensemble. En manquer une seule et vous obtenez une classe qui semble immuable mais ne l'est pas.
Les cinq règles
Pour rendre une classe véritablement immuable :
- Déclarez la classe
final(ou n'utilisez que des constructeurs privés). Sinon, une sous-classe peut briser le contrat. - Rendez chaque champ
private final.finalempêche la réaffectation après la construction ;privateempêche les appelants d'y accéder directement. - N'exposez pas de setters. Toute méthode de mutation (
add,set,clear,reset) est à exclure. - Copiez défensivement les entrées mutables dans le constructeur. Si l'appelant passe un
Dateou uneList, copiez-la — sinon il peut la muter de l'extérieur et votre objet « immuable » change sous vos pieds. - Copiez défensivement les retours mutables dans les getters — pour la même raison, dans le sens inverse.
Une classe qui respecte les cinq règles est profondément immuable. En manquer une seule et la garantie s'effrite.
final seul n'est pas l'immuabilité. Un champ final ne peut pas être réaffecté, mais s'il pointe vers un objet mutable — une List, un tableau, un Date — cet objet peut toujours changer. final List<String> tags signifie que vous ne pouvez pas remplacer la liste par une autre, pas que le contenu de la liste est figé. Les règles 4 et 5 existent précisément pour combler cet écart. Voir Java final keyword pour ce que final promet et ne promet pas.
L'exemple minimal
Pour une classe dont tous les champs sont des primitives ou déjà immuables, les règles se réduisent à presque rien :
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 est une primitive, donc il n'y a rien à copier défensivement. La classe est final, les champs sont private final, aucun setter n'existe. C'est tout.
Les champs mutables nécessitent des copies défensives
Les problèmes commencent quand un champ est lui-même mutable — un tableau, un Date, un ArrayList. Si vous stockez directement la référence de l'appelant, il garde une prise sur elle et peut muter vos données internes :
// 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 solution est de copier à l'entrée et à la sortie :
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
}
}Pour les collections, l'équivalent est List.copyOf(other) (retourne une liste non modifiable sauvegardée par une copie) :
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
}Notez l'asymétrie avec l'exemple du tableau : la méthode clone() d'un tableau produit une copie mutable, vous devez donc copier à nouveau à la sortie. List.copyOf produit une liste non modifiable, donc le getter peut la retourner directement — tout appelant qui tente de la muter reçoit une UnsupportedOperationException. Préférez les types de collections immuables quand vous le pouvez ; ils éliminent toute une catégorie d'erreurs de copie à la sortie.
Les « modifications » retournent de nouvelles instances
Une classe immuable peut tout de même prendre en charge les changements — en retournant une nouvelle instance :
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
}Par convention, la méthode se nomme with... quand elle produit une copie avec un seul champ modifié : point.withX(5), user.withEmail("..."). L'API date/heure de Java utilise ce modèle de manière cohérente — LocalDate.plusDays(7), LocalDate.withYear(2026).
Pourquoi c'est important
Les objets immuables vous apportent :
- La sécurité des threads gratuitement. Pas de verrous, pas de
volatile, pas de surprises de visibilité — il n'y a rien à synchroniser car l'état ne peut pas changer. - Le partage et la mise en cache sécurisés. Deux appelants détenant le même
Money(2000, "USD")ne peuvent pas interférer l'un avec l'autre. - Des clés de hachage fiables. Comme les champs utilisés dans
hashCodene peuvent pas changer, le compartiment de l'objet ne devient jamais obsolète. Une clé mutable dont le hash change après avoir été stockée dans uneHashMapest effectivement perdue — voir Java equals and hashCode. - Un raisonnement plus facile. Une fois que vous avez vu un objet immuable, vous savez ce qu'il fera pour le reste de sa vie. Fini l'archéologie du type « où est-ce que cela a été muté ? ».
Le coût est l'allocation de nouvelles instances pour chaque « modification ». Pour les petits objets fréquemment utilisés (String, Integer), c'est rarement un problème ; la JVM est très efficace pour les allocations de courte durée. Pour les cas vraiment coûteux, il existe des techniques spécifiques (string builders, structures de données persistantes) — mais n'y recourez que lorsque le profilage révèle un vrai problème.
Les records font la majeure partie du travail
Un record est implicitement final, a des champs private final, génère des accesseurs sans setters, et vous donne equals/hashCode/toString gratuitement :
public record Point(int x, int y) {}C'est profondément immuable tant que les composants eux-mêmes sont immuables. Pour les records qui contiennent un composant mutable (une List, un tableau), vous avez toujours besoin d'un constructeur compact qui copie défensivement :
public record Recipe(String name, List<String> steps) {
public Recipe {
steps = List.copyOf(steps);
}
}Quand les records conviennent, ils représentent le chemin le plus court vers une classe immuable correcte.
Un exemple concret
La suite
Les classes immuables concernent le contrôle du changement. Le dernier chapitre de la partie 6 porte sur le contrôle de la quantité — une classe conçue pour qu'il n'existe qu'une seule instance. Continuez vers Java singleton pattern.