BT

Diffuser les Connaissances et l'Innovation dans le Développement Logiciel d'Entreprise

Contribuez

Sujets

Sélectionner votre région

Accueil InfoQ Articles Retour sur les bases : equals et hashCode

Retour sur les bases : equals et hashCode

Je suis régulièrement amené à recevoir des candidats de tous niveaux afin de procéder à une évaluation technique et une de mes questions fétiches est "Pouvez vous me dire à quoi servent equals et hashCode en Java et comment sont-ils liés l'un à l'autre ?".

Cet article peut paraître superflu car tout développeur se doit évidemment d'être parfaitement affûté sur ce sujet on ne peut plus basique. Pourtant, la réalité montre que la majorité des réponses sont au mieux incomplètes et bien souvent totalement fausses, il m'a même été demandé d'épeler le nom d'une des deux méthodes !



Une étude menée chez IBM sur un patrimoine de code conséquent a même recensé que la quasi-totalité des implémentations d'equals et hashCode s'avéraient à minima fragiles et la plupart du temps buggées.

Bref, la piqûre de rappel semble d'intérêt public...
 

Les données du problème

En Java, l'opérateur == compare les valeurs des types primitifs (int, long, char, etc). Utilisé entre deux objets, ce sont les adresses des références qui sont comparées, ce qui implique que l'utilisation de == sur deux instances distinctes sémantiquement équivalentes retournera false. Quel que soit le corps de la classe User, le test suivant est passant :

    @Test
    public void should_not_be_equals_equals() throws Exception {
        final User user1 = new User();

        final String userNAme = "user";
        user1.setName(userNAme);
        final User user2 = new User();
        user2.setName(userNAme);

        assertThat(user1 == user2).isFalse();

    }

Note : Avec la classe String et les wrappers des types primitifs, il peut y avoir quelques variantes car les instances peuvent être réutilisées sans risque à l'insu du développeur par la JVM grâce à leur immuabilité.

Pour comparer sémantiquement deux instances, la classe java.lang.Object est pourvue de la méthode equals que toute classe est libre de redéfinir en respectant les règles suivantes : l'implémentation doit être reflexive, symétrique, transitive et cohérente, je vous laisse vous référer à la javadoc pour consulter le détail de chaque propriété. Un autre point de la documentation mérite notre attention : il est généralement nécessaire de redéfinir hashCode lorsque equals est implémentée, et nous allons voir rapidement pourquoi généralement devrait être remplacé par absolument !

Si le choix de ne pas redéfinir equals est retenu, l'implémentation d'Object ne fait que comparer les adresses, soit le même comportement que ==.

Equals seul ...

Donnons à notre classe User une méthode equals :

    private static class User{
        private String name;

        @Override
        public boolean equals(Object obj) {
            if (obj == nullreturn false;

            if! (obj instanceof User) ) return false;

            User other = (User) obj;

            return this.name !=null ? this.name.equals(other.name) : this.name == other.name;
        }
    }

Son comportement est validé par le test suivant :

    @Test
    public void should_be_equals() throws Exception {
        final User user1 = new User();
        user1.setName("bali");

        final User user2 = new User();
        user2.setName("bali");

        final User user3 = new User();
        user3.setName("bali");

        /* Reflexive */
        assertThat(user1.equals(user1)).isTrue();
        assertThat(user2.equals(user2)).isTrue();
        assertThat(user3.equals(user3)).isTrue();

        /* Symmetric */
        assertThat(user1.equals(user2)).isTrue();
        assertThat(user2.equals(user1)).isTrue();

        /* Transitive */
        assertThat(user1.equals(user2)).isTrue(); //rappel
        assertThat(user2.equals(user3)).isTrue();
        assertThat(user1.equals(user3)).isTrue();

        assertThat(user1.equals(null)).isFalse();
        assertThat(user2.equals(null)).isFalse();
        assertThat(user3.equals(null)).isFalse();


    }

Le problème qui va maintenant se poser est lors de l'utilisation de collections basées sur hashCode, ce qu'illustre l'échec des trois tests suivant :

    @Test
    public void testHashSet() throws Exception {
        final User user1 = new User();
        user1.setName("bali");

        final User user2 = new User();
        user2.setName("bali");

        final Set<User> users = new HashSet<>();
        users.add(user1);

        assertThat(users.iterator().next()).isEqualTo(user2);
        assertThat(users.contains(user2)).isTrue(); // --> fails!
    }

    @Test
    public void testHashMap() throws Exception {
        final User user1 = new User();
        user1.setName("bali");

        final User user2 = new User();
        user2.setName("bali");

        final Map<UserInteger> map = new HashMap<>();
        map.put(user1, 2);

        assertThat(map).hasSize(1);
        assertThat(map.keySet().iterator().next()).isEqualTo(user2);
        assertThat(map.values().iterator().next()).isEqualTo(2);
        assertThat(map).containsEntry(user2, 2); // --> fails!
    }

    @Test
    public void should_not_contain_doubles() throws Exception {
        final User user1 = new User();
        user1.setName("bali");

        final User user2 = new User();
        user2.setName("bali");

        final Set<User> users = new HashSet<>();
        users.add(user1);
        users.add(user2);

        assertThat(user1.equals(user2)).isTrue();
        assertThat(users).hasSize(1); // -> fails
    }

Ce qu'il se produit ici est que les Hash(Map|Set) classent les objets dans un histogramme basé sur le hashCode. A l'instar d'equals, le hashCode par défaut de Hotspot convertit l'adresse en entier et donc retourne des valeurs différentes pour des objets différents. Les méthodes des hash collections utilisent la valeur du hashCode pour localiser un objet dans l'histogramme et si celui-ci ne contient pas cet entier, alors il est considéré que l'objet ne figure pas dans la collection. Deuxième effet kiss kool : puisque deux objets distincts mais sémantiquement égaux ont des hashCode différents, alors ils sont rangés dans des buckets différents impliquant le stockage de doublons, ce qui est en rupture avec le contrat de base du Set.

Avec les deux

Il est maintenant clair que redéfinir hashCode de concert avec equals est primordial. Mais qu'est ce que le hashCode ? Simplement une réduction d'un objet sous forme de valeur entière. L'implémentation doit être cohérente avec equals : deux objets égaux doivent présenter le même hashcode, en revanche il n'est pas requis que deux objets ayant le même hashCode soient forcément égaux, à noter cependant que la documentation mentionne que des collisions dans les hashCode peuvent avoir un impact sur la performance des hash collections. Donc l'implémentation suivante est évidemment à proscrire mais parfaitement valide :

    private static class User{
        private String name;

        @Override
        public boolean equals(Object obj) {
            if (obj == nullreturn false;

            if! (obj instanceof User) ) return false;

            User other = (User) obj;

            return this.name !=null ? this.name.equals(other.name) : this.name == other.name;
        }

        @Override
        public int hashCode() {
            return name == null? 0 : name.length(); // Ugly but correct!
        }
    }

Cette version permet de passer tous les tests précédents, toutefois pour une implémentation pertinente de hashCode, vous pouvez suivre la méthode de Josh Bloch définie dans Effective Java, item 9, ou bien faire appel à une bibliothèque tierse comme Apache commons-lang avec le org.apache.commons.lang3.builder.HashCodeBuilder ou encore confier sa génération à Lombok. Ma préférence personnelle penche du côté de Lombok car c'est l'option qui entraîne le moins de code visible.

Héritage

J'espère que vous n'avez pas encore fermé l'onglet du navigateur car il nous reste quelques détails à fignoler. Je vous propose maintenant de dériver notre classe User :

    private static class User{
        private String name;

        @Override
        public boolean equals(Object obj) {
            if (obj == nullreturn false;

            if! (obj instanceof User) ) return false;

            User other = (User) obj;

            return this.name !=null ? this.name.equals(other.name) : this.name == other.name;
        }

        @Override
        public int hashCode() {
            return name.length(); // Ugly but correct!
        }
    }

    private static class UserWithPassword extends User{
        private String password;

        @Override
        public boolean equals(Object obj) {
            if(!super.equals(obj)) return false;

            if! (obj instanceof UserWithPassword) ) return false;

            UserWithPassword other = (UserWithPassword) obj;

            return this.password !=null ? this.password.equals(other.password) : this.password == other.password;
        }

        @Override
        public int hashCode() {
            return super.hashCode() + (password == null ? 0 : password.length());
        }
    }

Basés sur le même design, les tests de base appliqués à UserWithPassword passent sans problème, mais si on commence à les comparer avec des User, les choses se compliquent :

    @Test
    public void should_be_equals() throws Exception {
        final User user1 = new User();
        user1.setName("bali");

        final UserWithPassword user2 = new UserWithPassword();
        user2.setName("bali");
        user2.setPassword("balo");

        /* Reflexive */
        assertThat(user1.equals(user1)).isTrue();
        assertThat(user2.equals(user2)).isTrue();

        /* Symmetric */
        assertThat(user1.equals(user2)).isTrue();
        assertThat(user2.equals(user1)).isTrue(); // -> fails
    }

    @Test
    public void hashCode_should_be_the_same() throws Exception {
        final User user1 = new User();
        user1.setName("bali");

        final UserWithPassword user2 = new UserWithPassword();
        user2.setName("bali");
        user2.setPassword("balo");

        assertThat(user1.equals(user2)).isTrue();

        /* contract with hashcode */
        assertThat(user1.hashCode()).isEqualTo(user2.hashCode()); // --> fails
    }

Les clauses du contrat portant sur la réflexivité et la cohérence avec hashCode sont rompues, compromettant ainsi les comparaisons ainsi que les utilisations dans les collections. Le problème est qu'un UserWithPassword est effectivement un User, il faut donc donner l'opportunité à la sous-classe de déterminer la pertinence de la comparaison :

    private static class User{
        private String name;

        @Override
        public boolean equals(Object obj) {
            if (obj == nullreturn false;

            if! (obj instanceof User) ) return false;

            User other = (User) obj;

            if(!other.canEqual(this)) return false;

            return this.name !=null ? this.name.equals(other.name) : this.name == other.name;
        }

        protected boolean canEqual(Object o) {
            return o instanceof User;
        }

        @Override
        public int hashCode() {
            return name.length(); // Ugly but correct!
        }
    }

    private static class UserWithPassword extends User{
        private String password;

        @Override
        public boolean equals(Object obj) {
            if(!super.equals(obj)) return false;

            if! (obj instanceof UserWithPassword) ) return false;

            UserWithPassword other = (UserWithPassword) obj;

            if(!other.canEqual(this)) return false;

            return this.password !=null ? this.password.equals(other.password) : this.password == other.password;
        }

        @Override
        protected boolean canEqual(Object o) {
            return o instanceof UserWithPassword;
        }

        @Override
        public int hashCode() {
            return super.hashCode() + (password == null ? 0 : password.length());
        }
    }

En plus de redéfinir equals, les sous-classes doivent maintenant également implémenter canEqual qui ne fait partie d'aucun contrat mais qui permet à une superclasse de déléguer une partie de la décision de comparaison à son argument. Dans notre exemple, UserWithPassword.canEqual n'acceptera la comparaison qu'avec des objets de même classe ou de classe dérivée, un User sera donc refusé. Rejeter toutes les instances d'une autre classe que User aurait également fonctionné, toutefois une telle restriction nuit à l'évolutivité : comment être en mesure de prouver qu'aucun cas métier de devra permettre à un objet d'une classe dérivée ultérieurement ajoutée d'être comparé à un User ? C'est d'ailleurs la conception retenue par le code généré par Lombok et une raison supplémentaire de le préférer à la conception proposée par EqualsBuilder, le compagnon de HashCodeBuilder dans la bibliothèque Apache commons-lang.

Au chapitre des problèmes potentiels liés à l'héritage, notons au passage que Java 8 a apporté les implémentations de méthodes par défaut dans les interfaces. Toutefois, le compilateur refusera toute implémentation par défaut des méthodes publiques non finales de java.lang.Object. La raison invoquée est que le principe des defender methods ne sont prévues que pour apporter l'héritage multiple de comportement uniquement et non d'état, or toutes ces méthodes (equals, hashCode, toString) sont sémantiquement liées à l'état de l'instance. Cette restriction préserve donc nos applications de nouveaux problèmes de conception en lien avec equals et hashCode !

One more thing...

En dépit de tous les cas couverts maintenant par la classe User, il reste une faille :

    @Test
    public void mutability_fails_in_collections() throws Exception {
        final User user = new User();
        user.setName("bali");

        final Set<User> set = new HashSet<>();
        set.add(user);

        assertThat(set).hasSize(1);
        assertThat(set.contains(user)).isTrue();

        user.setName("bali balo");

        assertThat(set).hasSize(1);
        assertThat(set.iterator().next()).isEqualTo(user);
        assertThat(set.contains(user)).isTrue(); // --> fails

    }

Si les champs utilisés pour le calcul du hashCode sont modifiés, alors forcément une hash collection cherchera l'objet dans un bucket dans lequel il n'a pas été placé, entraînant l'incohérence illustrée par le test ci-dessus.

Cette fois, ce n'est pas la technique qui pourra aider à colmater cette brèche car la problématique est purement métier : il s'agit d'extraire les clefs fonctionnelles du modèle. Dans le cas d'un User, le nom paraît assez indiqué car il est fort peu probable que ce champ soit amené à évoluer au cours de la vie de l'objet. Il pourrait être commode de ne pas en autoriser la modification en n'implémentant pas de mutateur, mais cela entraînerait une rupture avec la convention JavaBeans et généralement une incompatibilité avec les frameworks. La définition de l'identité fonctionnelle n'est malheureusement pas toujours aussi triviale que dans l'exemple.

Pour finir ...

Bien que ces concepts soient extrêment basiques, ces quelques exemples démontrent que les opportunités de se prendre les pieds dans le tapis sont multiples. De plus, les conséquences de ce type de problème sont souvent silencieuses et peuvent aller jusqu'à la corruption des données : imaginez un ensemble d'objets à persister, le développeur pousse deux instances équivalentes en misant que la collection dédoublonnera et ce sont finalement deux objets qui seront enregistrés...

Etant donné que la surcharge de ces méthodes est optionnelle conjugué au fait que la cohérence entre les deux n'est que difficilement démontrable, l'intégrité de l'identité fonctionnelle des objets repose sur la vigilance du développeur et du représentant du métier. Bref, souvent sous-estimé, ce chapitre mérite toute notre attention !

Au sujet de l'Auteur

Brice Leporini est un développeur sénior qui totalise plus de quinze ans d’expérience sur différentes technologies dont dix focalisées sur l’ecosystème Java et les architectures n-tiers. Freelance depuis huit ans, son activité actuelle oscille entre le coaching technique d’équipes de jeunes geeks, les travaux d’amélioration de performance et les études préalables. Retrouvez-le sur Twitter à @blep.

Evaluer cet article

Pertinence
Style

Contenu Éducatif

BT