Les Collectors de Streams Java
Réduisez les streams Java en collections et autres résultats avec java.util.stream.Collectors.
collect est le terminal que nous avions différé. Il accepte un Collector<T, A, R> — une recette pour accumuler les éléments d'un stream dans un résultat R via un conteneur intermédiaire A — et l'exécute. Les recettes se trouvent dans la classe factory java.util.stream.Collectors, et elles couvrent la plupart de ce que vous écririez autrement à la main avec une boucle for, une Map, et quelques appels compute*. Une fois que vous savez lire groupingBy(..., counting()), l'API cesse de sembler cryptique.
Ce chapitre parcourt la boîte à outils selon le résultat souhaité : une liste, un ensemble, une map, un nombre unique, une chaîne de caractères, ou — via le motif downstream — une combinaison imbriquée de tout cela.
Listes, ensembles et collections spécifiques
Les deux formes de base :
List<String> list = words.stream().collect(Collectors.toList());
Set<String> set = words.stream().collect(Collectors.toSet());Remarques :
Collectors.toList()retourne uneList— généralement mutable, mais sans garantie. Pour la forme non modifiable que l'on utilise le plus souvent, utilisezstream.toList()(le terminal, pas le collector).Collectors.toSet()est non ordonné — typiquement unHashSet. Si vous avez besoin d'un ordre d'itération stable, demandez-le explicitement avectoCollection(LinkedHashSet::new).Collectors.toUnmodifiableList()ettoUnmodifiableSet()(Java 10+) renvoient des résultats immuables — ce sont les équivalents collector destream.toList().
Pour une implémentation spécifique, utilisez toCollection :
ArrayDeque<String> queue = words.stream()
.collect(Collectors.toCollection(ArrayDeque::new));
TreeSet<String> sorted = words.stream()
.collect(Collectors.toCollection(TreeSet::new));Le fournisseur est une référence de constructeur ; le collector le câble, vide le stream dedans, et le retourne.
toMap — associer chaque élément à une clé
toMap(keyMapper, valueMapper) transforme chaque élément en un Map.Entry et les accumule :
Map<String, Integer> nameAge = people.stream()
.collect(Collectors.toMap(Person::name, Person::age));Les clés dupliquées lèvent une IllegalStateException. C'est la règle qui piège tout le monde la première fois. Si deux Persons partagent le même nom, le toMap par défaut échoue. Le correctif est la surcharge avec une fonction de fusion :
Map<String, Integer> sumAgePerName = people.stream()
.collect(Collectors.toMap(
Person::name,
Person::age,
Integer::sum)); // merge: existingAge + newAgePour un type de map spécifique — LinkedHashMap pour préserver l'ordre d'insertion, TreeMap pour garder les clés triées — passez un fournisseur :
Map<String, Integer> ordered = people.stream()
.collect(Collectors.toMap(
Person::name, Person::age,
(a, b) -> a, // keep first on collision
LinkedHashMap::new));toUnmodifiableMap est la variante immuable (Java 10+).
groupingBy — répartir en groupes par clé
Le collector vers lequel tout le monde se tourne une fois qu'il réalise que toMap n'est pas le bon outil :
Map<String, List<Person>> byRole = people.stream()
.collect(Collectors.groupingBy(Person::role));Pour chaque élément, le classificateur produit une clé, et l'élément est ajouté au groupe de cette clé (downstream par défaut : toList()). Comparaison avec toMap :
| Produit | En cas de clé dupliquée | |
|---|---|---|
toMap | Map<K, V> (un V par K) | Lève une exception sauf si vous fournissez un merger |
groupingBy | Map<K, List<V>> (un groupe par K) | Ajoute au groupe |
Utilisez toMap quand il y a au plus une valeur par clé par conception (id → ligne, code → libellé). Utilisez groupingBy quand il y en a plusieurs.
Toute la puissance de groupingBy vient de son paramètre downstream, qui indique quoi faire avec les éléments partageant une clé. Le défaut est toList ; vous pouvez le remplacer par un autre collector — et ce collector peut lui-même être un groupingBy. La section « downstream » plus bas dans ce chapitre est là où l'API s'ouvre vraiment.
partitioningBy — répartir par prédicat
Un groupingBy spécialisé pour les prédicats binaires. Retourne une Map<Boolean, List<T>> :
Map<Boolean, List<Person>> adultsOrNot = people.stream()
.collect(Collectors.partitioningBy(p -> p.age() >= 18));
List<Person> adults = adultsOrNot.get(true);
List<Person> minors = adultsOrNot.get(false);partitioningBy contient toujours les deux clés true et false, même si un groupe est vide. C'est le seul avantage qu'il offre par rapport à groupingBy(p -> p.age() >= 18) — qui omettrait la clé si le groupe est vide.
Comme groupingBy, partitioningBy accepte un collector downstream.
counting, summingInt, averagingDouble, minBy, maxBy
Les collectors downstream qui produisent un nombre unique par groupe :
Map<String, Long> headcount = people.stream()
.collect(Collectors.groupingBy(Person::role, Collectors.counting()));
Map<String, Integer> totalAgePerRole = people.stream()
.collect(Collectors.groupingBy(Person::role,
Collectors.summingInt(Person::age)));
Map<String, Double> avgAgePerRole = people.stream()
.collect(Collectors.groupingBy(Person::role,
Collectors.averagingDouble(Person::age)));
Map<String, Optional<Person>> oldestPerRole = people.stream()
.collect(Collectors.groupingBy(Person::role,
Collectors.maxBy(Comparator.comparingInt(Person::age))));counting()—Long, la taille du groupe.summingInt/Long/Double(toX)— somme du primitif projeté.averagingInt/Long/Double(toX)— moyenne enDouble.minBy(cmp)/maxBy(cmp)— extrêmeOptional<T>.summarizingInt/Long/Double(toX)—IntSummaryStatistics/ etc., le bundle complet count/sum/min/max/average.
joining — concaténer des chaînes
Pour les streams de CharSequence :
String csv = words.stream().collect(Collectors.joining(","));
String pretty = words.stream().collect(Collectors.joining(", ", "[", "]"));Trois surcharges : sans argument (simple concaténation), un argument délimiteur, trois arguments délimiteur + préfixe + suffixe. Plus rapide que reduce("", String::concat) car il utilise un StringBuilder en interne et n'alloue pas de façon quadratique. Le bon outil dès que le résultat du pipeline est une chaîne unique.
mapping — transformer puis collecter
Enveloppe un autre collector pour que les éléments soient transformés en premier. L'usage le plus courant est à l'intérieur de groupingBy quand vous voulez regrouper par un critère et collecter une projection des éléments plutôt que les éléments eux-mêmes :
Map<String, List<String>> namesByRole = people.stream()
.collect(Collectors.groupingBy(
Person::role,
Collectors.mapping(Person::name, Collectors.toList())));Sans mapping, le toList() downstream collecterait des Persons entiers ; avec mapping(Person::name, ...), il ne collecte que les noms. Utilisez-le dès que vous seriez autrement obligé d'écrire groupingBy(...).entrySet().stream().map(...).collect(...) en deux passes successives.
filtering (Java 9+) est l'enveloppe correspondante pour « supprimer certains éléments avant de collecter » :
Map<String, List<Person>> adultsByRole = people.stream()
.collect(Collectors.groupingBy(
Person::role,
Collectors.filtering(p -> p.age() >= 18, Collectors.toList())));La différence avec stream.filter(...) avant le collector : filtering conserve la clé dans la map résultante même si aucun élément ne passe — son groupe est simplement vide.
reducing — réduction générale complète comme collector
La forme collector de reduce, utilisée comme downstream quand les formes standard ne conviennent pas :
Map<String, Optional<Person>> oldestPerRole = people.stream()
.collect(Collectors.groupingBy(
Person::role,
Collectors.reducing(BinaryOperator.maxBy(Comparator.comparingInt(Person::age)))));Il existe trois surcharges (un argument, deux arguments avec identité, trois arguments avec identité + mapper + accumulateur) correspondant aux trois formes de reduce du chapitre précédent. La forme à deux arguments est la plus courante comme downstream car elle retourne un T simple plutôt qu'un Optional<T>.
On écrit rarement reducing au sommet d'un pipeline — reduce est le terminal pour cela. On l'écrit comme downstream de groupingBy/partitioningBy quand on veut une réduction par groupe.
collectingAndThen — post-traiter le résultat
Enveloppe un collector avec une fonction de finalisation. L'usage standard est de rendre une List/Map collectée non modifiable, ou d'extraire une valeur finale d'un résultat summarizing* :
List<String> immutableNames = people.stream()
.map(Person::name)
.collect(Collectors.collectingAndThen(
Collectors.toList(),
Collections::unmodifiableList));
Map<String, Long> immutableCounts = people.stream()
.collect(Collectors.collectingAndThen(
Collectors.groupingBy(Person::role, Collectors.counting()),
Collections::unmodifiableMap));C'est aussi la façon de transformer groupingBy(..., minBy(...)) en valeur simple plutôt qu'en Optional<T> — le finisher déroule l'Optional avec une valeur par défaut connue.
teeing — exécuter deux collectors en une seule passe
(Java 12+) Alimente chaque élément vers deux collectors simultanément et combine leurs résultats :
record Range(int min, int max) {}
Range range = nums.stream()
.collect(Collectors.teeing(
Collectors.minBy(Integer::compare),
Collectors.maxBy(Integer::compare),
(lo, hi) -> new Range(lo.orElseThrow(), hi.orElseThrow())));Les deux collectors enfants voient chaque élément ; le merger reçoit leurs deux résultats. Utile quand vous auriez autrement streamé deux fois — par exemple calculer la moyenne et repérer les valeurs aberrantes.
Choisir le bon collector
| Vous voulez que le résultat soit | Utilisez |
|---|---|
List<T> (immuable, cas courant) | stream.toList() (terminal) |
List<T> (mutable) | Collectors.toList() ou toCollection(ArrayList::new) |
Set<T> | Collectors.toSet() ou toCollection(LinkedHashSet::new) |
| Collection spécifique | Collectors.toCollection(supplier) |
Map<K, V> un-à-un | Collectors.toMap(k, v) (+ merger si nécessaire) |
Map<K, List<T>> groupes | Collectors.groupingBy(k) |
Map<Boolean, List<T>> | Collectors.partitioningBy(pred) |
| Chaîne unique | Collectors.joining(delim, pre, suf) |
| Count/sum/avg par groupe | groupingBy(k, counting() / summingInt(...) / ...) |
| Projection par groupe | groupingBy(k, mapping(proj, toList())) |
| Extrême par groupe | groupingBy(k, minBy(cmp) / maxBy(cmp)) |
| Réduction personnalisée par groupe | groupingBy(k, reducing(...)) |
| Deux résultats en une seule passe | Collectors.teeing(c1, c2, merger) |
| Rendre le résultat non modifiable | envelopper dans collectingAndThen(c, Collections::unmodifiableList) |
Un exemple concret : tous les collectors sur un seul jeu de données
Le programme ci-dessous construit une liste d'enregistrements Person et applique chaque forme de collector sur celle-ci.
Ce qu'il faut retenir de l'exécution :
- Le
toMap(Person::name, Person::age)non protégé à la fin a levé uneIllegalStateExceptioncar deuxPersons partagent le nom "Alice". Le correctif standard est un troisième argument : unBinaryOperator<V>qui indique comment fusionner les valeurs quand des clés entrent en collision. Choisissez le merger selon votre sémantique (garder le premier, garder le dernier, sommer, concaténer) — c'est ce que l'appelageByNameplus tôt a fait avec(a, b) -> a. groupingBy(Person::role)a produit unMap<String, List<Person>>gratuitement. Remplacer le downstream par défauttoList()parcounting(),summingInt(...),averagingDouble(...), oumaxBy(...)a transformé le résultat par groupe de « une liste » en un nombre unique — même forme de pipeline, recette différente dans l'emplacement downstream.mapping(Person::name, toList())est la réponse à « je veux regrouper par rôle, mais mes groupes ne doivent contenir que des noms, pas desPersons entiers. » Projeter en amont dans le downstream est presque toujours plus propre que de collecter des enregistrements entiers puis de mapper les valeurs.partitioningBya retourné les deux cléstrueetfalsemême si l'une des moitiés aurait pu être vide. Cette prévisibilité est sa raison d'être par rapport àgroupingBy(predicate).teeinga collectéminetmaxen une seule passe, puis a transmis les deuxOptionals à un merger qui a construit l'enregistrementRange. Dès que vous auriez autrement streamé deux fois pour deux résumés, utilisezteeing.collectingAndThen(toList(), Collections::unmodifiableList)est l'astuce classique avec le finisher ; la même forme déroule ungroupingBy(..., maxBy(...))deMap<K, Optional<V>>enMap<K, V>quand vous avez déjà prouvé que chaque groupe est non vide.
Prochaine étape
Tous les collectors et intermédiaires de la partie jusqu'ici s'exécutent séquentiellement par défaut — un élément à la fois, dans l'ordre de rencontre, sur le thread appelant. Le prochain chapitre, Les Streams Parallèles Java, introduit la planification alternative — parallelStream() et stream().parallel() — ce qu'il est sûr de placer dans un pipeline parallèle, ce qui ne l'est pas (mutation d'état partagé, forEach sensible à l'ordre, reduce non associatif), et comment déterminer si le parallélisme aide vraiment ou rend le programme plus lent.