W3docs

Introduction aux Streams Java

Introduction à l'API Stream Java pour traiter des séquences d'éléments avec des opérations de style fonctionnel.

Un stream est un pipeline qui transporte les éléments d'une source à travers une séquence d'opérations et produit un résultat. Ce n'est pas une structure de données — il ne stocke rien. C'est une recette déclarative pour traiter des données, évaluée paresseusement, exécutée une seule fois. Les Streams sont arrivés en Java 8 avec les lambdas, et les deux ont été conçus pour fonctionner ensemble : chaque opération de stream prend une fonction, et le langage vous offre un moyen propre d'en écrire une.

La forme que vous écrirez des centaines de fois :

double avgAdultAge = people.stream()
    .filter(p -> p.age() >= 18)
    .mapToInt(Person::age)
    .average()
    .orElse(0.0);

Trois choses à remarquer. Le pipeline se lit de haut en bas comme des étapes décrivant ce que vous voulez, pas comment itérer. Chaque étape prend une fonction — un Predicate, un ToIntFunction — exactement le vocabulaire mis en place dans les chapitres précédents. Et le résultat sort d'une seule opération terminale ; il n'y a pas de boucle, pas d'accumulateur, pas de continue prématuré.

La forme du pipeline : source → intermédiaire → terminal

Tout pipeline de stream comporte trois parties :

  1. Une source. D'où viennent les éléments. Généralement une collection (coll.stream()), parfois un littéral (Stream.of(\"a\", \"b\")), un tableau (Arrays.stream(arr)), une plage IntStream (IntStream.range(0, 100)), une source d'E/S (Files.lines(path)), ou un générateur (Stream.iterate, Stream.generate). Le prochain chapitre leur est entièrement consacré.
  2. Zéro ou plusieurs opérations intermédiaires. Chacune retourne un autre stream, elles se chaînent donc. Les plus courantes : filter, map, flatMap, distinct, sorted, limit, skip, peek. Elles sont paresseuses — appeler filter ne teste encore rien ; cela enregistre simplement le prédicat.
  3. Exactement une opération terminale. Déclenche le pipeline. Exemples : forEach, collect, toList, count, sum, min, max, reduce, findFirst, anyMatch. La terminale produit une valeur (ou un effet de bord pour forEach) et consomme le stream — vous ne pouvez pas le réutiliser.
list.stream()              // SOURCE
    .filter(...)           // intermediate
    .map(...)              // intermediate
    .sorted()              // intermediate
    .toList();             // TERMINAL — runs the pipeline

Sans la terminale, rien ne se passe. Un stream que vous construisez sans jamais terminer est du poids mort — aucun travail n'est effectué, aucun effet de bord ne se déclenche, les lambdas ne s'exécutent pas.

Paresseux par conception

Les opérations intermédiaires sont paresseuses parce que la JVM ne sait pas quels éléments vous avez réellement besoin jusqu'à ce que la terminale les demande. Cela permet deux optimisations importantes :

Fusion. Les intermédiaires adjacents s'exécutent ensemble en un seul passage, pas un passage par opération. stream.filter(p).map(f) ne construit pas une liste filtrée intermédiaire pour ensuite la mapper ; il teste un élément, et s'il survit, le mappe, le tout en une seule étape.

Court-circuit. Une terminale comme findFirst, anyMatch, ou limit(n) arrête le pipeline dès qu'elle a sa réponse. Combiné à la paresse, cela signifie que vous pouvez exécuter un pipeline "trouver le premier carré pair supérieur à 100" sur un stream infini et obtenir une réponse en microsecondes :

int answer = Stream.iterate(1, n -> n + 1)         // 1, 2, 3, 4, ...
    .map(n -> n * n)                                // 1, 4, 9, 16, ...
    .filter(n -> n % 2 == 0 && n > 100)             // first match wins
    .findFirst()
    .orElseThrow();
// answer = 144

Stream.iterate(1, n -> n + 1) est infini, mais findFirst n'a demandé des éléments que jusqu'à ce qu'un corresponde. Le pipeline a testé 12 carrés (1, 4, 9, ..., 144) puis s'est arrêté.

À usage unique, comme un Iterator

Un Stream ne peut être parcouru qu'une seule fois. La terminale le consomme, et après cela l'objet stream est fermé ; appeler une autre terminale dessus lève une IllegalStateException :

Stream<String> s = list.stream();
long c1 = s.count();             // ok
long c2 = s.count();             // throws IllegalStateException — stream has already been operated upon

Si vous devez traiter les mêmes données deux fois, construisez le stream deux fois :

long c1 = list.stream().count();
long c2 = list.stream().count();

Cela correspond au fonctionnement de Iterator. L'objet stream est le curseur en mouvement, pas les données. Les données sont la source — les re-streamer est gratuit.

Streams vs collections — des rôles différents

AspectCollectionStream
Stocke des données ?OuiNon
Réutilisable ?OuiNon (une seule terminale)
Eager ou lazy ?EagerLazy jusqu'à la terminale
Modifie la source ?Oui (ex. list.add)Non — les pipelines sont en lecture seule
Itère explicitement ?Souvent (for, iterator())Non — le pipeline pilote l'itération
Modèle de coûtBookkeeping par élémentUn seul passage sur la source

Une collection est un conteneur ; un stream est un calcul sur un conteneur (ou une autre source). Ils se complètent : vous récupérez depuis une collection, exécutez un pipeline de stream, et collectez vers une collection (généralement différente).

Trois petits exemples que vous écrirez tout le temps

Compter les éléments qui correspondent à un prédicat :

long adults = people.stream().filter(p -> p.age() >= 18).count();

Construire une liste de valeurs transformées :

List<String> names = people.stream().map(Person::name).toList();

Réduire à une valeur unique :

int totalAge = people.stream().mapToInt(Person::age).sum();

Ces trois patterns — count, map-to-list, reduce-to-scalar — couvrent la plupart des usages de l'API. Le reste de la partie est une visite des opérations qui remplissent le comment pour chacun.

Trois choses que les streams ne sont pas

  • Pas un remplacement des boucles for en général. Une boucle qui construit quelque chose avec un flux de contrôle non trivial, qui nécessite un break avec des effets de bord, ou qui mute plusieurs variables, reste plus claire en tant que boucle. Les Streams brillent quand le travail est un pipeline d'opérations pures.
  • Pas un gain de performance sur de petites données. Un pipeline de stream alloue quelques petits objets ; une boucle de 10 éléments le surpassera. Les gains viennent de la clarté sur toutes les données et du parallélisme sur les grandes données.
  • Pas un substitut à Iterator/Iterable quand d'autres codes les attendent. Un stream produit des valeurs ; si vous avez besoin d'entrecouper la consommation (un for amélioré, une List retournée depuis une méthode), utilisez d'abord toList().

Séquentiel par défaut, parallèle sur demande

Tout stream que vous écrirez dans ce chapitre est séquentiel — les éléments circulent dans le pipeline un par un, dans l'ordre. Il y a aussi coll.parallelStream() (et stream.parallel()) qui planifie le pipeline sur le ForkJoinPool commun pour un travail multi-cœur. Les Streams parallèles font l'objet d'un chapitre ultérieur — ils font plusieurs hypothèses sur le pipeline (il doit être associatif, sans état, sans effets de bord) que les pipelines "intro" de ce chapitre respectent naturellement, donc la mise à niveau est généralement un changement d'un seul token.

Un exemple concret : un pipeline complet, la paresse, et la règle d'usage unique

Le programme ci-dessous construit une petite liste de records Person, exécute la forme canonique du pipeline (filter → map → sorted → collect), prouve la paresse avec peek, démontre le court-circuit sur un Stream.iterate infini, et montre l'IllegalStateException que vous obtenez en réutilisant un stream.

java— editable, runs on the server

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

  • Le pipeline canonique en quatre étapes — streamfiltermaptoList — a produit une liste triée de noms d'adultes sans boucle explicite, sans collection temporaire, et sans gestion de la nullabilité.
  • peek a imprimé une fois par élément récupéré. findFirst a récupéré des éléments jusqu'à ce qu'un satisfasse n*n > 50 (ce qui arrive à n = 8, carré 64) puis s'est arrêté. C'est la paresse et le court-circuit fonctionnant ensemble : les opérations en amont ont fait exactement le travail nécessaire et rien de plus.
  • Le pipeline "premier carré pair supérieur à 100" a tourné sur une source infinie. Sans court-circuit ce serait une boucle infinie ; avec lui, le pipeline a testé 12 valeurs et produit 144.
  • Le second s.count() a levé une IllegalStateException. Les Streams sont à usage unique ; si vous avez besoin d'un second passage, construisez un nouveau stream depuis la source.
  • Le pipeline "sans terminale" à la fin n'a rien imprimé depuis son peek. Sans terminale, les intermédiaires ne s'exécutent pas — le stream est juste une recette que personne n'a demandé d'exécuter.

Et ensuite

Vous connaissez la forme du pipeline, la séparation source/intermédiaire/terminal, le contrat de paresse, et la règle d'usage unique. Le prochain chapitre, Création de Streams Java, est le catalogue des sourcesCollection.stream(), Stream.of, Arrays.stream, IntStream.range, Stream.iterate, Stream.generate, Files.lines, String.chars(), Stream.empty, et l'API Stream.Builder. Une fois le chapitre sur les sources terminé, vous aurez tout ce qu'il faut pour démarrer, et le reste de la partie couvrira les opérations intermédiaires et terminales.

Pratique

Pratique
Vous écrivez `list.stream().filter(p).map(f);` sans appeler d'opération terminale. Que se passe-t-il lors de l'exécution de cette ligne ?
Vous écrivez `list.stream().filter(p).map(f);` sans appeler d'opération terminale. Que se passe-t-il lors de l'exécution de cette ligne ?
Was this page helpful?