Comment créer des objets immuables en Java?

Comme vous pourriez vous souvenir du Chapitre 4, un objet immuable est un objet qui ne peut pas changer d’état après sa création. Le modèle d’objets immuables est un modèle de conception orienté objet dans lequel un objet ne peut pas être modifié après sa création.

Les objets immuables sont utiles lors de l’écriture de code sécurisé, car vous n’avez pas à vous soucier des changements de valeurs. Ils simplifient également le code lors de la gestion de la concurrence, car les objets immuables peuvent être facilement partagés entre plusieurs threads.

Déclaration d’une Classe Immuable

Bien qu’il existe diverses techniques pour écrire une classe immuable, vous devriez connaître une stratégie commune pour rendre une classe immuable :

  1. Marquer la classe comme final ou rendre tous les constructeurs private.
  2. Marquer toutes les variables d’instance private et final.
  3. Ne pas définir de méthodes setter.
  4. Ne pas permettre la modification des objets mutables référencés.
  5. Utiliser un constructeur pour définir toutes les propriétés de l’objet, en faisant une copie si nécessaire.

La première règle empêche quiconque de créer une sous-classe mutable. Les deuxième et troisième règles garantissent que les appelants ne modifient pas les variables d’instance et constituent la marque d’une bonne encapsulation, un sujet que nous abordons avec les records dans le Chapitre 7.

La quatrième règle pour créer des objets immuables est subtile. Fondamentalement, cela signifie que vous ne devriez pas exposer une méthode d’accesseur (ou getter) pour les champs d’instance mutables. Voyez-vous pourquoi l’exemple suivant crée un objet mutable ?

import java.util.*;
public final class Animal { // Pas une déclaration d'objet immuable
    private final ArrayList<String> alimentsPreféres;
    
    public Animal() {
        this.alimentsPreféres = new ArrayList<String>();
        this.alimentsPreféres.add("Pommes");
    }
    
    public List<String> getAlimentsPreféres() {
        return alimentsPreféres;
    }
}

Nous avons soigneusement suivi les trois premières règles, mais malheureusement, un appelant malveillant pourrait toujours modifier nos données :

var zebre = new Animal();
System.out.println(zebre.getAlimentsPreféres()); // [Pommes]

zebre.getAlimentsPreféres().clear();
zebre.getAlimentsPreféres().add("Biscuits aux Pépites de Chocolat");
System.out.println(zebre.getAlimentsPreféres()); // [Biscuits aux Pépites de Chocolat]

Oh non ! Les zèbres ne devraient pas manger de Biscuits aux Pépites de Chocolat ! Ce n’est pas un objet immuable si nous pouvons changer son contenu ! Si nous n’avons pas de getter pour l’objet alimentsPreféres, comment les appelants y accèdent-ils ? Simple : en utilisant des méthodes déléguées ou des méthodes wrapper pour lire les données.

import java.util.*;
public final class Animal { // Une déclaration d'objet immuable
    private final List<String> alimentsPreféres;
    
    public Animal() {
        this.alimentsPreféres = new ArrayList<String>();
        this.alimentsPreféres.add("Pommes");
    }
    
    public int getNombreAlimentsPreféres() {
        return alimentsPreféres.size();
    }
    
    public String getAlimentPréféréItem(int index) {
        return alimentsPreféres.get(index);
    }
}

Dans cette version améliorée, les données sont toujours disponibles. Cependant, c’est un véritable objet immuable car la variable mutable ne peut pas être modifiée par l’appelant.

Méthodes d’Accès avec Copie à la Lecture

Outre la délégation d’accès à des objets mutables privés, une autre approche consiste à faire une copie de l’objet mutable chaque fois qu’il est demandé.

public ArrayList<String> getAlimentsPreféres() {
    return new ArrayList<String>(this.alimentsPreféres);
}

Bien sûr, les modifications dans la copie ne seront pas reflétées dans l’original, mais au moins l’original est protégé des modifications externes. Cela peut être une opération coûteuse si elle est appelée fréquemment par l’appelant.

Réalisation d’une Copie Défensive

Alors, qu’en est-il de la cinquième et dernière règle pour créer des objets immuables ? Dans la conception de notre classe, disons que nous voulons une règle selon laquelle les données pour alimentsPreféres sont fournies par l’appelant et qu’elles contiennent toujours au moins un élément. Cette règle est souvent appelée un invariant ; elle est vraie à tout moment où nous avons une instance de l’objet.

import java.util.*;
public final class Animal { // Pas une déclaration d'objet immuable
    private final ArrayList<String> alimentsPreféres;
    
    public Animal(ArrayList<String> alimentsPreféres) {
        if (alimentsPreféres == null || alimentsPreféres.size() == 0)
            throw new RuntimeException("alimentsPreféres est requis");
        this.alimentsPreféres = alimentsPreféres;
    }
    
    public int getNombreAlimentsPreféres() {
        return alimentsPreféres.size();
    }
    
    public String getAlimentPréféréItem(int index) {
        return alimentsPreféres.get(index);
    }
}

Pour garantir que alimentsPreféres est fourni, nous le validons dans le constructeur et lançons une exception s’il n’est pas fourni. Alors, est-ce immuable ? Pas tout à fait ! Un appelant malveillant pourrait être rusé et garder sa propre référence secrète à notre objet alimentsPreféres, qu’il peut modifier directement.

var preferés = new ArrayList<String>();
preferés.add("Pommes");

var zebre = new Animal(preferés); // L'appelant a toujours accès à preferés
System.out.println(zebre.getAlimentPréféréItem(0)); // [Pommes]

preferés.clear();
preferés.add("Biscuits aux Pépites de Chocolat");
System.out.println(zebre.getAlimentPréféréItem(0)); // [Biscuits aux Pépites de Chocolat]

Oups ! Il semble que Animal ne soit plus immuable, puisque son contenu peut changer après sa création. La solution est de faire une copie de l’objet liste contenant les mêmes éléments.

public Animal(List<String> alimentsPreféres) {
    if (alimentsPreféres == null || alimentsPreféres.size() == 0)
        throw new RuntimeException("alimentsPreféres est requis");
    this.alimentsPreféres = new ArrayList<String>(alimentsPreféres);
}

L’opération de copie est appelée une copie défensive car la copie est faite au cas où un autre code ferait quelque chose d’inattendu. C’est la même idée que la conduite défensive : prévenir un problème avant qu’il n’existe. Avec cette approche, notre classe Animal est à nouveau immuable.