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) et indexOf(null) lèvent NullPointerException. Vérifiez null d’abord, ou utilisez Objects.equals lors de la boucle sur la liste.

Quelques autres erreurs courantes :

  • Croire que unmodifiableList est 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, utilisez List.copyOf.
  • Remplacer collect(Collectors.toList()) par toList() de manière aveugle. Collectors.toList() retourne un ArrayList modifiable. Si le code en aval appelle add ou sort sur le résultat, il lèvera UnsupportedOperationException à l’exécution.
  • Appeler sort sur place. List.of(...).sort(...) lève toujours une erreur. Vous devez d’abord copier vers ArrayList, ou utiliser stream().sorted().toList().
  • S’appuyer sur l’ordre de Set.of ou Map.of dans 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 utilisez stream().toList() si null est 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. ArrayList est plus simple et plus rapide.
  • Certaines bibliothèques nécessitent des collections modifiables, par exemple une entité JPA avec Hibernate, où un champ @OneToMany doit être une collection modifiable pour que Hibernate la gère. Dans ce cas, gardez ArrayList en 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.of crée rapidement une liste fixe qui ne contient pas de null.
  • List.copyOf crée un snapshot indépendant. C’est le choix par défaut pour une defensive copy dans un constructeur ou un record.
  • Collections.unmodifiableList est 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.

javacollectionsimmutablejava-21clean-code