L'égalité d'identité et l'égalité de valeur en Java

/img/post/egalite.jpg

Dans certains langages de programmation, comme Java, il n’y a pas de méthodes ou d’opérateurs distincts pour différencier l’égalité d’identité et celle de valeur des objets. Si, dans un programme classique écrit dans un langage comme Java, l’égalité d’identité (de l’OID pour Object IDentifier) pourrait se faire avec l’opérateur == et celle de valeur avec la méthode equals surchargée, il n’en va plus de même dès qu’il s’agit d’objets persistés. Et là, in fine, c’est le drame : que compare t’on avec la méthode equals ? la valeur des objets ou leur identité ?

Cette question est importante car elle met en exergue une différence de sémantique qui, mal appréciée ou pas du tout appréhendée, pourrait aboutir à utiliser tantôt l’une, tantôt l’autre, aboutissant inévitablement à des bogues cachés, voir même, dans certaines situations d’usage, à un plantage.

Cette question est d’autant plus pertinente en Java où la méthode equals, par ses propres règles de codage, peut véhiculer les deux sémantiques. Je m’explique. Par défaut, la méthode equals d’un objet en Java effectue une égalité d’identité (qui se trouve être l’adresse mémoire de l’objet, comme dans les autres langages orientés objet). Nous pourrions par conséquence en déduire que la sémantique véhiculée par cette méthode est celle d’une égalité d’identité et donc nous y maintenir. Or, une des règles de codage en Java stipule que lorsqu’un objet peut être comparé, la méthode equals doit être consistante avec la méthode compareTo ; c’est-à-dire que :

myObject.compareTo(anotherObject) == 0 <=> myObject.equals(anotherObject) == true

Or la méthode compareTo effectue une comparaison de valeur ce qui fait que, par extension, la méthode equals devient alors une égalité de valeur !

A première vue, nous pourrions en déduire qu’il n’y a pas de sémantique bien définie avec la méthode equals. En fait, il n’en est rien. Sa sémantique est bien celle d’une égalité d’identité et le problème ici relève plus de l’utilisation de la comparaison entre objets. En effet, il est fréquent de rencontrer dans du code une implémentation de la méthode compareTo dès qu’une comparaison entre différents objets d’une même classe est à faire. Par exemple, dans le cas d’un tri. Or, ceci peut n’avoir aucun sens vis à vis du concept véhiculé par ces objets. Prenons un exemple. Imaginons que l’on souhaite trier des événements calendaires par leur date de début. Une des solutions pourrait d’implémenter l’interface Comparable et sa méthode compareTo en vue de réaliser la comparaison sur les dates de début entre événements. Or ceci est une erreur conceptuelle ; on désire comparer ici des dates, pas les événements calendaires eux même. En effet, la méthode compareTo est une opération de comparaison de valeurs entre deux objets, autrement dit une opération de comparaison d’états entre deux objets. Or, la date de début d’un événement n’est qu’une caractéristique parmi d’autres de son état. Qu’en est-il de sa date de fin ? De sa récurrence ? De son intitulé ? etc. Comparer deux événements n’a de sens que pour indiquer s’ils sont in fine soit différents, soit égaux en valeur, et ça ce n’est pas la sémantique de la méthode compareTo. Pour comparer les événements par leur date de début, on utilisera plutôt un objet de type Comparator qui transportera lui la sémantique de comparaison que l’on voudra sur les événements et qui portera en fait sur une ou plusieurs de leurs propriétés.

Mais alors, qu’en est-il de l’intérêt de l’interface Comparable et de sa méthode compareTo ? Vous connaissez la classe String ? Cette classe qui représente une chaîne de caractères en Java est immutable et constante. Ceci signifie qu’une même chaîne de caractères est représentée par une même instance et ne peut être modifiée ; nous avons ici une égalité à la fois d’identité et de valeur. Lorsque vous comparez deux variables distinctes avec une même chaîne de caractères, en fait vous comparez derrière le même objet (les variables sont des références qui pointent ici sur la même instance). Si vous modifiez une chaîne de caractères, en fait, vous n’obtenez qu’une autre chaîne de caractères, donc une autre instance avec une autre valeur ; celle sur laquelle a été demandée la modification n’a pas changé. Et par conséquent, la méthode equals de String, donnant une égalité à la fois d’identité et de valeur, est consistante avec la méthode compareTo. C’est dans ce contexte que cette méthode prend tout son sens : lorsque l’identité et la valeur d’un objet sont intrinsèquement liés, autrement dit lorsque vos objets sont immutables.

En conséquence, la méthode equals en Java est une opération d’égalité d’identité et devrait le rester dans vos programmes. Si une égalité par valeur doit se faire entre deux de vos objets avec la méthode equals, alors soit vos objets doivent être immutables et vous pouvez surcharger la méthode equals en ce sens, soit vous devez définir une nouvelle méthode, comme par exemple sameAs.

Maintenant, les choses sont un tant soit peu quelque plus délicats avec les objets persistés. En effet, ce qui est stocké dans une source de données, quelle qu’elle soit (une source NoSQL ou un SBGDR), c’est l’état des objets, leur valeur, et en aucune manière les objets mêmes. (Ou alors vous disposez d’une source de données orientée objet et, mise à part dans le monde Smalltalk, je n’en connais aucune qui soit utilisée dans les autres écosystèmes de programmation.) Aussi, si par deux fois un même événement calendaire est demandé, le programme obtiendra, sans utilisation de caches, deux instances différentes de ce même événement et construites à partir d’une même valeur récupérée de la source de données. La méthode equals retournera donc, par défaut, false lorsqu’un bout de code voudra savoir s’il s’agit d’un même événement. Dans ce contexte, la sémantique d’égalité d’identité est brisée.

À la différence des objets à vie courte, les objets persistants ont une durée de vie qui va au delà du temps d’exécution du programme, voir même ont une vie au delà d’un processus donné. L’identité par adresse mémoire ne suffit donc plus. C’est là qu’intervient l’identité de persistance ou de stockage. En effet, cette identité assure l’unicité de l’objet dans la source de données et peut donc être utilisée aussi dans le programme pour marquer l’identité des objets en lieu et place de leur adresse mémoire. Pour aller plus loin et assurer aussi son unicité au delà des sources de données, l’idéal serait que cette identité soit une valeur unique et universelle (UUID). Ainsi, dès que vous devez manipuler des objets persistés, vous pouvez surcharger la méthode equals pour qu’elle compare l’égalité des identifiants de persistance des objets si ces derniers sont persistés, sinon leur valeur d’adresse si l’un d’eux ne l’est pas :

public boolean equals(Object other) {
  if (! other instanceof CalendarEvent) {
    return false;
  }
  CalendarEvent otherEvent = (CalendarEvent) other;
  if (this.getId() != null && otherEvent.getId() != null) {
    return this.getId().equals(otherEvent.getId());
  }
  return this == otherEvent;
}

(Nous pouvons aussi s’assurer ici que la méthode getId renvoie toujours une valeur sous forme de Value Object, soit le hash code dans le cas où l’objet n’est pas encore persisté, sinon l’identifiant de persistance.)


comments powered by Disqus