W3docs

Bonnes pratiques d'immutabilité en Java

Pourquoi l'immutabilité est une bonne valeur par défaut en Java, et les patrons pour construire des types immuables en toute sécurité.

Un objet immuable est un objet dont l'état ne peut pas changer après sa construction. Cette seule propriété élimine toute une classe de bugs : plus de mutations surprises venant d'un autre thread, plus de surprises liées à l'aliasing où deux variables partagent le même état, et plus besoin de copie défensive à chaque lecture. En Java, l'immutabilité n'est pas automatique — vous devez la construire délibérément. Ce chapitre couvre les patrons qui rendent un type véritablement immuable et les habitudes qui le maintiennent tel quel.

Pourquoi l'immutabilité est une bonne valeur par défaut

L'état partagé mutable est la source de la plupart des bugs de concurrence et d'un nombre surprenant de bugs mono-thread également. Quand un objet ne peut pas changer, vous pouvez le passer librement, le mettre en cache et raisonner à son sujet sans suivre qui d'autre détient une référence.

PropriétéObjet mutableObjet immuable
Sécurité des threadsNécessite des verrous ou de la prudenceIntrinsèquement thread-safe
Partage sécuriséNon — les appelants peuvent muterOui — distribuez la même instance
Clé de map sécuriséeRisqué — hashCode peut dériverOui — l'identité est stable
Mise en cacheDoit être invalidée lors des changementsMise en cache indéfinie
RaisonnementSuivre chaque écritureLa valeur est fixée à la construction

Le coût est l'allocation : changer un champ signifie créer un nouvel objet. Pour la plupart du code, ce coût est négligeable et la sécurité en vaut la peine. Recourez à la mutabilité uniquement lorsque le profilage prouve que vous en avez besoin.

Les cinq règles pour une classe immuable

Une classe est immuable lorsque toutes les conditions suivantes sont remplies. En manquer une et un appelant peut atteindre et modifier l'état.

  1. La classe est final (ou tous les constructeurs sont privés) pour qu'elle ne puisse pas être sous-classée avec un comportement mutable.
  2. Tous les champs sont private final.
  3. Il n'y a pas de setters — ni d'autres méthodes qui modifient un champ.
  4. Les champs mutables sont copiés défensivement à l'entrée afin que la référence de l'appelant ne puisse pas être utilisée pour muter votre état.
  5. Les getters n'exposent jamais directement un objet interne mutable — retournez une copie ou une vue non modifiable.
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);
    }
}

Copies défensives pour les champs mutables

Les primitives et String sont déjà immuables, donc les stocker est sûr. Le danger vient des champs mutables — tableaux, collections, dates. Si vous stockez directement la référence de l'appelant, il garde un accès à vos données internes.

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 et Map.copyOf (Java 10+) font les deux tâches à la fois : ils copient les données et retournent une vue non modifiable. Pour les tableaux, utilisez array.clone() à l'entrée et clone() à nouveau à la sortie, car les tableaux sont toujours mutables et n'ont pas de wrapper en lecture seule.

Records : l'immutabilité par construction

Un record (Java 16+) est la façon la plus concise de déclarer un porteur de données immuable. Le compilateur génère des champs private final, un constructeur canonique, des accesseurs et equals/hashCode/toString basés sur les valeurs.

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");
        }
    }
}

Les records couvrent le cas courant à merveille, mais ils ne sont pas un bouclier magique : si un composant de record est un type mutable (comme List), vous devez quand même le copier défensivement dans le constructeur compact, car l'accesseur généré retourne la référence stockée telle quelle.

Produire des copies modifiées : le patron « wither »

Puisque vous ne pouvez pas muter un objet immuable, vous créez une copie modifiée. La convention est une méthode withX qui retourne une nouvelle instance avec un champ modifié et les autres conservés.

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
    }
}

Cela permet de garder l'original sûr à partager tout en permettant aux appelants de construire des variantes. C'est le même modèle que le JDK utilise en interne — LocalDate.plusDays, String.replace et BigDecimal.add retournent tous de nouvelles instances plutôt que de muter le récepteur.

Un exemple complet exécutable

Le programme ci-dessous construit un petit Account immuable, puis essaie toutes les astuces qu'un appelant pourrait utiliser pour le muter — passer une liste et muter l'originale, muter la liste retournée, et renommer. Il prouve que chaque défense tient, puis montre pourquoi les valeurs immuables font de bonnes clés de map.

java— editable, runs on the server

Ce qu'il faut retenir de l'exécution :

  • Roles after mutating source: [read, write] prouve que la copie défensive a fonctionné — ajouter admin à la liste originale n'a jamais atteint l'Account.
  • L'UnsupportedOperationException sur acc.roles().add("hacker") montre que le getter a retourné une vue non modifiable, de sorte que les appelants ne peuvent pas muter les données internes à travers lui.
  • Original name still: Ada à côté de New object name: Grace prouve que withName a produit une copie et laissé l'original intact.
  • Different instance: true confirme que le wither a retourné un objet véritablement nouveau plutôt que la même référence.
  • Records equal by value: true et Key still found: origin-ish montrent que les valeurs immuables se comparent et se hachent par contenu, ce qui en fait des clés HashMap fiables.

Pratique

Pratique
Lorsqu'une classe immuable stocke un champ mutable comme une List, pourquoi faut-il faire une copie défensive dans le constructeur ?
Lorsqu'une classe immuable stocke un champ mutable comme une List, pourquoi faut-il faire une copie défensive dans le constructeur ?
Was this page helpful?