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 :
- 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 plageIntStream(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é. - 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 — appelerfilterne teste encore rien ; cela enregistre simplement le prédicat. - 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 pourforEach) et consomme le stream — vous ne pouvez pas le réutiliser.
list.stream() // SOURCE
.filter(...) // intermediate
.map(...) // intermediate
.sorted() // intermediate
.toList(); // TERMINAL — runs the pipelineSans 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 = 144Stream.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 uponSi 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
| Aspect | Collection | Stream |
|---|---|---|
| Stocke des données ? | Oui | Non |
| Réutilisable ? | Oui | Non (une seule terminale) |
| Eager ou lazy ? | Eager | Lazy 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ût | Bookkeeping par élément | Un 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
foren général. Une boucle qui construit quelque chose avec un flux de contrôle non trivial, qui nécessite unbreakavec 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/Iterablequand d'autres codes les attendent. Un stream produit des valeurs ; si vous avez besoin d'entrecouper la consommation (unforamélioré, uneListretournée depuis une méthode), utilisez d'abordtoList().
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.
Ce qu'il faut retenir de l'exécution :
- Le pipeline canonique en quatre étapes —
stream→filter→map→toList— a produit une liste triée de noms d'adultes sans boucle explicite, sans collection temporaire, et sans gestion de la nullabilité. peeka imprimé une fois par élément récupéré.findFirsta récupéré des éléments jusqu'à ce qu'un satisfassen*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é uneIllegalStateException. 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 sources — Collection.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.