Un stream en Java est une séquence de données. Un pipeline de stream consiste en des opérations qui s’exécutent sur un stream pour produire un résultat. D’abord, nous examinons le flux des pipelines conceptuellement. Ensuite, nous abordons le code.
Comprendre le Flux du Pipeline
Imaginez un pipeline de stream comme une chaîne de montage dans une usine. Supposons que nous gérons une chaîne de montage pour fabriquer des panneaux pour les expositions d’animaux au zoo. Nous avons plusieurs postes. C’est le travail d’une personne de sortir les panneaux d’une boîte. C’est le travail d’une deuxième personne de peindre le panneau. C’est le travail d’une troisième personne de graver le nom de l’animal sur le panneau. C’est le travail de la dernière personne de mettre le panneau terminé dans une boîte pour être transporté vers l’exposition appropriée.
Notez que la deuxième personne ne peut rien faire jusqu’à ce qu’un panneau ait été sorti de la boîte par la première personne. De même, la troisième personne ne peut rien faire jusqu’à ce qu’un panneau ait été peint, et la dernière personne ne peut rien faire jusqu’à ce qu’il soit gravé.
La chaîne de montage pour la fabrication des panneaux est finie. Une fois que nous avons traité le contenu de notre boîte de panneaux, nous avons terminé. Les streams finis ont une limite. D’autres chaînes de montage fonctionnent essentiellement indéfiniment, comme celle pour la production alimentaire. Bien sûr, elles s’arrêtent à un moment donné lorsque l’usine ferme, mais faisons comme si cela n’arrivait pas. Ou pensez à un cycle de lever/coucher de soleil comme infini, puisqu’il ne se termine pas pendant une période extraordinairement longue.
Une autre caractéristique importante d’une chaîne de montage est que chaque personne touche chaque élément pour effectuer son opération, puis cette donnée disparaît. Elle ne revient pas. La personne suivante s’en occupe à ce moment-là. C’est différent des listes et des files d’attente que vous avez vues dans le chapitre précédent. Avec une liste, vous pouvez accéder à n’importe quel élément à tout moment. Avec une file d’attente, vous êtes limité quant aux éléments auxquels vous pouvez accéder, mais tous les éléments sont là. Avec les streams, les données ne sont pas générées à l’avance — elles sont créées lorsque nécessaire. C’est un exemple d’évaluation paresseuse, qui retarde l’exécution jusqu’à ce qu’elle soit nécessaire.
Beaucoup de choses peuvent se produire dans les stations de la chaîne de montage en cours de route. En programmation fonctionnelle, on les appelle opérations de stream. Tout comme la chaîne de montage, les opérations se produisent dans un pipeline. Quelqu’un doit commencer et terminer le travail, et il peut y avoir n’importe quel nombre de stations entre les deux. Après tout, un travail avec une seule personne n’est pas une chaîne de montage! Il y a trois parties dans un pipeline de stream :
- Source : D’où provient le stream.
- Opérations intermédiaires : Transforme le stream en un autre. Il peut y avoir aussi peu ou autant d’opérations intermédiaires que vous le souhaitez. Comme les streams utilisent l’évaluation paresseuse, les opérations intermédiaires ne s’exécutent pas tant que l’opération terminale ne s’exécute pas.
- Opération terminale : Produit un résultat. Comme les streams ne peuvent être utilisés qu’une seule fois, le stream n’est plus valide après la fin d’une opération terminale.
Notez que les opérations sont inconnues pour nous. En regardant la chaîne de montage de l’extérieur, vous vous souciez uniquement de ce qui entre et sort. Ce qui se passe entre les deux est un détail d’implémentation.
Vous devrez bien connaître les différences entre les opérations intermédiaires et terminales. Assurez-vous de pouvoir remplir le tableau 10.2.
Scénario | Opération intermédiaire | Opération terminale |
---|---|---|
Partie requise d’un pipeline utile ? | Non | Oui |
Peut exister plusieurs fois dans le pipeline ? | Oui | Non |
Le type de retour est un type de stream ? | Oui | Non |
Exécuté lors de l’appel de méthode ? | Non | Oui |
Stream valide après l’appel ? | Oui | Non |
Une usine a généralement un contremaître qui supervise le travail. Java sert de contremaître lorsqu’il travaille avec des pipelines de stream. C’est un rôle vraiment important, surtout lorsqu’il s’agit d’évaluation paresseuse et de streams infinis. Pensez à déclarer un stream comme donner des instructions au contremaître. Au fur et à mesure que le contremaître découvre ce qui doit être fait, il installe les postes et indique aux travailleurs quels seront leurs devoirs. Cependant, les travailleurs ne commencent pas avant que le contremaître ne leur dise de commencer. Le contremaître attend de voir l’opération terminale pour lancer le travail. Il surveille également le travail et arrête la ligne dès que le travail est terminé.
Examinons quelques exemples de cela. Nous n’utilisons pas de code dans ces exemples car il est vraiment important de comprendre le concept de pipeline de stream avant de commencer à écrire le code.
Création de Sources de Stream
En Java, les streams dont nous avons parlé sont représentés par l’interface Stream<T>
, définie dans le package java.util.stream
.
Création de Streams Finis
Pour simplifier, nous commençons par des streams finis. Il existe plusieurs façons de les créer.
Stream<String> vide = Stream.empty(); // count = 0
Stream<Integer> elementUnique = Stream.of(1); // count = 1
Stream<Integer> depuisTableau = Stream.of(1, 2, 3); // count = 3
Java fournit également un moyen pratique de convertir une Collection en stream.
var liste = List.of("a", "b", "c");
Stream<String> depuisListe = liste.stream();
Création d’un Stream Parallèle
Il est tout aussi facile de créer un stream parallèle à partir d’une liste.
var liste = List.of("a", "b", "c");
Stream<String> depuisListeParallele = liste.parallelStream();
C’est une excellente fonctionnalité car vous pouvez écrire du code qui utilise la concurrence avant même d’apprendre ce qu’est un thread. L’utilisation de streams parallèles est comme la mise en place de plusieurs tables de travailleurs qui peuvent effectuer la même tâche. La peinture serait beaucoup plus rapide si nous pouvions avoir cinq peintres peignant des panneaux au lieu d’un seul. Gardez à l’esprit que certaines tâches ne peuvent pas être effectuées en parallèle, comme le rangement des panneaux dans l’ordre où ils ont été créés dans le stream. Soyez également conscient qu’il y a un coût à la coordination du travail, donc pour les petits streams, cela pourrait être plus rapide de le faire séquentiellement.
Création de Streams Infinis
Jusqu’à présent, ce n’est pas particulièrement impressionnant. Nous pourrions faire tout cela avec des listes. Nous ne pouvons pas créer une liste infinie, cependant, ce qui rend les streams plus puissants.
Stream<Double> aleatoires = Stream.generate(Math::random);
Stream<Integer> nombresImpairs = Stream.iterate(1, n -> n + 2);
La ligne 17 génère un stream de nombres aléatoires. Combien de nombres aléatoires ? Autant que vous en avez besoin. Si vous appelez aleatoires.forEach(System.out::println)
, le programme imprimera des nombres aléatoires jusqu’à ce que vous le tuiez. Plus tard dans le chapitre, vous apprendrez des opérations comme limit()
pour transformer le stream infini en stream fini.
La ligne 18 vous donne plus de contrôle. La méthode iterate()
prend une valeur de départ ou initiale comme premier paramètre. C’est le premier élément qui fera partie du stream. L’autre paramètre est une expression lambda qui est passée à la valeur précédente et génère la valeur suivante. Comme avec l’exemple des nombres aléatoires, il continuera à produire des nombres impairs aussi longtemps que vous en aurez besoin.
Affichage d’une Référence de Stream
Si vous essayez d’appeler System.out.print(stream)
, vous obtiendrez quelque chose comme ceci :
java.util.stream.ReferencePipeline$3@4517d9a3
C’est différent d’une Collection, où vous voyez le contenu.
Et si vous vouliez seulement des nombres impairs inférieurs à 100 ? Il existe une version surchargée de iterate()
qui aide :
Stream<Integer> nombreImpairesSous100 = Stream.iterate(
1, // valeur initiale
n -> n < 100, // Prédicat pour spécifier quand c'est fini
n -> n + 2); // UnaryOperator pour obtenir la valeur suivante
Cette méthode prend trois paramètres. Notez comment ils sont séparés par des virgules (,) comme dans toutes les autres méthodes. Comme une boucle for, vous devez veiller à ne pas créer accidentellement un stream infini.
Révision des Méthodes de Création de Stream
Pour réviser, assurez-vous de connaître toutes les méthodes du tableau 10.3. Ce sont les façons de créer une source pour les streams, étant donné une instance de Collection coll
.
Méthode | Fini ou infini ? | Notes |
---|---|---|
Stream.empty() | Fini | Crée Stream avec zéro élément. |
Stream.of(varargs) | Fini | Crée Stream avec les éléments listés. |
coll.stream() | Fini | Crée Stream à partir de Collection. |
coll.parallelStream() | Fini | Crée Stream à partir de Collection où le stream peut s’exécuter en parallèle. |
Stream.generate(supplier) | Infini | Crée Stream en appelant Supplier pour chaque élément sur demande. |
Stream.iterate(seed, unaryOperator) | Infini | Crée Stream en utilisant seed pour le premier élément puis en appelant UnaryOperator pour chaque élément suivant sur demande. |
Stream.iterate(seed, predicate, unaryOperator) | Fini ou infini | Crée Stream en utilisant seed pour le premier élément puis en appelant UnaryOperator pour chaque élément suivant sur demande. S’arrête si Predicate retourne false. |
Utilisation des Opérations Terminales Communes
Vous pouvez effectuer une opération terminale sans aucune opération intermédiaire, mais pas l’inverse. C’est pourquoi nous parlons d’abord des opérations terminales. Les réductions sont un type spécial d’opération terminale où tout le contenu du stream est combiné en un seul primitif ou Objet. Par exemple, vous pourriez avoir un int ou une Collection.
Le tableau 10.4 résume cette section. N’hésitez pas à l’utiliser comme guide pour vous rappeler les points les plus importants au fur et à mesure que nous passons en revue chacun individuellement. Nous les expliquons du plus simple au plus complexe plutôt que par ordre alphabétique.
Méthode | Que se passe-t-il pour les streams infinis | Valeur de retour | Réduction |
---|---|---|---|
count() | Ne termine pas | long | Oui |
min() max() | Ne termine pas | Optional<T> | Oui |
findAny() findFirst() | Termine | Optional<T> | Non |
allMatch() anyMatch() noneMatch() | Termine parfois | boolean | Non |
forEach() | Ne termine pas | void | Non |
reduce() | Ne termine pas | Varie | Oui |
collect() | Ne termine pas | Varie | Oui |
Comptage
La méthode count()
détermine le nombre d’éléments dans un stream fini. Pour un stream infini, elle ne se termine jamais. Pourquoi ? Comptez de 1 à l’infini, et faites-nous savoir quand vous avez terminé. Ou plutôt, ne faites pas ça, car nous préférerions que vous étudiiez plutôt que de passer le reste de votre vie à compter. La méthode count()
est une réduction car elle examine chaque élément dans le stream et renvoie une seule valeur. La signature de la méthode est la suivante :
public long count()
Cet exemple montre l’appel de count()
sur un stream fini :
Stream<String> s = Stream.of("singe", "gorille", "bonobo");
System.out.println(s.count()); // 3
Trouver le Minimum et le Maximum
Les méthodes min()
et max()
vous permettent de passer un comparateur personnalisé et de trouver la valeur la plus petite ou la plus grande dans un stream fini selon cet ordre de tri. Comme la méthode count, min()
et max()
se bloquent sur un stream infini parce qu’ils ne peuvent pas être sûrs qu’une valeur plus petite ou plus grande n’arrive pas plus tard dans le stream. Les deux méthodes sont des réductions car elles renvoient une seule valeur après avoir examiné l’ensemble du stream. Les signatures de méthode sont les suivantes :
public Optional<T> min(Comparator<? super T> comparator)
public Optional<T> max(Comparator<? super T> comparator)
Cet exemple trouve l’animal avec le moins de lettres dans son nom :
Stream<String> s = Stream.of("singe", "ape", "bonobo");
Optional<String> min = s.min((s1, s2) -> s1.length()-s2.length());
min.ifPresent(System.out::println); // ape
Notez que le code renvoie un Optional plutôt que la valeur. Cela permet à la méthode de spécifier qu’aucun minimum ou maximum n’a été trouvé. Nous utilisons la méthode Optional ifPresent()
et une référence de méthode pour imprimer le minimum seulement si on en trouve un. Voici un exemple où il n’y a pas de minimum :
Optional<?> minVide = Stream.empty().min((s1, s2) -> 0);
System.out.println(minVide.isPresent()); // false
Puisque le stream est vide, le comparateur n’est jamais appelé, et aucune valeur n’est présente dans l’Optional.
Trouver une Valeur
Les méthodes findAny()
et findFirst()
renvoient un élément du stream à moins que le stream soit vide. Si le stream est vide, elles renvoient un Optional vide. C’est la première méthode que vous avez vue qui peut se terminer avec un stream infini. Comme Java génère seulement la quantité de stream dont vous avez besoin, le stream infini a seulement besoin de générer un élément.
Comme son nom l’indique, la méthode findAny()
peut renvoyer n’importe quel élément du stream. Lorsqu’elle est appelée sur les streams que vous avez vus jusqu’à présent, elle renvoie couramment le premier élément, bien que ce comportement ne soit pas garanti.
Ces méthodes sont des opérations terminales mais pas des réductions. La raison en est qu’elles renvoient parfois sans traiter tous les éléments. Cela signifie qu’elles renvoient une valeur basée sur le stream mais ne réduisent pas tout le stream en une valeur.
Les signatures de méthode sont les suivantes :
public Optional<T> findAny()
public Optional<T> findFirst()
Cet exemple trouve un animal :
Stream<String> s = Stream.of("singe", "gorille", "bonobo");
Stream<String> infini = Stream.generate(() -> "chimpanzé");
s.findAny().ifPresent(System.out::println); // singe (généralement)
infini.findAny().ifPresent(System.out::println); // chimpanzé
Trouver une correspondance est plus utile qu’il n’y paraît. Parfois, nous voulons simplement échantillonner les résultats et obtenir un élément représentatif, mais nous n’avons pas besoin de gaspiller le traitement pour tous les générer. Après tout, si nous prévoyons de travailler avec un seul élément, pourquoi s’embêter à en examiner davantage ?
Correspondance
Les méthodes allMatch()
, anyMatch()
et noneMatch()
recherchent dans un stream et renvoient des informations sur la façon dont le stream se rapporte au prédicat. Elles peuvent ou non se terminer pour les streams infinis. Cela dépend des données. Comme les méthodes find, elles ne sont pas des réductions car elles ne regardent pas nécessairement tous les éléments.
Les signatures de méthode sont les suivantes :
public boolean anyMatch(Predicate<? super T> predicate)
public boolean allMatch(Predicate<? super T> predicate)
public boolean noneMatch(Predicate<? super T> predicate)
Cet exemple vérifie si les noms d’animaux commencent par des lettres :
var liste = List.of("singe", "2", "chimpanzé");
Stream<String> infini = Stream.generate(() -> "chimpanzé");
Predicate<String> pred = x -> Character.isLetter(x.charAt(0));
System.out.println(liste.stream().anyMatch(pred)); // true
System.out.println(liste.stream().allMatch(pred)); // false
System.out.println(liste.stream().noneMatch(pred)); // false
System.out.println(infini.anyMatch(pred)); // true
Cela montre que nous pouvons réutiliser le même prédicat, mais nous avons besoin d’un stream différent à chaque fois. La méthode anyMatch()
renvoie true car deux des trois éléments correspondent. La méthode allMatch()
renvoie false car l’un ne correspond pas. La méthode noneMatch()
renvoie également false car au moins un correspond. Sur le stream infini, une correspondance est trouvée, donc l’appel se termine. Si nous avions appelé allMatch()
, cela aurait fonctionné jusqu’à ce que nous tuions le programme.
N’oubliez pas que allMatch()
, anyMatch()
et noneMatch()
renvoient un boolean. En revanche, les méthodes find renvoient un Optional car elles renvoient un élément du stream.
Itération
Comme dans le Java Collections Framework, il est courant d’itérer sur les éléments d’un stream. Comme prévu, l’appel de forEach()
sur un stream infini ne se termine pas. Comme il n’y a pas de valeur de retour, ce n’est pas une réduction.
Avant de l’utiliser, considérez si une autre approche serait meilleure. Les développeurs qui apprennent d’abord à écrire des boucles ont tendance à les utiliser pour tout. Par exemple, une boucle avec une instruction if pourrait être écrite avec un filtre. Vous en apprendrez plus sur les filtres dans la section des opérations intermédiaires.
La signature de la méthode est la suivante :
public void forEach(Consumer<? super T> action)
Notez que c’est la seule opération terminale avec un type de retour void. Si vous voulez que quelque chose se produise, vous devez le faire se produire dans le Consumer. Voici une façon d’imprimer les éléments dans le stream (il y a d’autres façons, que nous abordons plus tard dans le chapitre) :
Stream<String> s = Stream.of("Singe", "Gorille", "Bonobo");
s.forEach(System.out::print); // SingeGorilleBonobo
N’oubliez pas que vous pouvez appeler forEach()
directement sur une Collection ou sur un Stream. Ne vous confondez pas lorsque vous voyez les deux approches.
Notez que vous ne pouvez pas utiliser une boucle for traditionnelle sur un stream.
Stream<Integer> s = Stream.of(1);
for (Integer i : s) {} // NE COMPILE PAS
Bien que forEach()
ressemble à une boucle, c’est vraiment un opérateur terminal pour les streams. Les streams ne peuvent pas être utilisés comme source dans une boucle for-each car ils n’implémentent pas l’interface Iterable.
Réduction
La méthode reduce()
combine un stream en un seul objet. C’est une réduction, ce qui signifie qu’elle traite tous les éléments. Les trois signatures de méthode sont les suivantes :
public T reduce(T identity, BinaryOperator<T> accumulator)
public Optional<T> reduce(BinaryOperator<T> accumulator)
public <U> U reduce(U identity, BiFunction<U,? super T,U> accumulator, BinaryOperator<U> combiner)
Prenons-les une par une. La façon la plus courante de faire une réduction est de commencer par une valeur initiale et de continuer à la fusionner avec la valeur suivante. Pensez à la façon dont vous concaténeriez un tableau d’objets String en une seule String sans programmation fonctionnelle. Cela pourrait ressembler à ceci :
var tableau = new String[] { "l", "o", "u", "p" };
var resultat = "";
for (var s: tableau) resultat = resultat + s;
System.out.println(resultat); // loup
L’identité est la valeur initiale de la réduction, dans ce cas une String vide. L’accumulateur combine le résultat actuel avec la valeur actuelle dans le stream. Avec des lambdas, nous pouvons faire la même chose avec un stream et une réduction :
Stream<String> stream = Stream.of("l", "o", "u", "p");
String mot = stream.reduce("", (s, c) -> s + c);
System.out.println(mot); // loup
Notez comment nous avons toujours la String vide comme identité. Nous concaténons toujours les objets String pour obtenir la valeur suivante. Nous pouvons même réécrire cela avec une référence de méthode :
Stream<String> stream = Stream.of("l", "o", "u", "p");
String mot = stream.reduce("", String::concat);
System.out.println(mot); // loup
Essayons-en une autre. Pouvez-vous écrire une réduction pour multiplier tous les objets Integer dans un stream ? Essayez-le. Notre solution est montrée ici :
Stream<Integer> stream = Stream.of(3, 5, 6);
System.out.println(stream.reduce(1, (a, b) -> a*b)); // 90
Nous définissons l’identité à 1 et l’accumulateur à la multiplication. Dans de nombreux cas, l’identité n’est pas vraiment nécessaire, donc Java nous permet de l’omettre. Lorsque vous ne spécifiez pas d’identité, un Optional est renvoyé car il pourrait ne pas y