W3docs

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 une List — généralement mutable, mais sans garantie. Pour la forme non modifiable que l'on utilise le plus souvent, utilisez stream.toList() (le terminal, pas le collector).
  • Collectors.toSet() est non ordonné — typiquement un HashSet. Si vous avez besoin d'un ordre d'itération stable, demandez-le explicitement avec toCollection(LinkedHashSet::new).
  • Collectors.toUnmodifiableList() et toUnmodifiableSet() (Java 10+) renvoient des résultats immuables — ce sont les équivalents collector de stream.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 + newAge

Pour 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 :

ProduitEn cas de clé dupliquée
toMapMap<K, V> (un V par K)Lève une exception sauf si vous fournissez un merger
groupingByMap<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 en Double.
  • minBy(cmp) / maxBy(cmp) — extrême Optional<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 soitUtilisez
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écifiqueCollectors.toCollection(supplier)
Map<K, V> un-à-unCollectors.toMap(k, v) (+ merger si nécessaire)
Map<K, List<T>> groupesCollectors.groupingBy(k)
Map<Boolean, List<T>>Collectors.partitioningBy(pred)
Chaîne uniqueCollectors.joining(delim, pre, suf)
Count/sum/avg par groupegroupingBy(k, counting() / summingInt(...) / ...)
Projection par groupegroupingBy(k, mapping(proj, toList()))
Extrême par groupegroupingBy(k, minBy(cmp) / maxBy(cmp))
Réduction personnalisée par groupegroupingBy(k, reducing(...))
Deux résultats en une seule passeCollectors.teeing(c1, c2, merger)
Rendre le résultat non modifiableenvelopper 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.

java— editable, runs on the server

Ce qu'il faut retenir de l'exécution :

  • Le toMap(Person::name, Person::age) non protégé à la fin a levé une IllegalStateException car deux Persons partagent le nom "Alice". Le correctif standard est un troisième argument : un BinaryOperator<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'appel ageByName plus tôt a fait avec (a, b) -> a.
  • groupingBy(Person::role) a produit un Map<String, List<Person>> gratuitement. Remplacer le downstream par défaut toList() par counting(), summingInt(...), averagingDouble(...), ou maxBy(...) 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 des Persons entiers. » Projeter en amont dans le downstream est presque toujours plus propre que de collecter des enregistrements entiers puis de mapper les valeurs.
  • partitioningBy a retourné les deux clés true et false même si l'une des moitiés aurait pu être vide. Cette prévisibilité est sa raison d'être par rapport à groupingBy(predicate).
  • teeing a collecté min et max en une seule passe, puis a transmis les deux Optionals à un merger qui a construit l'enregistrement Range. Dès que vous auriez autrement streamé deux fois pour deux résumés, utilisez teeing.
  • collectingAndThen(toList(), Collections::unmodifiableList) est l'astuce classique avec le finisher ; la même forme déroule un groupingBy(..., maxBy(...)) de Map<K, Optional<V>> en Map<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.

Pratique

Pratique
`people.stream().collect(Collectors.toMap(Person::name, Person::age))` lève une `IllegalStateException` quand deux `Person`s partagent un nom. Quel correctif correspond à l'intention de 'sommer leurs âges en cas de collision de noms' ?
`people.stream().collect(Collectors.toMap(Person::name, Person::age))` lève une `IllegalStateException` quand deux `Person`s partagent un nom. Quel correctif correspond à l'intention de 'sommer leurs âges en cas de collision de noms' ?
Was this page helpful?