Collections non modifiables en Java
Créez des collections immuables en Java avec List.of, Set.of, Map.of et les wrappers Collections.unmodifiable*.
Une collection modifiable permet à quiconque détient une référence d'en modifier le contenu. Une collection non modifiable ne le permet pas — appeler add, remove, put, clear ou set lève une UnsupportedOperationException. Java offre deux façons complémentaires d'en créer une : les factories .of(...) introduites dans Java 9 (List.of, Set.of, Map.of, Map.ofEntries) et les anciens wrappers Collections.unmodifiable*. Ils se ressemblent à l'appel mais se comportent différemment sur deux points importants, et le bon outil dépend de ce dont vous avez réellement besoin.
Ce chapitre clôt la partie sur les collections en vous donnant une recette moderne et claire pour « donner une constante » et « prendre un instantané ».
Pourquoi l'immuabilité
Trois avantages concrets qui valent ce motif :
- Partage sécurisé. Passer une liste non modifiable à un constructeur, à un thread de travail ou à un consommateur d'événements vous évite de vous soucier qu'ils mutent votre état. Le type statique ne dit pas « lecture seule », mais le runtime le dit.
- Hashable en toute sécurité. Mettre une
Listmutable dans unHashSetest un bug — si le contenu de la liste change, sonhashCodechange, et l'ensemble perd l'élément. Les collections non modifiables évitent entièrement ce problème. - Meilleure conception d'API. Retourner une vue non modifiable depuis un getter signifie « c'est à moi — lisez-la, ne la modifiez pas. » Sans cela, chaque appelant doit décider de faire une copie défensive ou non.
Les deux stratégies
List.of, Set.of, Map.of, Map.ofEntries — collections vraiment immuables
Ajoutées dans Java 9. Elles construisent une nouvelle collection avec son propre stockage interne. Personne d'autre n'en détient de référence :
List<String> roles = List.of("admin", "editor", "viewer");
Set<Integer> primes = Set.of(2, 3, 5, 7, 11);
Map<String, Integer> ages = Map.of("alice", 30, "bob", 25);
Map<String, Integer> many = Map.ofEntries(
Map.entry("alice", 30),
Map.entry("bob", 25),
Map.entry("carol", 28)
);Utilisez-les pour les constantes et les littéraux — de petites collections fixes que vous écrivez dans le code. Le JIT les compile en représentations très compactes et peu coûteuses (souvent un seul tableau inline). Le coût est nul par allocation au-delà de ce que le littéral lui-même occupe.
Trois contraintes à retenir :
- Pas d'éléments
null, pas de clésnull, pas de valeursnull.List.of("a", null)lève uneNullPointerExceptionà la construction. Si vous avez besoin de représenter « absent », utilisezOptionalou omettez la clé de la map. - Pas de doublons pour
Set.ofetMap.of.Set.of("a", "a")lève uneIllegalArgumentException. Elles sont prévues pour des données littérales que vous contrôlez. Map.ofn'a des surcharges que jusqu'à 10 entrées. Pour 11 ou plus, utilisezMap.ofEntries(Map.entry(...), Map.entry(...), ...).
Collections.unmodifiableList(coll) etc. — vues d'une collection existante
Enveloppez une collection dans une vue en lecture seule. L'original reste mutable, et les modifications apportées via l'original sont visibles à travers la vue :
List<String> mutable = new ArrayList<>(List.of("a", "b", "c"));
List<String> view = Collections.unmodifiableList(mutable);
view.add("d"); // throws UnsupportedOperationException
mutable.add("d"); // legal — and the view sees the change
System.out.println(view); // [a, b, c, d]Utilisez-les quand vous voulez exposer une collection interne sans la copier et sans donner aux appelants la permission de la muter. Le motif classique est un getter :
public List<String> getNames() {
return Collections.unmodifiableList(this.names);
}L'appelant ne peut pas modifier this.names via la vue retournée. Vous, vous pouvez. Si vous souhaitez également vous l'interdire, copiez :
return List.copyOf(this.names);…ce qui constitue la troisième stratégie.
List.copyOf, Set.copyOf, Map.copyOf — instantané, puis gel
Un raccourci pour « copier le contenu actuel dans une nouvelle collection immuable » :
List<String> snapshot = List.copyOf(mutable);Après cet appel, snapshot est totalement indépendant de mutable. Les modifications ultérieures de mutable sont invisibles à travers snapshot. Il existe également une optimisation astucieuse : si la source est déjà une collection non modifiable produite par List.of / List.copyOf, l'appel retourne la source elle-même — zéro allocation.
copyOf rejette les éléments null, comme of. Si votre source peut contenir null, utilisez plutôt Collections.unmodifiableList(new ArrayList<>(source)).
Les trois motifs en résumé
| Motif | Indépendant de la source ? | Autorise null ? | Quand l'utiliser |
|---|---|---|---|
List.of("a", "b") | n/a (pas de source) | Non | Constantes littérales |
List.copyOf(source) | Oui — stockage propre | Non | Instantané à un instant donné |
Collections.unmodifiableList(source) | Non — vue | Oui | Exposer un état interne en lecture seule |
Quand le site d'appel correspond à « ces données sont littéralement codées en dur ? », utilisez of. Quand il correspond à « je veux un instantané figé de ce qui existe maintenant », utilisez copyOf. Quand il correspond à « je veux que le contenu actuel soit observable mais non modifiable via cette référence », utilisez unmodifiableList.
Peu profond, pas profond
Les trois stratégies sont peu profondes — elles figent la structure de la collection, pas les éléments qu'elle contient.
List<int[]> arrays = List.of(new int[]{1, 2}, new int[]{3, 4});
arrays.add(new int[]{5}); // UnsupportedOperationException
arrays.get(0)[0] = 99; // OK — and now the list contains {99, 2}Si vous voulez une immuabilité profonde, vous devez choisir des types d'éléments qui sont eux-mêmes immuables. Les Records avec des champs primitifs ou String le sont. Les Records avec des champs mutables ne le sont pas. C'est la même mise en garde qui s'applique aux références final en général : le lien est fixé, la cible peut ne pas l'être.
Set.of et Map.of ont un ordre d'itération non spécifié
Deux choix de conception intentionnels surprennent les développeurs :
Set.ofetMap.ofrandomisent délibérément l'ordre d'itération entre les exécutions d'une même JVM. Si vous écrivez du code qui dépend d'un ordre spécifique issu de ces méthodes, vous obtiendrez des tests instables. UtilisezList.of(qui préserve l'ordre littéral) ou unLinkedHashSet/LinkedHashMapenveloppé avecCollections.unmodifiable*quand vous avez vraiment besoin de l'ordre.Set.of(a, b)etSet.of(b, a)peuvent itérer différemment même dans la même exécution si les valeurs ont des hash différents. Ne comparez pas par toString.
C'est intentionnel — Java vous empêche de dépendre accidentellement de l'ordre afin que l'implémentation soit libre de le modifier.
Ce que l'immuabilité ne vous donne pas
- Ce n'est pas thread-safe pour les lectures de champs d'éléments mutables. Si les éléments sont mutables et qu'un autre thread les modifie, vous avez besoin de synchronisation de toute façon.
- Cela ne rend pas la collection sous-jacente thread-safe.
Collections.unmodifiableList(arrayList)est une vue d'une liste non thread-safe ; si un autre thread faitaddsurarrayList, la lecture via la vue peut voir un état corrompu. Pour une immuabilité thread-safe,List.copyOf(ouList.of) est le bon outil — elles possèdent leur propre stockage privé. - Cela ne rend pas
.equalsindépendant de l'ordre. UneListretournée parList.ofest toujours égale par position aux autres listes, pas par contenu.
Un exemple pratique : littéraux, instantanés, vues, et le piège de la superficialité
Le programme ci-dessous illustre les trois stratégies côte à côte, démontre la surprise « la vue voit les mutations », la promesse « la copie est indépendante », et le piège de la superficialité qui surprend tout le monde la première fois.
Ce qu'il faut retenir de l'exécution :
List.ofetList.copyOfproduisent tous les deux une collection vraiment immuable — elles rejettent toute mutation. Elles ne diffèrent que par la façon dont vous avez fourni les données : littéralement ou en les copiant depuis autre part.- La vue
Collections.unmodifiableLista rejetéview.addmais a acceptébacking.addvia la référence originale. Les modifications apportées via la liste de support sont devenues visibles à travers la vue. C'est la caractéristique définissante d'une vue, et la raison pour laquelle cette stratégie ne remplace pascopyOfdans du code non fiable. - Le piège de la superficialité est réel : les éléments
int[]d'uneList<int[]>immuable sont eux-mêmes mutables, et modifier l'un d'eux réécrit la liste « gelée ». Si vous voulez une immuabilité profonde, vos éléments doivent déjà être immuables. Set.ofa rejeté le doublon, etMap.ofa rejeté la valeurnull— tous deux à la construction. Ces collections échouent rapidement et bruyamment ; c'est une fonctionnalité.List.copyOfd'une liste déjà immuable a retourné la même instance sans allouer. C'est l'optimisation du JDK, et c'est pourquoi « toujours copier à la sortie » est peu coûteux quand la source est déjà immuable.
La suite — et vers la Partie 12
Cela clôt la partie Collections Framework. Vous connaissez désormais chaque implémentation (ArrayList, LinkedList, HashMap, TreeMap, les files, les deques et le reste), chaque interface (Collection, List, Set, Map, Queue, Deque), les curseurs d'itération (Iterator, ListIterator), les interfaces d'ordonnancement (Comparable, Comparator), la boîte à outils statique (Collections), et la gestion de l'immuabilité.
La prochaine partie — Functional Programming — change de registre. Au lieu de comment stocker les données, elle traite de comment exprimer des transformations sur les données. Le premier chapitre, Functional Programming in Java, introduit le modèle mental : les fonctions comme valeurs, l'immuabilité, les fonctions pures et la composition. À partir de là, la partie développe les lambdas, les références de méthodes, les interfaces fonctionnelles intégrées (Function, Predicate, Consumer, Supplier), Optional et les streams — qui utilisent les collections que vous venez d'apprendre comme source et destination.
La plupart des motifs de cette partie — list.sort(Comparator.comparing(Person::name)), map.getOrDefault(k, 0), stream().filter(...).toList() — ont déjà une saveur fonctionnelle. La Partie 12 rend cette saveur explicite et vous montre comment l'utiliser pour tout le reste.