Aller au contenu principal

Immutabilité - `final` et copies défensives

L'immutabilité est un concept fondamental en programmation orientée objet, particulièrement important en Java, qui désigne l'état d'un objet restant inchangé après sa création. Un objet immuable est un objet dont l'état interne ne peut pas être modifié une fois qu'il a été entièrement construit.

Qu'est-ce qu'un objet immuable ?

Un objet est considéré comme immuable si son état ne peut pas être modifié après sa création. Une fois construit, les valeurs de ses champs restent constantes pendant toute sa durée de vie. Parmi les exemples de classes immuables dans la bibliothèque standard de Java, on trouve String, Integer, Long, Float, Double et BigDecimal.

Pourquoi l'immutabilité ? (Avantages)

L'immutabilité offre des avantages significatifs dans le développement logiciel :

  1. Sécurité des threads : Les objets immuables sont intrinsèquement thread-safe (sûrs pour les threads). Étant donné que leur état ne peut pas changer, plusieurs threads peuvent y accéder simultanément sans avoir besoin de mécanismes de synchronisation (comme des verrous), ce qui élimine les problèmes tels que les conditions de concurrence ou les interblocages. Cela simplifie considérablement la programmation concurrente.
  2. Simplicité et prévisibilité : L'état d'un objet immuable est fixe. Cela rend le code plus facile à raisonner, à comprendre et à déboguer, car vous n'avez pas à vous soucier des changements inattendus provenant d'autres parties du programme.
  3. Partage plus sûr : Les objets immuables peuvent être librement partagés entre différentes parties d'une application sans craindre qu'une partie ne corrompe l'état de l'objet pour une autre. Ceci est particulièrement utile dans les environnements multithreads.
  4. Mise en cache et hachage : Les objets immuables sont d'excellents candidats pour les clés dans une HashMap ou les éléments dans un HashSet car leur code de hachage (s'il est calculé correctement) reste constant. Ils peuvent également être facilement mis en cache.
  5. Effets secondaires réduits : Les fonctions ou méthodes qui opèrent sur des objets immuables ne peuvent pas produire d'effets secondaires sur ces objets. Cela conduit à un code plus prévisible et plus facile à tester.
  6. Sécurité : Les objets immuables peuvent être plus sûrs en termes de sécurité, car leur état ne peut pas être altéré après leur création.

Comment atteindre l'immutabilité

Pour créer une classe immuable en Java, vous devez suivre un ensemble de règles strictes :

  1. Déclarez la classe comme final : Cela empêche d'autres classes de l'étendre et potentiellement de surcharger des méthodes ou de modifier son état de manière mutable.
    • Alternative : Si vous souhaitez autoriser la sous-classing, vous pouvez rendre le constructeur private et fournir des méthodes de fabrique statiques, ou simplement vous assurer que toutes les méthodes qui pourraient exposer un état mutable sont gérées correctement (moins courant pour une véritable immutabilité).
  2. Déclarez tous les champs comme private et final :
    • private : Garantit que les champs ne peuvent pas être directement accédés ou modifiés depuis l'extérieur de la classe.
    • final : Garantit que la valeur du champ (ou l'objet auquel il fait référence) n'est attribuée qu'une seule fois lors de la construction de l'objet et ne peut pas être réaffectée ultérieurement.
  3. Ne fournissez aucune méthode setter (mutateur) : Il ne doit y avoir aucune méthode permettant la modification de l'état de l'objet après sa construction.
  4. Initialisez tous les champs via le constructeur : Le constructeur doit entièrement initialiser tous les champs final.
  5. Gérez attentivement les références d'objets mutables (Copies défensives) : C'est la règle la plus cruciale et souvent négligée. Si votre classe immuable contient des champs qui sont des références à des objets mutables (par exemple, Date, ArrayList, des objets mutables personnalisés), vous devez effectuer des copies défensives.
    • Pour les arguments entrants dans le constructeur : Créez une nouvelle copie de tout objet mutable passé au constructeur. Ne stockez pas la référence directe à l'objet mutable externe. Cela empêche le code externe de modifier l'état de votre objet immuable après sa construction.
    • Pour les références sortantes dans les méthodes getter (accesseurs) : Retournez une nouvelle copie de tout objet mutable qui fait partie de l'état de votre objet immuable. Ne retournez pas la référence directe à l'objet mutable interne. Cela empêche le code externe d'obtenir une référence à votre état interne et de le modifier.

Les copies défensives expliquées

Les copies défensives sont essentielles pour prévenir les problèmes d'"aliasing" (d'alias) où une référence à un objet mutable est partagée, permettant au code externe de modifier l'état interne de votre objet prétendument immuable.

Exemple (Violation vs. Respect des règles) :

Supposons que nous voulions créer une classe Period immuable avec une startDate et une endDate.

Violation (Sans copies défensives) :

import java.util.Date; // Date est une classe mutable

public final class MutablePeriod {
private final Date startDate;
private final Date endDate;

public MutablePeriod(Date startDate, Date endDate) {
this.startDate = startDate; // Stocke la référence directe
this.endDate = endDate; // Stocke la référence directe
}

public Date getStartDate() {
return startDate; // Retourne la référence directe
}

public Date getEndDate() {
return endDate; // Retourne la référence directe
}

@Override
public String toString() {
return startDate + " - " + endDate;
}

public static void main(String[] args) {
Date start = new Date();
Date end = new Date();
MutablePeriod period = new MutablePeriod(start, end);

System.out.println("Période originale : " + period); // Affiche les dates

// Modification externe des objets Date originaux :
start.setYear(1900); // Modifie l'objet 'start'
end.setYear(2500); // Modifie l'objet 'end'

System.out.println("Période modifiée : " + period); // L'état interne de l'objet Period a changé !
// Même s'il est 'final' et n'a pas de mutateurs.
}
}

Respect des règles (Avec copies défensives) :

import java.util.Date; // Date est une classe mutable

public final class ImmutablePeriod { // La classe est finale
private final Date startDate; // Les champs sont privés et finaux
private final Date endDate;

public ImmutablePeriod(Date startDate, Date endDate) {
// Copie défensive pour les arguments mutables entrants :
// Crée de nouveaux objets Date pour stocker l'état interne,
// afin que les objets Date externes ne puissent pas modifier l'état de cet objet.
this.startDate = new Date(startDate.getTime());
this.endDate = new Date(endDate.getTime());

// Validation de base (optionnel mais bonne pratique pour un constructeur)
if (this.startDate.compareTo(this.endDate) > 0) {
throw new IllegalArgumentException(startDate + " après " + endDate);
}
}

public Date getStartDate() {
// Copie défensive pour les références mutables sortantes :
// Retourne un nouvel objet Date, pas celui interne,
// afin que le code externe ne puisse pas modifier l'état de cet objet.
return new Date(startDate.getTime());
}

public Date getEndDate() {
// Copie défensive pour les références mutables sortantes :
return new Date(endDate.getTime());
}

@Override
public String toString() {
return startDate + " - " + endDate;
}

public static void main(String[] args) {
Date start = new Date();
Date end = new Date();
ImmutablePeriod period = new ImmutablePeriod(start, end);

System.out.println("Période originale : " + period);

// Tentative de modification externe des objets Date originaux :
start.setYear(1900);
end.setYear(2500);

System.out.println("Tentative de modification : " + period); // L'objet Period reste inchangé !

// Tentative de modification externe via un accesseur :
period.getStartDate().setYear(1950);
period.getEndDate().setYear(2050);

System.out.println("Tentative de modification via un accesseur : " + period); // L'objet Period reste inchangé !
}
}

Considérations et compromis

Bien que l'immutabilité offre de nombreux avantages, ce n'est pas toujours le meilleur choix :

  • Surcharge de performance : Créer un nouvel objet pour chaque modification (par exemple, dans une boucle où une String est concaténée à plusieurs reprises) peut entraîner une activité accrue de la collecte des déchets et une surcharge de performance par rapport à la modification d'un objet mutable sur place. Pour de tels scénarios, des alternatives mutables (comme StringBuilder) sont souvent préférées.
  • Augmentation de la création d'objets : Dans les scénarios avec des changements d'état fréquents, l'immutabilité peut entraîner une prolifération d'objets, augmentant potentiellement la consommation de mémoire et la pression sur le ramasse-miettes.
  • Verbosité : Parfois, l'implémentation stricte de l'immutabilité peut impliquer plus de code passe-partout (boilerplate), surtout avec des objets complexes contenant de nombreux champs mutables imbriqués.

Malgré ces compromis, les avantages de l'immutabilité, en particulier dans les systèmes concurrents et complexes, l'emportent souvent sur les coûts. C'est un principe puissant pour construire des logiciels robustes et fiables.