I. Introduction ♪▲
Les développeurs Java connaissent tous très bien la notion de classe immuable. Beaucoup ont d'ailleurs pesté contre sa principale représentante, java.lang.String. L'expression classe immuable est en fait un abus de langage, un substitut courant pour objet immuable. Un objet dit immuable est une instance de classe dont les membres exportés (ou visibles, que cela soit par un modificateur d'accès direct ou indirect, protected, public ou package private) ne peuvent être modifiés après création.
Les classes immuables ont de nombreux avantages en leur faveur et leur utilisation simplifie parfois tellement le développement que je voulais écrire ce billet pour vous encourager à en utiliser aussi souvent que possible. De par leur propriété intrinsèque, ces objets ont un seul état. Cette dernière remarque peut vous sembler anodine, mais a de nombreuses répercussions très importantes. Voici en vrac les avantages des objets immuables :
- ils sont garantis thread-safe ;
- ils peuvent être mis en cache ;
- ils n'ont besoin ni de constructeur par copie ni d'implémentation de l'interface Cloneable ;
- il n'est pas nécessaire d'en faire une copie défensive ;
- leurs invariants sont testés à la création seulement ;
- ils constituent d'excellentes clés pour les Map et Set ;
- leurs valeurs peuvent être mises en cache par le client sans risque de désynchronisation.
Les classes immuables sont particulièrement adaptées à la représentation de types de données abstraits. L'API de Java en contient plusieurs exemples : Integer, Color, BigDecimal, etc. La définition de type abstrait dépend toutefois de votre application. Ainsi, un logiciel affichant le contenu d'un magasin en ligne (livres, musique, DVD, etc.) pourra utiliser des classes immuables pour représenter les articles.
II. Créer une classe immuable▲
Écrire une classe immuable n'est pas une tâche difficile, mais demande beaucoup d'attention pour ne pas exporter indirectement des valeurs. Voici les règles à suivre :
- la classe doit être déclarée final (dans le cas contraire, il serait possible de modifier une instance par héritage) ;
- tous les champs doivent être déclarés final ;
- la référence à this ne doit jamais être exportée ;
- tous les champs faisant référence à un objet non immuable doivent être privés, ne jamais être exportés, représenter l'unique référence à cet objet et ne jamais modifier l'état de l'objet.
Le dernier point est le plus délicat, mais évident avec un exemple :
private
final
Date theDate;
public
MaClasse
(
Date theDate) {
this
.theDate =
theDate;
}
@Override
public
String toString
(
) {
return
theDate.toString
(
);
}
À première vue, cet exemple est correct puisque le membre theDate est privé, final et jamais exporté. En outre, la valeur renvoyée par toString est immuable. En réalité, theDate est bel et bien exporté, indirectement :
Date d =
new
Date
(
);
MaClasse c =
new
MaClasse
(
d);
d.setYear
(
98
);
System.out.println
(
c);
Ce programme affiche Fri Mar 27 00:50:23 PST 1998, notre classe n'est donc pas immuable. Pour résoudre ce problème, il faut réaliser une copie défensive des objets non immuables passes en paramètre :
private
final
Date theDate;
public
MaClasse
(
Date theDate) {
this
.theDate =
(
Date) theDate.clone
(
);
}
Cette fois-ci le résultat est bien Mon Mar 27 00:52:18 PST 2006 malgré la modification de l'année. Ce qui est vrai pour les paramètres d'entrée de la classe l'est également pour les valeurs que la classe renvoie à travers ses différentes méthodes. Ainsi, pour ajouter une méthode getDate() à notre classe, nous devrons écrire ceci :
public
Date getDate
(
) {
return
(
Date) theDate.clone
(
);
}
Malheureusement, la copie défensive ne suffit pas toujours. Prenez l'exemple suivant :
private
final
Date startDate;
private
final
Date endDate;
public
MaClasse
(
Date startDate, Date endDate) {
if
(
startDate.compareTo
(
endDate) >
0
) {
throw
new
IllegalArgumentException
(
"The start date is not <= the end date."
);
}
this
.startDate =
(
Date) startDate.clone
(
);
this
.endDate =
(
Date) endDate.clone
(
);
}
Ce code semble parfait à première vue, mais cache un gros problème potentiel. En effet, il est possible avec un thread de modifier startDate et/ou endDate de manière à ce que la condition soit validée, mais que des valeurs interdites soient conservées par l'objet. Puisqu'il n'est pas possible de prédire le séquencement des opérations multithread dans la plupart des environnements, rien ne garantit que le thread principal ne s'arrêtera pas juste après l'exécution du if pour donner la main à un second thread qui modifiera startYear pour que sa valeur soit supérieure a endYear. La bonne stratégie (la seule en fait) est la suivante :
private
final
Date startDate;
private
final
Date endDate;
public
MaClasse
(
Date startDate, Date endDate) {
Date copyStart =
(
Date) startDate.clone
(
);
Date copyEnd =
(
Date) endDate.clone
(
)
if
(
copyStart.compareTo
(
copyEnd) >
0
) {
throw
new
IllegalArgumentException
(
"The start date is not <= the end date."
);
}
this
.startDate =
copyStart;
this
.endDate =
copyEnd;
}
Ce dernier exemple montre bien pourquoi les classes immuables sont souvent intéressantes. Si Date était immuable, nous pourrions simplement écrire this.startDate = startDate.
Rendez-vous service, utilisez des classes immuables :)