equals() et hashCode() : contrat et erreurs courantes

1. Aperçu

equals() et hashCode() sont deux méthodes simples en apparence, mais mal implémentées, elles font que HashSet contient des doublons et HashMap « perd » ses clés. Ces erreurs ne lèvent généralement pas d’exception. Le programme continue à s’exécuter, il fonctionne juste mal.

Cet article présente le contrat que Java impose à ces deux méthodes. Ensuite, on traverse les erreurs courantes, chacune avec un exemple qui la reproduit. Après la lecture, vous saurez bien implémenter ces deux méthodes, quand utiliser record, et comment écrire des tests pour les vérifier.

2. Le contrat de equals()

Par défaut, Object.equals() compare les références, c’est-à-dire this == obj. En l’overridant, vous devez préserver ces cinq propriétés pour toute référence autre que null :

  • Reflexivité : x.equals(x) est toujours true.
  • Symétrie : x.equals(y) est true si et seulement si y.equals(x) est true.
  • Transitivité : si x.equals(y) et y.equals(z), alors x.equals(z).
  • Cohérence : si l’objet ne change pas, l’appel répété doit donner le même résultat.
  • Avec null : x.equals(null) est toujours false.

Une implémentation standard pour une classe Customer ressemble à ceci :

import java.util.Objects;

public final class Customer {
    private final String email;
    private final String name;

    public Customer(String email, String name) {
        this.email = email;
        this.name = name;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Customer other)) return false;
        return Objects.equals(email, other.email)
                && Objects.equals(name, other.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(email, name);
    }
}

Explication de chaque partie :

  • this == o est un raccourci quand deux références pointent le même objet.
  • instanceof Customer other utilise la correspondance de motif (pattern matching, Java 16+). Cette expression retourne false quand o est null, donc la propriété « avec null » est garantie sans vérification supplémentaire.
  • Objects.equals est sûr avec un champ null.
  • Objects.hash calcule le hash exactement à partir des champs utilisés dans equals().

La classe est déclarée final. La raison sera claire à la section 6.

3. Le contrat de hashCode()

hashCode() a trois règles :

  1. L’appel répété sur le même objet inchangé doit retourner la même valeur.
  2. Si a.equals(b), alors a.hashCode() == b.hashCode(). C’est la règle la plus importante.
  3. Deux objets différents peuvent avoir le même hash code (collision). Néanmoins, une bonne distribution des hash aide les collections à s’exécuter plus vite.

L’inverse de la règle 2 n’est pas vrai. Même hash code ne signifie pas égal.

Pour voir pourquoi la règle 2 est importante, regardez comment HashMap trouve une clé. D’abord il utilise hashCode() pour sélectionner un bucket. Ensuite il utilise equals() pour comparer les éléments dans ce bucket. Si deux objets « égaux » ont des hash différents, ils se trouvent dans deux buckets différents, et equals() n’est jamais appelé.

4. Erreur 1 : Override equals() en oubliant hashCode()

C’est une erreur classique. Supposons que vous omettiez hashCode() dans la classe de la section 2 :

public class Customer {
    private final String email;

    public Customer(String email) { this.email = email; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Customer other)) return false;
        return email.equals(other.email);
    }
    // manque hashCode()
}
Set<Customer> customers = new HashSet<>();
customers.add(new Customer("an@example.com"));
customers.add(new Customer("an@example.com"));

System.out.println(customers.size());
System.out.println(customers.contains(new Customer("an@example.com")));
2
false

Les deux objets sont égaux selon equals(). Mais hashCode() reste l’implémentation par défaut de Object, généralement différente pour chaque instance. Le résultat est que HashSet contient deux éléments « en double », et contains() retourne false. Ce résultat est pratiquement certain de se produire. Deux hash par défaut correspondent rarement, donc l’erreur est encore plus difficile à reproduire de façon stable.

Remarque : les IDE et les outils comme SonarQube avertissent d’une telle erreur. Ne désactivez pas cet avertissement.

5. Erreur 2 : Overload au lieu d’override

Cette erreur est beaucoup plus subtile :

public class Order {
    private final long id;

    public Order(long id) { this.id = id; }

    public boolean equals(Order other) {   // FAUX : le paramètre est Order, pas Object
        return other != null && id == other.id;
    }

    @Override
    public int hashCode() { return Long.hashCode(id); }
}
Order a = new Order(1);
Order b = new Order(1);

System.out.println(a.equals(b));                  // appelle equals(Order)
System.out.println(List.of(a).contains(b));       // appelle equals(Object)
true
false

equals(Order) est une nouvelle méthode (overload). Elle n’overrides pas equals(Object). Les collections appellent toujours equals(Object), donc elle utilise toujours la comparaison par référence de Object.

La prévention est très simple : toujours ajouter @Override. Si vous ajoutez @Override à la méthode ci-dessus, le compilateur signalera l’erreur immédiatement.

6. Erreur 3 : Briser la symétrie lors de l’héritage

Considérez une classe Point et une sous-classe ColorPoint qui ajoute le champ color :

public class Point {
    protected final int x, y;
    public Point(int x, int y) { this.x = x; this.y = y; }

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof Point p)) return false;
        return x == p.x && y == p.y;
    }

    @Override
    public int hashCode() { return Objects.hash(x, y); }
}

public class ColorPoint extends Point {
    private final String color;
    public ColorPoint(int x, int y, String color) { super(x, y); this.color = color; }

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof ColorPoint cp)) return false;
        return super.equals(cp) && color.equals(cp.color);
    }

    @Override
    public int hashCode() { return Objects.hash(x, y, color); }
}
Point p = new Point(1, 2);
ColorPoint cp = new ColorPoint(1, 2, "RED");

System.out.println(p.equals(cp));
System.out.println(cp.equals(p));
true
false

La symétrie est brisée. De plus, p.equals(cp) est true mais les hash des deux objets sont différents, donc la règle 2 de la section 3 est aussi violée.

Il n’y a aucun moyen d’ajouter un champ significatif à une sous-classe tout en maintenant le contrat avec instanceof. Vous avez trois options :

  • Déclarer la classe final ou utiliser record. C’est la façon la plus simple.
  • Utiliser getClass() != o.getClass() au lieu d’instanceof. Dans ce cas, Point et ColorPoint ne sont jamais égaux. Cette approche préserve le contrat, mais une sous-classe sans champ supplémentaire (par exemple une proxy) ne peut pas être égale à la classe parente.
  • Préférer la composition : faire en sorte que ColorPoint contienne un Point au lieu d’hériter de Point.

7. Erreur 4 : Champ mutable comme clé

hashCode() est calculé quand l’objet est ajouté à HashSet. Si après un champ utilisé pour calculer le hash change, l’objet reste dans son ancien bucket :

public class Product {
    private String sku;
    public Product(String sku) { this.sku = sku; }
    public void setSku(String sku) { this.sku = sku; }

    @Override
    public boolean equals(Object o) {
        return o instanceof Product p && Objects.equals(sku, p.sku);
    }

    @Override
    public int hashCode() { return Objects.hashCode(sku); }
}
Product product = new Product("SKU-1");
Set<Product> catalog = new HashSet<>();
catalog.add(product);

product.setSku("SKU-2");

System.out.println(catalog.contains(product));
System.out.println(catalog.remove(product));
System.out.println(catalog.size());
false
false
1

L’objet reste dans le set, mais vous ne le trouvez pas et ne pouvez pas le supprimer. Le résultat est une petite fuite mémoire et une logique incorrecte.

Remarque : Utilisez uniquement des champs immuables dans equals()/hashCode(). Si vous devez utiliser un champ mutable, ne le modifiez pas quand l’objet est une clé dans HashMap ou un élément dans HashSet. La bonne façon est de retirer l’objet, le modifier, puis le rajouter.

8. Erreur 5 : Les pièges de BigDecimal et des tableaux

Certains types du JDK ont un equals() qui ne fonctionne pas comme vous l’attendez.

8.1. BigDecimal compare aussi l’échelle

BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("1.00");

System.out.println(a.equals(b));
System.out.println(a.compareTo(b) == 0);
false
true

BigDecimal.equals() considère 1.0 et 1.00 comme différents car leur échelle est différente. Si votre classe Money utilise BigDecimal dans equals(), normalisez d’abord l’échelle. Par exemple, appelez amount.setScale(2, RoundingMode.HALF_UP) dans le constructeur. Notez que stripTrailingZeros() normalise aussi, mais peut produire une échelle négative, par exemple avec 100.

8.2. Les tableaux comparent les références

Les tableaux n’overridaient pas equals(). Donc new int[]{1, 2}.equals(new int[]{1, 2}) retourne false. Quand un champ est un tableau, utilisez Arrays.equals() et Arrays.hashCode(). Ce piège s’applique aussi aux record, comme vous le verrez à la section suivante.

9. Utiliser record pour éviter la plupart des erreurs

À partir de Java 16, record génère automatiquement equals(), hashCode() et toString() basés sur tous les composants :

public record OrderLine(String productSku, int quantity) {}
var line1 = new OrderLine("SKU-1", 2);
var line2 = new OrderLine("SKU-1", 2);

System.out.println(line1.equals(line2));
System.out.println(line1.hashCode() == line2.hashCode());
true
true

record est une classe final, les champs sont final, et les deux méthodes sont générées correctement selon le contrat. Les erreurs des sections 4, 5 et 6 disparaissent. L’erreur de la section 7 disparaît presque aussi, sauf si le composant est un objet mutable, par exemple une List mutable.

Deux points à retenir avec record :

  • Un composant qui est un tableau est toujours comparé par référence. Utilisez List à la place d’un tableau.
  • Un composant BigDecimal est toujours sujet au piège de l’échelle. Normalisez dans le constructeur compact :
public record Money(BigDecimal amount, String currency) {
    public Money {
        amount = amount.setScale(2, RoundingMode.HALF_UP);
    }
}

Avec cette déclaration, new Money(new BigDecimal("1.0"), "VND") est égal à new Money(new BigDecimal("1.00"), "VND"), car les deux sont normalisés à l’échelle 2.

10. Tester equals() et hashCode()

Vous devriez avoir des tests pour le comportement principal. Voici un test avec JUnit 5 et AssertJ pour la classe Customer de la section 2 :

import org.junit.jupiter.api.Test;
import java.util.HashSet;
import java.util.Set;

import static org.assertj.core.api.Assertions.assertThat;

class CustomerTest {

    @Test
    void equalCustomers_shouldHaveSameHashCode_andDeduplicateInSet() {
        var a = new Customer("an@example.com", "An");
        var b = new Customer("an@example.com", "An");

        assertThat(a).isEqualTo(b);
        assertThat(b).isEqualTo(a);
        assertThat(a.hashCode()).isEqualTo(b.hashCode());

        Set<Customer> set = new HashSet<>(Set.of(a));
        assertThat(set).contains(b);
    }

    @Test
    void customer_shouldNotEqualNullOrOtherType() {
        var a = new Customer("an@example.com", "An");

        assertThat(a).isNotEqualTo(null);
        assertThat(a).isNotEqualTo("an@example.com");
    }
}

Le premier test vérifie la symétrie, la règle « égal implique même hash », et le comportement réel dans un HashSet. Le second test vérifie le cas null et le cas d’un type différent.

Écrire tous les cas à la main est assez long. La bibliothèque EqualsVerifier vérifie le contrat entier en une seule ligne :

<dependency>
    <groupId>nl.jqno.equalsverifier</groupId>
    <artifactId>equalsverifier</artifactId>
    <version>3.16.1</version>
    <scope>test</scope>
</dependency>
@Test
void equalsContract() {
    EqualsVerifier.forClass(Customer.class).verify();
}

Si vous oubliez un champ dans hashCode(), utilisez un champ non final, ou une classe non final avec instanceof, le test échouera avec un message indiquant quelle règle est violée.

11. Quand NE PAS overrider

Toutes les classes n’ont pas besoin d’overrider equals()/hashCode() :

  • Classes qui ont une identité naturelle par instance, comme Thread, PaymentService ou les beans Spring. Deux services sont deux objets différents, et la comparaison par référence est correcte.
  • Entités JPA : évitez d’utiliser tous les champs. Ne vous fiez pas naïvement à @Id auto-généré, car id est null avant la persistence, ce qui change le hash après la sauvegarde. Une approche courante consiste à comparer par une clé métier immuable. Une autre approche consiste à comparer par id quand il n’est pas null, et laisser hashCode() retourner une constante de classe, par exemple getClass().hashCode().
  • Lombok @Data sur une entité ou une classe avec relations bidirectionnelles : cette annotation génère equals()/hashCode() utilisant tous les champs. Cela peut causer une StackOverflowError quand deux entités se référencent mutuellement, ou déclencher un chargement lazy involontaire.

12. Conclusion

Le contrat entre equals() et hashCode() tourne autour d’une règle centrale : deux objets égaux doivent avoir le même hash code. La plupart des erreurs pratiques viennent de cinq causes :

  • oublier d’overrider hashCode() ;
  • faire un overload equals(Order) au lieu d’overrider equals(Object) ;
  • l’héritage qui brise la symétrie ;
  • utiliser un champ mutable comme clé ;
  • les pièges de BigDecimal et des tableaux.

Dans le code nouveau, privilégiez record pour les value objects. Pour les classes ordinaires, déclarez final, ajoutez toujours @Override, et utilisez uniquement des champs immuables. Enfin, ajoutez un test avec EqualsVerifier pour que l’outil vérifie le contrat pour vous.

javaequalshashcodecollectionsrecord