Vous aimez ce que vous lisez sur ce blog ?
Envie d'aller plus loin avec véritable formation d'expertise en Java ?
Venez suivre ma formation Masterclasse Expertise Java !

"Même un développeur experimenté a besoin de continuer à apprendre. Et dans cette formation... j'ai appris beaucoup !" - A.G., Java Champion

Sessions intra-entreprises sur demande : contact[at]mokatech.net.
Inscrivez-vous vite !

Stream.distinct(), gare à la javadoc !

L'API Stream fournit une méthode distinct() permettant d'éliminer les éléments en doublon.
Sa Javadoc indique :

Returns a stream consisting of the distinct elements (according to Object.equals(Object)) of this stream.

Cette description laisse penser qu'il suffit que nos objets implémentent equals() pour être correctement traités par distinct(), ce qui est faux (ou du moins incomplet).

En effet, la classe DistinctOps, qui gère l'opération de dédoublonnement du stream, s'appuie sur un LinkedHashSet ou une ConcurrentHashMap[1] (dans le cas d'un stream parallèle), lesquels utilisent également, comme leur nom l'indique, la méthode hashCode() de nos objets.

Pour le prouver, il suffit de dédoublonner un jeu de données avec Stream.distinct(), et de comparer le résultat avec un dédoublonnement "à la main" avec List.contains()[2] qui se base uniquement sur equals().

public class Person {
    private final String name;
 
    public Person(String name) {
        this.name = name;
    }
 
    @Override
    public String toString() {
        return name;
    }
 
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        return name.equals(((Person) o).name);
    }
 
    // Pas de redéfinition de hashCode 
    // => un hashCode différent par instance en mémoire
 
}
public class StreamDistinct {
 
    public static void main(String[] args) {
 
        Person[] daltons = {
                new Person("Joe Dalton"),
                new Person("Joe Dalton"),  // Doublon !
                new Person("Joe Dalton"),  // Doublon !
                new Person("Jack Dalton"),
                new Person("William Dalton"),
                new Person("Averell Dalton"),
        };
 
        // Dédoublonnage via Stream.distinct
        List<Person> d1 = 
            Stream.of(daltons).distinct().collect(Collectors.toList());
        System.out.println("Stream.distinct : "+d1.size() + " Daltons");
 
        // Dédoublonnage manuel via List.contains
        List<Person> d2 = new ArrayList<>();
        for (Person dalton : daltons) {
            if (!d2.contains(dalton)) {
                d2.add(dalton);
            }
        }
        System.out.println("List.contains : "+d2.size() + " Daltons");
 
    }
 
}

Le résultat confirme la différence de comportement :

Stream.distinct : 6 Daltons
List.contains : 4 Daltons

En revanche, dès que la méthode hashCode() est correctement redéfinie dans la classe Person, les deux méthodes donnent le même résultat (4 Daltons).

Conclusion : quitte à écrire une documentation, autant qu'elle soit techniquement exacte et exhaustive ! Un développeux naïf (mais pas vous, naturellement) pourrait croire en lisant la documentation de Stream.distinct() que seule l'implémentation d'equals est nécessaire, et s'exposerait à des bugs subtils. Dans cet exemple précis, il est possible de lire le code source pour comprendre le fonctionnement exact de la classe, mais ce n'est pas toujours le cas...

Notes

[1] On notera ici une astuce d'implémentation : ConcurrentHashMap refusant les clé nulles, un booléen supplémentaire est utilisé pour indiquer la présence d'un élément null.

[2] Attention, expérience réalisée par un professionnel dans des conditions maîtrisées. Ne faites pas ça à la maison !


Commentaires

1. Le mercredi 3 septembre 2014, 13:26 par Tibi

Sauf que tout le monde sait que redéfinir equals sans redéfinir hashCode est puni de mort dans la plupart des états.
Si tu fais ça, tu t’exposes à plus que des « bugs subtils ».

2. Le lundi 8 septembre 2014, 14:12 par Loïc

Comme le dit Tibi, hashCode doit être redéfinit si equals est surchargé ! C'est même la javadox qui le dit: http://docs.oracle.com/javase/8/doc...

"Note that it is generally necessary to override the hashCode method whenever this method is overridden, so as to maintain the general contract for the hashCode method, which states that equal objects must have equal hash codes."

Ajouter un commentaire

Le code HTML est affiché comme du texte et les adresses web sont automatiquement transformées.