Collections immuables en Java : List.of, copyOf et unmodifiable
1. Aperçu
Java offre plusieurs façons de créer une collection « immuable » : List.of, List.copyOf, Collections.unmodifiableList, Stream.toList()… Au premier abord, elles se ressemblent, mais elles diffèrent sur trois points : copient-elles les données, acceptent-elles null, et reflètent-elles les changements de la collection source ?
Cet article parcourt chacune de ces approches avec des exemples concrets. Après l’avoir lu, vous saurez choisir la bonne API, éviter les NullPointerException inattendues et écrire une defensive copy conforme aux standards.
2. Pourquoi on a besoin de collections immuables
Regardez la classe Order suivante :
import java.util.List;
public class Order {
private final List<String> items;
public Order(List<String> items) {
this.items = items;
}
public List<String> getItems() {
return items;
}
}
Le mot-clé final verrouille seulement la référence, pas le contenu de la liste. Les modifications sont toujours possibles de l’extérieur :
List<String> items = new ArrayList<>(List.of("Laptop"));
Order order = new Order(items);
items.add("Mouse"); // modifier la liste source
order.getItems().add("Keyboard"); // modifier via le getter
System.out.println(order.getItems());
[Laptop, Mouse, Keyboard]
Order ne peut plus contrôler son propre état. Quand un objet est partagé entre plusieurs threads, ce type d’erreur est encore plus difficile à trouver. Les collections immuables résolvent ce problème : personne ne peut les modifier, vous pouvez donc les partager sans crainte et sans synchronisation.
3. List.of, Set.of et Map.of
À partir de Java 9, les méthodes factory of créent des collections immuables en une seule ligne :
List<String> statuses = List.of("NEW", "PAID", "SHIPPED");
Set<String> currencies = Set.of("VND", "USD", "EUR");
Map<String, Integer> stock = Map.of("Laptop", 5, "Mouse", 20);
statuses.add("CANCELLED");
Exception in thread "main" java.lang.UnsupportedOperationException
Toutes les méthodes qui modifient les données comme add, remove, set, clear, sort ou put lèvent UnsupportedOperationException. L’exception est levée toujours, même quand l’opération ne changerait rien en pratique, par exemple supprimer un élément qui n’existe pas.
Map.of n’a d’overload que jusqu’à 10 paires clé-valeur. Si vous en avez besoin de plus, vous utilisez Map.ofEntries :
Map<String, Integer> prices = Map.ofEntries(
Map.entry("Laptop", 25_000_000),
Map.entry("Mouse", 300_000)
);
3.1. N’accepte pas null
Les collections créées avec of rejettent null immédiatement lors de l’initialisation :
List<String> names = List.of("An", null);
Exception in thread "main" java.lang.NullPointerException
Il y a un piège plus subtil : même interroger null lève une erreur.
List<String> names = List.of("An", "Bình");
System.out.println(names.contains(null));
Exception in thread "main" java.lang.NullPointerException
Avec ArrayList, l’appel ci-dessus retournerait false. Si votre ancien code a quelque chose comme list.contains(input) où input peut être null, passer à List.of cassera ce code.
3.2. Set et Map : doublons et ordre
Set.of et Map.of ne passent pas silencieusement à côté des doublons, ils lèvent une erreur :
Set<String> tags = Set.of("java", "spring", "java");
Exception in thread "main" java.lang.IllegalArgumentException: duplicate element: java
L’ordre d’itération de Set.of et Map.of n’est pas garanti. Dans OpenJDK, cet ordre change même entre différentes exécutions de la JVM. Donc un test comparant la chaîne toString() d’un Set.of peut passer ou échouer de façon aléatoire. Si vous avez besoin de préserver l’ordre, utilisez LinkedHashSet ou LinkedHashMap puis enveloppez-les, comme dans la section 5.
4. List.copyOf : une copie immuable
List.of convient quand vous connaissez les éléments à l’avance. Mais quand vous avez déjà une collection, par exemple un paramètre passé au constructeur, vous utilisez List.copyOf (disponible depuis Java 10) :
List<String> source = new ArrayList<>(List.of("Laptop", "Mouse"));
List<String> copy = List.copyOf(source);
source.add("Keyboard");
System.out.println(source);
System.out.println(copy);
[Laptop, Mouse, Keyboard]
[Laptop, Mouse]
copy est une copie indépendante. Les modifications de source ne l’affectent pas. Comme List.of, copyOf lève également NullPointerException si la collection source contient null.
Un détail intéressant : si l’entrée est déjà une liste immuable créée par List.of ou List.copyOf, OpenJDK retourne simplement cette instance sans la copier à nouveau :
List<String> a = List.of("x", "y");
System.out.println(List.copyOf(a) == a);
true
Grâce à cela, appeler copyOf « pour être sûr » ne coûte pratiquement rien. Cependant, c’est un détail d’implémentation, donc ne codez pas en vous fiant à la comparaison ==.
De la même manière, il existe aussi Set.copyOf et Map.copyOf. Set.copyOf ne lève pas d’erreur si la source a des doublons, il en garde juste un, car la source peut être une List.
5. Collections.unmodifiableList : juste une vue
Collections.unmodifiableList existe depuis Java 1.2 et fonctionne complètement différemment. Il ne copie pas, il enveloppe simplement la collection source dans une classe qui bloque l’écriture :
List<String> source = new ArrayList<>(List.of("Laptop", "Mouse"));
List<String> view = Collections.unmodifiableList(source);
source.add("Keyboard");
System.out.println(view);
view.add("Monitor");
[Laptop, Mouse, Keyboard]
Exception in thread "main" java.lang.UnsupportedOperationException
Vous ne pouvez pas modifier via view, mais quiconque détient la référence source peut toujours le faire, et les changements s’affichent immédiatement sur view. Donc unmodifiable signifie « ne peut pas être modifié via cette référence », et non « immuable ».
Ce comportement de vue est utile quand vous voulez que l’appelant voie les données les plus récentes sans pouvoir les modifier :
public class ShoppingCart {
private final List<String> items = new ArrayList<>();
public void addItem(String item) {
items.add(item);
}
public List<String> getItems() {
return Collections.unmodifiableList(items);
}
}
Le getter retourne une vue sans copie, donc le coût est O(1). L’appelant ne peut que lire, tandis que ShoppingCart conserve un contrôle total sur la modification interne.
La dernière différence : cette vue accepte null. L’appel contains(null) retourne false au lieu de lever une erreur.
6. Autres options et tableau comparatif
Il y a d’autres API souvent confondues :
// Java 16+ : résultat immuable, ACCEPTE null
List<String> a = Stream.of("A", null).toList();
// Java 10+ : résultat immuable, REJETTE null
List<String> b = Stream.of("A", "B").collect(Collectors.toUnmodifiableList());
// Taille fixe mais SET() TOUJOURS POSSIBLE, écrit directement dans le tableau source
String[] arr = {"A", "B"};
List<String> c = Arrays.asList(arr);
c.set(0, "Z");
System.out.println(arr[0]); // Z
Tableau récapitulatif :
| API | Copie ? | null |
Voit les changements de la source ? | Modifiable ? |
|---|---|---|---|---|
List.of(...) |
Pas de source | Lève NPE | N/A | Non |
List.copyOf(c) |
Oui | Lève NPE | Non | Non |
Collections.unmodifiableList(l) |
Non (vue) | Accepte | Oui | Non (via vue) |
stream.toList() |
Oui | Accepte | Non | Non |
Collectors.toUnmodifiableList() |
Oui | Lève NPE | Non | Non |
Arrays.asList(arr) |
Non (vue de tableau) | Accepte | Oui | Seulement set |
7. Immuabilité superficielle (shallow immutability)
Toutes ces API verrouillent seulement la structure de la collection, pas les éléments qu’elle contient :
public class OrderLine {
private int quantity;
public OrderLine(int quantity) { this.quantity = quantity; }
public int getQuantity() { return quantity; }
public void setQuantity(int quantity) { this.quantity = quantity; }
}
List<OrderLine> lines = List.of(new OrderLine(1));
lines.get(0).setQuantity(99);
System.out.println(lines.get(0).getQuantity());
99
Vous ne pouvez pas ajouter ni supprimer d’éléments, mais vous pouvez modifier le contenu de chacun. Pour une véritable immuabilité, les éléments eux-mêmes doivent aussi être immuables. La manière la plus concise est d’utiliser record :
public record OrderLine(String product, int quantity) {}
En mettant cela ensemble, voici le modèle de defensive copy standard pour un agrégat immuable :
import java.util.List;
public record Order(String customer, List<OrderLine> lines) {
public Order {
lines = List.copyOf(lines);
}
}
Le constructeur compact remplace le paramètre lines par une copie immuable avant de l’assigner au champ. L’accesseur lines() retourne cette même copie, donc vous n’avez pas besoin de la copier à nouveau dans le getter. Si lines est null ou contient null, l’objet échoue immédiatement à la création, au lieu de causer une erreur ailleurs plus tard.
8. Tester le comportement
Ajoutez JUnit 5 et AssertJ à pom.xml. Si vous utilisez Spring Boot 3.x, spring-boot-starter-test les inclut déjà tous les deux :
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.26.3</version>
<scope>test</scope>
</dependency>
Le test ci-dessous vérifie les trois comportements principaux de Order et de la vue :
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class OrderTest {
@Test
void shouldNotReflectChangesToSourceList() {
List<OrderLine> source = new ArrayList<>();
source.add(new OrderLine("Laptop", 1));
Order order = new Order("An", source);
source.add(new OrderLine("Mouse", 2));
assertThat(order.lines()).hasSize(1);
}
@Test
void shouldRejectModificationThroughAccessor() {
Order order = new Order("An", List.of(new OrderLine("Laptop", 1)));
assertThatThrownBy(() -> order.lines().add(new OrderLine("Mouse", 2)))
.isInstanceOf(UnsupportedOperationException.class);
}
@Test
void unmodifiableViewShouldReflectSourceChanges() {
List<String> source = new ArrayList<>(List.of("A"));
List<String> view = Collections.unmodifiableList(source);
source.add("B");
assertThat(view).containsExactly("A", "B");
}
}
Le premier test confirme que List.copyOf dans le constructeur compact a séparé Order de la liste source. Le deuxième test garantit que l’accesseur n’ouvre pas la porte à la modification des données. Le dernier test capture le comportement de la vue, pour que personne ne confonde unmodifiableList avec une copie.
9. Erreurs courantes et quand ne pas utiliser
Note :
List.of(...).contains(null)etindexOf(null)lèventNullPointerException. Vérifieznulld’abord, ou utilisezObjects.equalslors de la boucle sur la liste.
Quelques autres erreurs courantes :
- Croire que
unmodifiableListest immuable. C’est juste une vue. Si la source est toujours référencée quelque part, les données peuvent toujours changer. Pour un snapshot, utilisezList.copyOf. - Remplacer
collect(Collectors.toList())partoList()de manière aveugle.Collectors.toList()retourne unArrayListmodifiable. Si le code en aval appelleaddousortsur le résultat, il lèveraUnsupportedOperationExceptionà l’exécution. - Appeler
sortsur place.List.of(...).sort(...)lève toujours une erreur. Vous devez d’abord copier versArrayList, ou utiliserstream().sorted().toList(). - S’appuyer sur l’ordre de
Set.ofouMap.ofdans les logs, JSON ou les tests. - Passer directement les données d’une source externe potentiellement
null(JSON, résultats de base de données) àList.copyOf. Filtrez d’abord, ou utilisezstream().toList()sinullest une valeur valide.
Il y a quelques cas où ne pas utiliser de collections immuables :
- Une collection qui doit changer fréquemment au cours d’une courte portée locale, par exemple un buffer accumulant des résultats dans une méthode.
ArrayListest plus simple et plus rapide. - Certaines bibliothèques nécessitent des collections modifiables, par exemple une entité JPA avec Hibernate, où un champ
@OneToManydoit être une collection modifiable pour que Hibernate la gère. Dans ce cas, gardezArrayListen interne et retournez seulement une vue au getter. - Une très grande collection mise à jour fréquemment. Chaque « modification » nécessitant une copie complète coûtera O(n).
10. Conclusion
Les trois API principales servent trois objectifs différents :
List.ofcrée rapidement une liste fixe qui ne contient pas denull.List.copyOfcrée un snapshot indépendant. C’est le choix par défaut pour une defensive copy dans un constructeur ou unrecord.Collections.unmodifiableListest une vue en lecture seule sur des données qui peuvent toujours changer. C’est peu coûteux, mais pas immuable.
Rappelez-vous aussi que toutes les collections immuables de la JDK ne sont qu’immuables en surface. Combinez-les avec des éléments immuables comme record pour avoir des objets vraiment sûrs à partager entre les threads.