Programmation Java

L'API Streams de Java : pipelines, collectors et pièges

Intermédiaire8 min de lectureMis à jour

Cette page a été traduite de l'anglais avec l'aide de l'IA, puis vérifiée par rapport à l'original. Lire la version anglaise

Un stream est un pipeline d'opérations appliquées à une séquence d'éléments, évalué paresseusement et déclenché par une opération terminale. Ce guide explique ce modèle, passe en revue les opérations intermédiaires et terminales que vous utiliserez au quotidien, montre comment les collectors transforment un stream en données exploitables et recense les erreurs — réutiliser un stream, introduire des effets de bord, paralléliser à la légère — qui provoquent de vrais bugs.

Ce que vous allez apprendre

  • Expliquer le modèle des streams : une source, des opérations intermédiaires paresseuses, une opération terminale
  • Choisir la bonne opération pour filtrer, transformer, aplatir, trier et réduire
  • Utiliser Collectors pour regrouper, partitionner, concaténer et agréger des données
  • Déterminer quand un stream est plus lisible qu'une boucle, et quand il ne l'est pas
  • Reconnaître les pièges courants, notamment les cas où les streams parallèles ralentissent le code

Avant Java 8, transformer une collection supposait une boucle, une liste temporaire et une bonne dose de cérémonial qui masquait ce que le code faisait réellement. Les streams ont remplacé tout cela par un pipeline : vous décrivez ce que vous voulez — filtrer ceci, transformer cela, additionner le résultat — et la bibliothèque se charge de l'itération. Bien utilisé, un stream se lit comme une spécification. Utilisé sans discernement, il devient plus lent et plus difficile à déboguer que la boucle qu'il remplace. Ce guide couvre les deux versants.

Le modèle des streams

Un stream n'est pas une structure de données. Il ne contient aucun élément en propre : c'est une vue sur une source — une collection, un tableau, une fonction génératrice, les lignes d'un fichier — à travers laquelle les éléments circulent un par un. Un pipeline de stream comporte trois parties :

  1. Une source qui produit les éléments : list.stream(), Arrays.stream(array), Stream.of(a, b, c), IntStream.range(0, 10), Files.lines(path).
  2. Zéro, une ou plusieurs opérations intermédiaires qui renvoient un nouveau stream : filter, map, sorted, distinct, limit. Elles sont paresseuses : rien ne s'exécute au moment où vous les appelez.
  3. Une opération terminale qui produit un résultat ou un effet de bord et déclenche tout le pipeline : collect, forEach, count, findFirst, reduce, anyMatch.
List<String> names = List.of("Ada", "Grace", "Linus", "Barbara", "Ken");

List<String> result = names.stream()          // source
    .filter(n -> n.length() > 3)              // intermediate, lazy
    .map(String::toUpperCase)                 // intermediate, lazy
    .sorted()                                 // intermediate, lazy
    .toList();                                // terminal — runs everything

// [BARBARA, GRACE, LINUS]

L'évaluation paresseuse est importante pour deux raisons. D'abord, le pipeline peut court-circuiter le reste du traitement : un findFirst() placé après un filter s'arrête dès qu'un élément passe le filtre, sans examiner les autres. Ensuite, les opérations sont fusionnées : chaque élément traverse filter, puis map, puis la suite, au lieu que toute la liste soit d'abord filtrée dans une liste temporaire puis transformée. Ajoutez un peek pour observer l'ordre :

Stream.of("a", "bb", "ccc")
    .peek(s -> System.out.println("filter sees " + s))
    .filter(s -> s.length() > 1)
    .peek(s -> System.out.println("map sees " + s))
    .map(String::length)
    .findFirst();

// filter sees a
// filter sees bb
// map sees bb
// (ccc is never examined)

Un stream ne se consomme qu'une fois. Une fois l'opération terminale exécutée, le stream est fermé ; toute nouvelle opération lève IllegalStateException: stream has already been operated upon or closed. Si vous devez parcourir les données deux fois, créez deux streams à partir de la source.

Les opérations intermédiaires que vous utiliserez vraiment

Au quotidien, l'essentiel du code à base de streams repose sur un vocabulaire réduit.

OpérationCe qu'elle faitUsage typique
filter(Predicate)Conserve les éléments qui satisfont le prédicatSélectionner un sous-ensemble
map(Function)Remplace chaque élément par un nouvel élémentExtraire un champ, convertir un type
flatMap(Function)Remplace chaque élément par un stream et aplatit le toutCollections imbriquées
mapMulti(BiConsumer)Comme flatMap, sans créer de stream interne (Java 16)Déplier des éléments à moindre coût
distinct()Supprime les doublons au sens de equalsDédoublonnage
sorted() / sorted(Comparator)Trie ; impose de mettre tous les éléments en mémoire tamponOrdonner avant la sortie
limit(n) / skip(n)Tronque ou ignore un préfixePagination, « top N »
takeWhile / dropWhilePréfixe défini par un prédicat (Java 9)Données ordonnées
peek(Consumer)Exécute une action sans modifier l'élémentDébogage uniquement

flatMap est l'opération que l'on a le plus de mal à se représenter. Supposons que chaque Order possède une liste de LineItem et que vous vouliez toutes les lignes de toutes les commandes :

List<LineItem> allItems = orders.stream()
    .flatMap(order -> order.items().stream())
    .toList();

map vous donnerait un Stream<List<LineItem>>, c'est-à-dire un stream de listes. flatMap vous donne un Stream<LineItem>.

Les spécialisations primitives IntStream, LongStream et DoubleStream évitent le boxing et ajoutent des opérations numériques comme sum(), average() et summaryStatistics(). On passe de l'une à l'autre avec mapToInt, mapToObj et boxed() :

int totalCents = orders.stream()
    .flatMap(o -> o.items().stream())
    .mapToInt(LineItem::priceCents)
    .sum();

Opérations terminales et réduction

Une opération terminale clôt le pipeline. Les plus courantes se rangent en quatre familles :

  • Collecter dans une structure : collect(Collector), toList() (Java 16), toArray().
  • Réduire à une seule valeur : count(), sum(), min(Comparator), max(Comparator), reduce(identity, accumulator).
  • Rechercher : findFirst(), findAny(), anyMatch(p), allMatch(p), noneMatch(p). Toutes fonctionnent en court-circuit.
  • Effets de bord : forEach(action), forEachOrdered(action).

reduce mérite qu'on s'y attarde, car c'est la forme générale des autres. Elle combine les éléments deux à deux au moyen d'une fonction associative, en partant d'une valeur identité :

int total = IntStream.of(3, 5, 7).reduce(0, Integer::sum);      // 15
Optional<String> longest = words.stream()
    .reduce((a, b) -> a.length() >= b.length() ? a : b);        // no identity → Optional

L'accumulateur doit être associatif et l'identité doit en être vraiment une (0 pour la somme, 1 pour le produit, "" pour la concaténation). La bibliothèque s'appuie sur ces propriétés pour répartir le travail dans les streams parallèles : si vous vous trompez, une exécution parallèle donnera un autre résultat qu'une exécution séquentielle.

Collectors : retransformer un stream en données

C'est avec Collectors que les streams montrent tout leur intérêt. groupingBy et partitioningBy remplacent des dizaines de lignes de construction manuelle de maps.

record Employee(String name, String department, int salary) {}

// Employees per department
Map<String, List<Employee>> byDept = employees.stream()
    .collect(Collectors.groupingBy(Employee::department));

// Average salary per department
Map<String, Double> avgSalary = employees.stream()
    .collect(Collectors.groupingBy(Employee::department,
                                   Collectors.averagingInt(Employee::salary)));

// Names per department, as a single comma-separated string
Map<String, String> namesByDept = employees.stream()
    .collect(Collectors.groupingBy(Employee::department,
                                   TreeMap::new,
                                   Collectors.mapping(Employee::name, Collectors.joining(", "))));

// Split into high and low earners
Map<Boolean, List<Employee>> highEarners = employees.stream()
    .collect(Collectors.partitioningBy(e -> e.salary() > 100_000));

Le schéma est toujours le même : une fonction de classification choisit la clé, et un collector aval (downstream collector), facultatif, décide du sort des éléments qui partagent une même clé. counting(), summingInt, averagingDouble, mapping, filtering, maxBy, toSet et joining sont les collectors aval que vous utiliserez le plus. teeing (Java 12) exécute deux collectors et fusionne leurs résultats, ce qui est pratique pour calculer, par exemple, un minimum et un maximum en une seule passe.

Deux détails de toMap sont à l'origine de bugs en production. D'une part, toMap(keyFn, valueFn) lève une IllegalStateException en cas de clé en double ; passez une fonction de fusion en troisième argument si des doublons sont possibles. D'autre part, il refuse les valeurs null : remplacez-les par une valeur sentinelle ou filtrez-les au préalable.

Streams ou boucles ?

Le stream est le meilleur choix quand le code est une transformation : prendre des données, les filtrer, les remodeler, les agréger. Le pipeline exprime l'intention et chaque étape se lit indépendamment. La boucle est préférable quand :

  • Vous devez sortir de façon anticipée sur une condition complexe qui ne rentre ni dans takeWhile ni dans anyMatch.
  • Le corps a plusieurs effets de bord ou modifie plusieurs variables : les streams vous poussent alors vers un forEach qui capture un état mutable, ce qui cumule les inconvénients des deux approches.
  • Vous gérez des exceptions vérifiées (checked exceptions). Les interfaces fonctionnelles standard ne peuvent pas en lever, si bien que le code du stream finit par envelopper chaque appel dans un try/catch à l'intérieur de la lambda, ce qui est bien plus verbeux qu'une boucle.
  • Les performances sont critiques et la collection est petite : le surcoût par élément d'un stream se mesure dans les boucles serrées.

Une règle utile : si vous vous surprenez à écrire un stream dont le forEach ajoute des éléments à une liste déclarée en dehors du stream, réécrivez-le avec collect — ou avec une boucle.

Les streams parallèles

Appeler parallelStream() ou .parallel() découpe la source en morceaux traités sur le ForkJoinPool commun. Une ligne suffit pour l'activer, et une ligne suffit pour le regretter. Les streams parallèles sont utiles quand toutes ces conditions sont réunies :

  • La source se découpe à faible coût et de manière équilibrée (ArrayList, tableaux, IntStream.range — pas une LinkedList ni un itérateur).
  • Le travail par élément est lié au CPU et conséquent, et non des I/O ou une opération arithmétique triviale.
  • Les éléments sont suffisamment nombreux : des dizaines de milliers, pas quelques dizaines.
  • Les opérations sont sans état ; sorted, distinct et limit sur un stream ordonné imposent une fusion coûteuse.

Sinon, le coût de coordination l'emporte et la version parallèle est plus lente. Second piège : le pool commun est partagé par toute la JVM. Un stream parallèle qui fait des I/O bloquantes affame tous les autres streams parallèles et tous les CompletableFuture du processus. Tenez le travail bloquant complètement à l'écart des streams parallèles.

// Reasonable candidate: large array, pure CPU work
long primes = IntStream.rangeClosed(2, 5_000_000).parallel()
    .filter(Primes::isPrime)
    .count();

// Bad candidate: tiny list, blocking call
customers.parallelStream().forEach(c -> emailService.send(c)); // don't

Les erreurs courantes

Des effets de bord dans les lambdas. Modifier une collection ou un compteur partagé depuis map ou filter crée une data race avec parallel() et embrouille le lecteur même en séquentiel. Utilisez des collectors.

Des lambdas avec état. Une lambda qui dépend de ce qu'elle a déjà vu (un total cumulé stocké dans une case de tableau, par exemple) casse l'hypothèse d'indépendance des éléments. reduce ou une boucle est le bon outil.

Oublier l'opération terminale. Un pipeline d'opérations intermédiaires sans appel terminal ne fait strictement rien. Le code compile et saute le travail sans rien signaler.

Réutiliser un stream. Stocker un stream dans une variable et lui appliquer deux opérations terminales lève une exception à l'exécution.

Trier alors que seul le premier élément vous intéresse. sorted().findFirst() trie tout ; min(comparator) se contente d'une seule passe.

Ne pas fermer les streams issus de Files.lines. Un stream adossé à un fichier garde un descripteur ouvert. Enveloppez-le dans un try-with-resources.

Traiter l'Optional renvoyé par findFirst avec get(). Utilisez orElse, orElseThrow ou ifPresent ; un simple get() sur un résultat vide lève NoSuchElementException.

Où va l'API

Java 22 a finalisé les stream gatherers (JEP 461) : Stream.gather(Gatherer) est aux opérations intermédiaires ce que collect est aux opérations terminales, à savoir un moyen d'écrire vos propres étapes de fenêtrage, de fold ou de scan qui s'insèrent dans le pipeline. Les gatherers intégrés Gatherers.windowFixed, windowSliding, fold et scan couvrent des cas qui obligeaient auparavant à revenir à une boucle. Si vous êtes sur une version récente, apprenez-les avant de vous lancer dans un itérateur écrit à la main.

Points clés à retenir

  • Un stream est un pipeline paresseux : source → opérations intermédiaires → une opération terminale. Rien ne s'exécute avant l'appel terminal.
  • filter, map, flatMap, sorted, distinct et limit couvrent la plupart des transformations ; les streams primitifs évitent le boxing pour les calculs numériques.
  • Les collectors — en particulier groupingBy avec un collector aval — remplacent la construction manuelle de maps. N'oubliez pas les règles de toMap sur les clés en double et les valeurs null.
  • Utilisez les streams pour les transformations, et les boucles pour les sorties anticipées, les exceptions vérifiées et la modification de plusieurs variables.
  • Les streams parallèles sont un outil de performance réservé aux traitements volumineux, découpables et liés au CPU ; n'y mettez jamais d'I/O bloquantes.
  • Un stream ne sert qu'une fois, les lambdas doivent être exemptes d'effets de bord, et l'accumulateur passé à reduce doit être associatif.

Sources et lectures complémentaires

Questions fréquentes

Les streams sont-ils plus rapides que les boucles for ?
En général non, et ils sont souvent un peu plus lents sur de petites collections, à cause des objets du pipeline et des appels de lambdas. Les streams visent la clarté et la composabilité, pas la vitesse brute. Les streams parallèles peuvent accélérer un traitement volumineux, gourmand en CPU et facile à découper, mais ils peuvent aussi le ralentir : mesurez avant de vous engager.
Peut-on réutiliser un stream après une opération terminale ?
Non. Un stream ne se parcourt qu'une seule fois. Appeler une opération sur un stream déjà consommé lève une IllegalStateException. Créez un nouveau stream à partir de la source à chaque fois, ou collectez les résultats dans une collection si vous devez les parcourir deux fois.
Quelle est la différence entre map et flatMap ?
map transforme chaque élément en exactement un nouvel élément. flatMap transforme chaque élément en un stream de zéro, un ou plusieurs éléments, puis concatène ces streams : c'est l'outil pour aplatir des collections imbriquées, par exemple passer d'un stream de commandes à un stream de toutes leurs lignes de commande.
Faut-il utiliser Collectors.toList() ou Stream.toList() ?
Stream.toList(), ajoutée en Java 16, renvoie une liste non modifiable et c'est l'option la plus concise quand vous n'avez pas besoin de modifier le résultat. La spécification de Collectors.toList() ne garantit rien sur la mutabilité, même si l'implémentation actuelle renvoie une ArrayList. Préférez Stream.toList(), sauf si vous avez besoin d'une liste modifiable : dans ce cas, utilisez Collectors.toCollection(ArrayList::new) pour l'exprimer explicitement.