{fellows}

Java : Thérapie du couple Equals et HashCode 💑

⏱️ Temps de lecture estimé : 12-15 minutes

📋 Sommaire

  1. Introduction : Un mystère à élucider
  2. Equals : Le perfectionniste (mais lent)
  3. HashCode : Le rapide (mais approximatif)
  4. Equals et HashCode : Un couple inséparable
  5. Le contrat de mariage : Les règles officielles
  6. L'anecdote qui fait peur : Quand hashCode devient incohérent
  7. La solution : L'immutabilité au secours du couple
  8. Pour aller plus loin
  9. En résumé : Les conseils du thérapeute

⚡ TL;DR - L'essentiel en 30 secondes

Le problème : Si vous modifiez un objet après l'avoir ajouté dans un Set ou une Map, il devient introuvable ! 😱

Pourquoi ?

  • hashCode() calcule un numéro de "tiroir" où ranger l'objet
  • Si vous modifiez l'objet, le hashCode() recalculé sera différent
  • Le Set cherche dans le mauvais tiroir → objet perdu !

La solution :

  • ✅ Utilisez des records (Java 14+) ou @Value de Lombok → objets immutables
  • ✅ Ne modifiez JAMAIS un objet après l'avoir mis dans un Set/Map
  • ✅ Si vous redéfinissez equals(), redéfinissez TOUJOURS hashCode()

Exemple concret dans l'article : Un code reproductible qui montre comment "Marie Durand" disparaît mystérieusement d'un Set après modification de son nom, mais reste trouvable dans une List.

👇 Lisez l'article complet pour comprendre le mécanisme en profondeur avec des schémas et des explications pas à pas.


Introduction : Un mystère à élucider

Imaginez ce scénario : vous avez une liste d'utilisateurs. Parmi eux, "Marie Durand" est présente et va changer de nom pour devenir "Marie Martin", vous la cherchez... et vous la trouvez. Normal.

Maintenant, même situation avec un Set au lieu d'une List... et là, surprise : impossible de trouver Marie ! 😱

Voici le code complet qui met en évidence ce comportement mystérieux :

import java.util.List;
import java.util.Objects;
import java.util.Set;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;

@Slf4j
public class EqualsHashCode {

  public static void main(String[] args) {
    User user1 = new User("Jean", "Dupont");
    User user2 = new User("Marie", "Durand");
    User user3 = new User("Lucas", "Martin");
    User user4 = new User("Sophie", "Bernard");
    List<User> usersInList = List.of(user1, user2, user3, user4);
    Set<User> usersInSet = Set.of(user1, user2, user3, user4);

    log.info("On souhaite chercher Marie Durand dans la List et dans le Set");
    User userToFind = new User("Marie", "Durand");
    log.info("Est-ce que Marie Durand est présente dans la List ? {}", usersInList.contains(userToFind));
    log.info("Est-ce que Marie Durand est présente dans le Set ? {}", usersInSet.contains(userToFind));

    log.info("Marie Durand devient Marie Martin");
    user2.setLastName("Martin");
    userToFind = new User("Marie", "Martin");

    log.info("Est-ce que Marie Martin est présente dans la List ? {}", usersInList.contains(userToFind));
    log.info("Est-ce que Marie Martin est présente dans le Set ? {}", usersInSet.contains(userToFind));
  }

  @Getter
  @Setter
  @AllArgsConstructor
  private static class User {
    private String firstName;
    private String lastName;

    @Override
    public String toString() {
      return firstName + " " + lastName;
    }

    // Note : Lombok peut générer automatiquement equals() et hashCode()
    // avec @EqualsAndHashCode, mais nous les implémentons ici manuellement
    // pour bien comprendre leur fonctionnement
    @Override
    public boolean equals(Object o) {
      log.debug("À l'intérieur de la méthode equals pour comparer {} et {}", this, o);
      if (o == null || getClass() != o.getClass()) return false;

      User user = (User) o;
      return Objects.equals(firstName, user.firstName) && Objects.equals(lastName, user.lastName);
    }

    @Override
    public int hashCode() {
      log.debug("À l'intérieur de la méthode hashCode pour {}", this);
      int result = Objects.hashCode(firstName);
      result = 31 * result + Objects.hashCode(lastName);
      return result;
    }
  }
}

Sortie du programme :

INFO - On souhaite chercher Marie Durand dans la List et dans le Set
INFO - Est-ce que Marie Durand est présente dans la List ? true
INFO - Est-ce que Marie Durand est présente dans le Set ? true
INFO - Marie Durand devient Marie Martin
INFO - Est-ce que Marie Martin est présente dans la List ? true
INFO - Est-ce que Marie Martin est présente dans le Set ? false  ← 💥

Que s'est-il passé ? La List trouve Marie après le changement de nom, mais pas le Set ! C'est exactement ce mystère que nous allons élucider ensemble en comprenant comment fonctionnent equals() et hashCode().

Equals et HashCode, c'est un peu comme un vieux couple qui travaille en coulisses : depuis l'arrivée de l'annotation @EqualsAndHashCode de Lombok, puis des records en Java, on ne les voit plus beaucoup. Pourtant, ils sont à l'œuvre à chaque fois que vous utilisez un Set, une Map, ou que vous faites une recherche dans une collection.

Aujourd'hui, séance de thérapie pour comprendre qui fait quoi, pourquoi ils ne peuvent pas se séparer, et surtout... pourquoi Marie a disparu du Set ! 🔍


🎯 Equals : Le perfectionniste (mais lent)

Comment ça marche ?

La méthode equals() compare deux objets de manière exhaustive et précise. Elle vérifie chaque attribut un par un pour déterminer si deux objets sont "égaux" selon la logique métier.

Dans notre exemple, deux utilisateurs sont considérés comme égaux si leur firstName ET leur lastName sont identiques :

@Override
public boolean equals(Object o) {
    if (o == null || getClass() != o.getClass()) return false;

    User user = (User) o;
    return Objects.equals(firstName, user.firstName) &&
           Objects.equals(lastName, user.lastName);
}

Comment une List utilise equals() ?

Quand vous appelez usersInList.contains(userToFind), voici ce qui se passe en coulisses :

  1. La List parcourt tous les éléments un par un (itération)
  2. Pour chaque élément, elle appelle element.equals(userToFind)
  3. Si equals() retourne true, elle arrête et retourne true
  4. Si elle arrive au bout sans trouver, elle retourne false

Exemple concret avec notre code :

Recherche de "Marie Durand" dans la List :
1. "Jean Dupont".equals("Marie Durand") → false
2. "Marie Durand".equals("Marie Durand") → true ✅ Trouvé !

Après modification du nom en "Martin" :

Recherche de "Marie Martin" dans la List :
1. "Jean Dupont".equals("Marie Martin") → false
2. "Marie Martin".equals("Marie Martin") → true ✅ Trouvé !

💡 Point clé : La List compare les valeurs actuelles des objets. Même si vous modifiez un objet après l'avoir ajouté, equals() utilise les nouvelles valeurs pour la comparaison.

Le problème : la performance

Avec 4 utilisateurs, pas de souci. Mais imaginez une List de 1 million d'utilisateurs ! Dans le pire cas (l'utilisateur recherché est le dernier ou n'existe pas), la List doit parcourir et comparer 1 million d'objets. C'est ce qu'on appelle une complexité O(n) - le temps augmente proportionnellement avec le nombre d'éléments. 🐢

C'est là qu'hashCode() entre en jeu pour sauver la mise !


⚡ HashCode : Le rapide (mais approximatif)

Comment ça marche ?

La méthode hashCode() calcule un nombre entier (le "hash code") qui représente l'objet. L'idée : transformer les données de l'objet en un seul nombre qui servira d'"identifiant rapide".

@Override
public int hashCode() {
    int result = Objects.hashCode(firstName);
    result = 31 * result + Objects.hashCode(lastName);
    return result;
}

Exemples de hash codes pour nos utilisateurs :

"Jean Dupont"    → hashCode: 2129727086
"Marie Durand"   → hashCode:   60886204
"Lucas Martin"   → hashCode:  289483407
"Sophie Bernard" → hashCode: 1083766916

C'est quoi un bucket (tiroir) ?

Imaginez une commode avec des tiroirs numérotés. Le Set (ou HashMap) utilise le hashCode() pour déterminer dans quel tiroir ranger chaque objet.

Analogie concrète :

  • Vous avez une commode avec 16 tiroirs (numérotés de 0 à 15)
  • Vous voulez ranger "Marie Durand" dont le hashCode est 60886204
  • Question : dans quel tiroir la ranger ?

Réponse : On utilise le modulo !

Numéro du tiroir = hashCode % nombre_de_tiroirs
Numéro du tiroir = 60886204 % 16 = 12

Marie va dans le tiroir n°12.

💡 Le modulo (%) donne le reste de la division. C'est comme une horloge : 15h % 12 = 3h.

Schéma : La commode à tiroirs du Set

┌─────────────────────────────────────┐
│         Commode (HashSet)           │
├─────────────────────────────────────┤
│ Tiroir 0:  []                       │
│ Tiroir 1:  []                       │
│ ...                                 │
│ Tiroir 3:  []                       │
│ Tiroir 4:  [Sophie Bernard]         │  ← hashCode % 16 = 4
│ ...                                 │
│ Tiroir 12: [Marie Durand]           │  ← hashCode % 16 = 12
│ ...                                 │
│ Tiroir 14: [Jean Dupont]            │  ← hashCode % 16 = 14
│ Tiroir 15: [Lucas Martin]           │  ← hashCode % 16 = 15
└─────────────────────────────────────┘

Comment un Set utilise hashCode() pour trouver un élément ultra-rapidement ?

Quand vous appelez usersInSet.contains(userToFind), voici ce qui se passe :

Étape 1 : Calculer le hashCode

int hash = userToFind.hashCode(); // Ex: 60886204 pour "Marie Durand"

Étape 2 : Trouver le bon tiroir (bucket)

int bucketIndex = hash % numberOfBuckets; // 60886204 % 16 = 12

Étape 3 : Chercher uniquement dans CE tiroir

  • Le Set va directement au tiroir n°12
  • Il ne regarde QUE les objets dans ce tiroir (pas besoin de parcourir les 15 autres !)
  • Dans le tiroir, il utilise equals() pour comparer avec les objets présents

💡 La magie : Le Set ne parcourt PAS toute la collection ! Il va directement au bon tiroir grâce à un calcul mathématique (le modulo). C'est comme connaître directement la page d'un dictionnaire au lieu de le feuilleter depuis le début.

Performance :

  • List : doit potentiellement parcourir 1 million d'objets → O(n) 🐢
  • Set : va directement au bon tiroir, qui contient peut-être 10 objets → O(1) en moyenne 🚀

Les collisions : quand plusieurs objets partagent le même tiroir

Parfois, plusieurs objets différents peuvent avoir le même numéro de tiroir (même résultat du modulo). C'est ce qu'on appelle une collision.

Exemple :

"Marie Durand"   → hashCode: 60886204 → 60886204 % 16 = 12 → Tiroir 12
"Paul Bernard"   → hashCode: 1522787500 → 1522787500 % 16 = 12 → Tiroir 12 aussi !

Les deux vont dans le même tiroir (collision), mais ce n'est pas grave ! Le Set utilisera ensuite equals() pour les différencier dans le tiroir.

Schéma avec collision :

┌─────────────────────────────────────┐
│ Tiroir 12: [Marie Durand]           │
│            [Paul Bernard]  ← Collision !
└─────────────────────────────────────┘

Quand le Set cherche "Marie Durand" dans le tiroir 12, il va :

  1. Comparer avec "Marie Durand" → equals() retourne true ✅
  2. Pas besoin de continuer, c'est trouvé !

Si le Set cherche "Sophie Bernard" :

  1. Calculer son hashCode → 1083766916
  2. 1083766916 % 16 = 4 → Aller directement au tiroir 4
  3. Comparer avec equals() dans ce tiroir

💡 Même avec des collisions, c'est toujours beaucoup plus rapide que de parcourir toute la collection !


💑 Equals et HashCode : Un couple inséparable

Vous commencez à comprendre : hashCode() permet de trouver le bon tiroir rapidement, puis equals() permet de vérifier précisément l'égalité. Mais que se passe-t-il si on redéfinit l'un sans l'autre ?

Exemple concret du drame

Imaginons que vous redéfinissez equals() pour comparer les utilisateurs par leurs noms, mais que vous oubliez de redéfinir hashCode() :

@Override
public boolean equals(Object o) {
    if (o == null || getClass() != o.getClass()) return false;
    User user = (User) o;
    return Objects.equals(firstName, user.firstName) &&
           Objects.equals(lastName, user.lastName);
}

// hashCode() pas redéfini → utilise la version par défaut de Object
// qui retourne un nombre basé sur l'adresse mémoire de l'objet !

Ce qui se passe :

User marie1 = new User("Marie", "Durand");
User marie2 = new User("Marie", "Durand");

// Selon equals(), ils sont égaux
marie1.equals(marie2); // true ✅

// Mais leurs hashCodes sont différents (adresses mémoire différentes)
marie1.hashCode(); // Ex: 6738746
marie2.hashCode(); // Ex: 2096171631

Set<User> users = new HashSet<>();
users.add(marie1);
users.contains(marie2); // FALSE ! 💥

Pourquoi false ?

  1. Le Set calcule le hashCode de marie2 → 2096171631
  2. Il calcule le numéro du tiroir : 2096171631 % 16 = 15
  3. Il va chercher dans le tiroir 15
  4. Mais marie1 est dans un autre tiroir (tiroir 10, par exemple)
  5. Le Set ne trouve rien dans le tiroir 15 → retourne false

Même si marie1.equals(marie2) retourne true, le Set ne les trouve jamais car ils sont dans des tiroirs différents ! 😱

La règle d'or du couple (le contrat)

Si deux objets sont égaux selon equals(), ils DOIVENT avoir le même hashCode()

C'est le "contrat de mariage" entre ces deux méthodes. Si vous violez ce contrat, vos collections (Set, Map, etc.) ne fonctionneront pas correctement.

💡 En résumé :

  • Si a.equals(b) retourne true, alors a.hashCode() == b.hashCode() doit être true
  • Si a.hashCode() == b.hashCode(), ça ne garantit pas que a.equals(b) (collision possible)

📜 Le contrat de mariage : Les règles officielles

Java impose des règles strictes à ce couple. Voici le résumé de leurs vœux officiels :

Pour equals() :

La méthode equals() doit respecter 5 propriétés :

  1. Réflexive : Un objet est toujours égal à lui-même

    x.equals(x) == true
  2. Symétrique : Si x = y, alors y = x

    x.equals(y) == y.equals(x)
  3. Transitive : Si x = y et y = z, alors x = z

    si x.equals(y) && y.equals(z), alors x.equals(z)
  4. Consistante : Appeler equals() plusieurs fois doit toujours donner le même résultat (tant que les objets ne changent pas)

    x.equals(y) retourne toujours la même valeur
  5. Null-safe : Aucun objet x non null n'est égal à null

    x.equals(null) == false

Pour hashCode() :

  1. Consistance : Appeler hashCode() plusieurs fois sur le même objet DOIT retourner la même valeur (durant l'exécution de l'application)

    x.hashCode() retourne toujours le même nombre
    // (tant que les attributs utilisés dans equals ne changent pas)
  2. Égalité implique même hash : Si deux objets sont égaux selon equals(), ils doivent avoir le même hashCode()

    si x.equals(y) == true, alors x.hashCode() == y.hashCode()
  3. Différence n'implique pas hash différent : Deux objets différents PEUVENT avoir le même hashCode() (collision acceptable)

    si x.equals(y) == false, x.hashCode() peut être == ou != y.hashCode()

💡 Remarque importante : Dans le contrat, on utilise le terme "consistante" pour equals() et "consistance" pour hashCode(). C'est la même idée : le résultat ne doit pas changer tant que les données ne changent pas.


🚨 L'anecdote qui fait peur : Quand hashCode devient incohérent

Maintenant, revenons à notre mystère initial : pourquoi Marie disparaît du Set après modification de son nom ?

Analysons étape par étape ce qui se passe dans notre code :

Étape 1 : Ajout initial dans le Set

User user2 = new User("Marie", "Durand");
Set<User> usersInSet = Set.of(user1, user2, user3, user4);

Ce qui se passe :

  1. Le Set calcule le hashCode() de user2 avec le nom "Durand"

    hashCode("Marie", "Durand") = 60886204
  2. Il calcule le numéro du tiroir :

    60886204 % 16 = 12  → Tiroir 12
  3. Il range user2 dans le tiroir 12

Schéma :

┌──────────────────────────────────────────┐
│ Tiroir 0:  []                            │
│ Tiroir 1:  []                            │
│ ...                                      │
│ Tiroir 12: [user2: Marie Durand] ← Ici ! │
│ ...                                      │
└──────────────────────────────────────────┘

Étape 2 : Première recherche (avant modification)

User userToFind = new User("Marie", "Durand");
usersInSet.contains(userToFind);

Ce qui se passe :

  1. Calcul du hashCode() de userToFind :

    hashCode("Marie", "Durand") = 60886204  ← Même hashCode !
  2. Calcul du tiroir :

    60886204 % 16 = 12  → Tiroir 12
  3. Le Set va au tiroir 12 et compare avec equals() :

    user2.equals(userToFind)
    → compare firstName: "Marie" == "Marie" ✅
    → compare lastName: "Durand" == "Durand" ✅
    → retourne true

Résultat : true ✅ Marie est trouvée !

Étape 3 : Modification du nom

// Modifie user2.lastName = "Martin"
user2.setLastName("Martin");
// Modifie userToFind pour chercher Marie Martin
userToFind = new User("Marie", "Martin");

⚠️ Attention : On vient de modifier l'objet user2 après l'avoir ajouté dans le Set !

État actuel :

user2 dans le Set        : Marie Martin (mais toujours dans le tiroir 12 !)
userToFind qu'on cherche : Marie Martin

Étape 4 : Recherche après modification (le drame !)

usersInSet.contains(userToFind);

Ce qui se passe :

  1. Calcul du hashCode() de userToFind avec le nouveau nom "Martin" :

    hashCode("Marie", "Martin") = 300096257  ← Nouveau hashCode !
  2. Calcul du tiroir avec le nouveau hashCode :

    300096257 % 16 = 1  → Tiroir 1 ← DIFFÉRENT du tiroir 12 !
  3. Le Set va au tiroir 1 et cherche...

    • Le tiroir 1 est vide (ou contient d'autres objets)
    • user2 (Marie) est toujours dans le tiroir 12 avec son ancien hashCode !

Résultat : false 💥 Marie n'est PAS trouvée !

Schéma du problème :

Avant modification :
┌────────────────────────────────────────────────┐
│ Tiroir 1:  []                                  │
│ Tiroir 12: [user2: Marie Durand] ← Rangée ici  │
└────────────────────────────────────────────────┘
recherche("Marie Durand") → calcul tiroir 12 → Trouvé ! ✅

Après modification :
┌───────────────────────────────────────────────────┐
│ Tiroir 1:  []                                     │
│ Tiroir 12: [user2: Marie Martin] ← Toujours ici ! │
└───────────────────────────────────────────────────┘
recherche("Marie Martin") → calcul tiroir 1 → Pas trouvé ! ❌

Le problème : L'objet user2 est physiquement toujours dans le tiroir 12 (là où il a été rangé avec "Durand"), mais quand on cherche "Marie Martin", le calcul du hashCode() nous envoie au tiroir 1 ! C'est comme chercher un livre à la lettre M alors qu'il est rangé à la lettre D.

Pourquoi la List n'a pas ce problème ?

La List n'utilise PAS de tiroirs ni de hashCode() ! Elle parcourt simplement tous les éléments et utilise equals() pour comparer les valeurs actuelles :

usersInList.contains(userToFind);
→ Parcourt tous les éléments
→ Compare avec equals("Marie", "Martin")
→ Trouve user2 qui a maintenant firstName="Marie" et lastName="Martin"
→ Retourne true ✅

La List s'en fiche du hashCode(), elle compare juste les valeurs actuelles avec equals(). Mais c'est beaucoup plus lent sur de grandes collections !

💡 Moralité : Ne JAMAIS modifier un objet après l'avoir ajouté dans un Set ou utilisé comme clé dans une Map. Vous cassez la "cohérence" du hashCode() et votre objet devient "introuvable" dans la collection, comme perdu dans les limbes. 👻


🛡️ La solution : L'immutabilité au secours du couple

Comment éviter ce drame ? Rendez vos objets immutables !

Avec les records (Java 14+) :

public record User(String firstName, String lastName) {
    // equals() et hashCode() générés automatiquement ✅
    // Immutabilité garantie : impossible de modifier firstName ou lastName ! ✅
}

Avec un record, impossible de faire user.setLastName("Martin") car il n'y a pas de setter. Le problème est résolu à la racine !

Avec Lombok (avant les records) :

@Value // Comme @Data mais immutable
public class User {
    String firstName;
    String lastName;
    // equals() et hashCode() générés automatiquement ✅
    // Attributs final : immutabilité garantie ! ✅
}

@Value génère automatiquement :

  • Des champs final (immutables)
  • Un constructeur avec tous les champs
  • Les méthodes equals() et hashCode()
  • Pas de setters !

Si vous DEVEZ avoir des objets mutables ?

Si vous n'avez pas le choix et que vos objets doivent être mutables, alors :

❌ Ne les utilisez JAMAIS comme :

  • Éléments d'un Set
  • Clés d'une Map

✅ Utilisez plutôt :

  • Une List (qui n'utilise que equals(), pas de hashCode())
  • Des wrappers immutables autour de vos objets mutables
  • Assurez-vous que les objets ne sont pas modifiés entre leur insertion et leur recherche

💡 Conseil : Dans une grande majorité des cas, les objets de données (DTO, entités, value objects) devraient être immutables. C'est plus sûr, plus simple, et ça évite une tonne de bugs subtils.


📚 Pour aller plus loin

Si vous voulez approfondir le sujet :

Sur les 5 propriétés d'equals (réflexive, symétrique, transitive, etc.) :

Sur hashCode et le mystère du nombre 31 :

Sur les collisions et la performance :

Sur l'implémentation interne des HashMap :

Sur les bonnes pratiques equals/hashcode concernant les entités Hibernate (non évoqué dans cet article) :


🎯 En résumé : Les conseils du thérapeute

  1. Utilisez des records (Java 14+) ou @Value de Lombok pour vos objets de données → Immutabilité + equals()/hashCode() gratuits et corrects
  2. Si vous redéfinissez equals(), redéfinissez TOUJOURS hashCode() → Sinon vos collections (Set, Map) ne fonctionneront pas correctement
  3. Ne modifiez JAMAIS un objet après l'avoir ajouté dans un Set ou utilisé comme clé de Map → Le hashCode() devient incohérent et l'objet devient introuvable
  4. Laissez votre IDE ou Lombok générer ces méthodes → Moins d'erreurs, code plus maintenable
  5. Utilisez les bons types de collections selon vos besoins :
    • List : Ordre important, recherche O(n), pas de contrainte d'immutabilité
    • Set : Pas de doublons, recherche O(1), objets doivent être immutables ou non modifiés

Equals et hashCode(), c'est comme un couple qui danse le tango : ils doivent être parfaitement synchronisés, sinon c'est la chute assurée ! 💃🕺

contact

Premier contact
et pas le dernier

Je suis