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 toujourstrue. - Symétrie :
x.equals(y)esttruesi et seulement siy.equals(x)esttrue. - Transitivité : si
x.equals(y)ety.equals(z), alorsx.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 toujoursfalse.
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 == oest un raccourci quand deux références pointent le même objet.instanceof Customer otherutilise la correspondance de motif (pattern matching, Java 16+). Cette expression retournefalsequandoestnull, donc la propriété « avec null » est garantie sans vérification supplémentaire.Objects.equalsest sûr avec un champnull.Objects.hashcalcule le hash exactement à partir des champs utilisés dansequals().
La classe est déclarée final. La raison sera claire à la section 6.
3. Le contrat de hashCode()
hashCode() a trois règles :
- L’appel répété sur le même objet inchangé doit retourner la même valeur.
- Si
a.equals(b), alorsa.hashCode() == b.hashCode(). C’est la règle la plus importante. - 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
finalou utiliserrecord. C’est la façon la plus simple. - Utiliser
getClass() != o.getClass()au lieu d’instanceof. Dans ce cas,PointetColorPointne 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
ColorPointcontienne unPointau lieu d’hériter dePoint.
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é dansHashMapou un élément dansHashSet. 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
BigDecimalest 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,PaymentServiceou 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 à
@Idauto-généré, caridestnullavant 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 paridquand il n’est pasnull, et laisserhashCode()retourner une constante de classe, par exemplegetClass().hashCode(). - Lombok
@Datasur une entité ou une classe avec relations bidirectionnelles : cette annotation génèreequals()/hashCode()utilisant tous les champs. Cela peut causer uneStackOverflowErrorquand 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’overriderequals(Object); - l’héritage qui brise la symétrie ;
- utiliser un champ mutable comme clé ;
- les pièges de
BigDecimalet 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.