Thématiques principales

mardi 28 août 2018

Les Streams avec Java 8

De la plomberie?

Je vous avais dit que nous reviendrions sur les Streams définis dans l'API Java 8. J'avais évoqué cette API en novembre dernier lorsque nous avions fait un tour d'horizon de Java 8 [1]. Je sais ça fait un peu loin et pas mal d'eau a coulé sous les ponts, cependant je pense que même si cet article arrive un peu tard (même si nous avons quand même vu pas mal d'autres choses depuis à la place) cela ne fera pas de mal de regarder un peu plus dans le détail cette API qui est incontournable.

Tout d'abord il faut dire que le Stream est un pattern de conception, nous ne l’avions pas traité dans le blog, il serait intéressant de le traité mais il s'agit essentiellement d'un paradigme de modélisation de la gestion de flux qu'une manière d’implémenter ces dits flux. Dans ce paradigme, tout comme dans l'API [2], bien sur nous y retrouverons des traits comme la capacité de construire des pipelines de traitement.



En fait du coup, un stream, c'est quoi? un tuyau! ou plusieurs mis bout a bout entre lesquels divers filtres, transformations et traitement sont appliqués. Les éléments ainsi introduit dans le stream va alors subir les différents traitements successivement (ce n'est pas aussi simple car le stream peut avoir a traiter plusieurs éléments en même temps, voir tous les éléments) et ce tant qu'il y a des éléments. Grace a cette propriété (le tant que) les streams sont capables de traiter des listes ou des ensembles d’éléments de taille non connu (pour ne pas dire infini qui n'aurait pas beaucoup de sens...) initialement.

Dans Java 8, les streams vont donc être un complément au pattern Iterator qui se cache derrière nos structures foreach. D'une part en permettant des traitements potentiellement infini mais aussi de façon a simplifier les boucles et les traitements. En effet le constat est que les manipulations sur les listes dans les boucles sont généralement des traitements produisant des résultats sans liens avec un besoin quelconque de modifier la liste elle-même (chose généralement à bannir sans risque d'effets de bords désagréables, d'ou l'utilisation de liste immutable).

Initialisation d'un stream

Ainsi, grâce aux streams, le traitement sur un ensemble d’éléments (liste, ensemble, infini ou non) peut être infini si le stream est construit comme tel, et cela ne pose aucun problème. Par exemple on peut créer des streams finis voir vide:

1
2
3
4
5
6
7
8
9
Stream emptyStream = Stream.empty();

Collection<String> collection = Arrays.asList("a", "b", "c");
Stream<String> streamOFromArray = collection.stream();

String[] arr = new String[]{"a", "b", "c"};
Stream<String> streamOFromArray2 = Arrays.stream(arr);

IntStream streamOfChars = "abc".chars();

ou des streams infinis (vous verrons comment les limiter), en compréhension

1
2
3
Stream<String> genStream = Stream.generate(() -> "1");
IntStream s=IntStream.range(1,10);
Stream<String> iterateStream = Stream.iterate("thomas",(x) -> x.toUpperCase());

Au passage on constate que les Streams s'appuient aussi beaucoup sur les nouvelles API fonctionnelles apportées aussi par Java 8 avec les lambdas expression (sujet traité dans [1]).

On peut aussi construire des streams statiquement avec des builders comme on le ferait avec un StringBuilder (pour ceux qui connaissent)

1
Stream<String> phrase=Stream.<String>builder().add("Ceci ").add("est ").add("une ").add("phrase.").build();

Ou a partir de chaîne de caractères pour en faciliter le parsing ou la dissection avec des RegEx ( StreamSupport)

Filtration

Maintenant que l'on a des streams sous la main, le tout est d'en faire quelque chose. Comme nous en avions parlé, différents traitements sont possibles et chacun d'eux nous permettra de construire alors un nouveau stream. Il importe donc de bien comprendre l'emploi de ces differentes fonctions car comme nous l'avons évoqué, ces traitements vont se cascader, impliquant que l'ordre d’exécution à une importance significative.

Par exemple :

Considérons un stream produisant des entiers dont on ne gardera que les éléments pair. On voudra ne conserver que les 20 premiers éléments du calcul. Pour répondre à ce problème, il nous faudra employer la fonction filter et la fonction limit.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
System.out.println("Stream1");
Stream<Integer> stream = Stream.iterate(0,(x)-> x+1 );
stream.limit(10).filter((x) -> (x % 2) == 0).forEach(System.out::println);

System.out.println("Stream2");
Stream<Integer> stream2 = Stream.iterate(0,(x)-> x+1 );
stream2.filter((x) -> (x % 2) == 0).limit(10).forEach(System.out::println);

System.out.println("Stream3");
IntStream stream3=new Random().ints(0,100);
stream3.limit(10).filter((x) -> (x % 2) == 0).sorted().forEach(System.out::println);

Avec cet exemple simple, on voit bien que l'ordre d’exécution des fonctions a une importance. Le résultat est affiché via la fonction foreach grâce a laquelle il est possible de passer en paramètre une fonction qui sera appliqué a tous les éléments produit par le stream.

Manipulation sur les streams

A la place et comme suit, il est possible d'utiliser la fonction collect qui renvoi un élément de l'API collection et aggregant l'ensemble des éléments produits par le stream (une fois celui-ci vidé et trié avec la fonction sorted).

1
2
3
System.out.println("Stream4");
Stream<Integer> stream4=new Random().ints(0,100).boxed();// on fait un boxed pour traduire de IntStream
Set<Integer> set=stream4.filter((x) -> (x % 2) == 0).limit(10).sorted().collect(Collectors.toSet());

Voyons maintenant les fonctions un peu plus complexe que sont map et reduce. Ces deux fonctions sont un peu le cœur des streams car elles vont nous permettre d'appliquer des opérations de traitements, transformations et calculs.

Prenons par exemple le besoin de calculer un ensemble de point suivant une droite tel que y=a*x+b avec a=4 et b=3. Avec l'API Stream, cela nous donne le code suivant:

1
2
Stream<Double> stream= DoubleStream.iterate(0.00, x -> x + 1 ).boxed();
Set<Double> s=stream.map(x -> 4*(x+Math.random())+3).limit(200000).collect(Collectors.toSet());

De la même manière, il va être possible de calculer la moyenne des différents points avec la fonction reduce :

1
2
Integer value=IntStream.range(1,10).reduce((x,y)->x+y).getAsInt();
System.out.println(value); //45

Avec ces deux exemples simples, on comprend assez facilement le role des fonctions map/reduce.

La première va construire un stream constitué des résultats de la fonction passée en paramètre (un peu comme foreach sans être une fonction finale pour le stream).

A noter que des variantes de la fonction map existent : des fonctions comme mapToObject, boxed, mapToDouble,etc...permettent de faciliter la conversion d'un Stream d'un type d'objet en un Stream contenant un autre type d'objets.

La seconde, la fonction reduce, a l'inverse va appliquer de façon globale la fonction passée en paramètre afin de restituer un resultat unitaire (ici une moyenne).

Un peu plus loin

Afin afin de permettre des traitements spécifiques, il est possible d'employer des Streams spécifiques dédiés a la manipulation des entiers, des doubles, des longs etc... Ces Streams permettent alors de récupérer pas exemple des statistiques sur les données du stream comme avec l’objet IntSummaryStatistics

1
2
3
4
5
6
Stream<Integer> stream4=new Random().ints(0,100).boxed();
IntSummaryStatistics stats=stream4.mapToInt(x -> x).summaryStatistics();
System.out.println("Plus grand" + stats.getMax());
System.out.println("Plus petit" + stats.getMin());
System.out.println("La somme" + stats.getSum());
System.out.println("La moyenne" + stats.getAverage());

Voila nous sommes a la fin de la présentation des Streams. Dernier point a évoquer est la possibilité d'utiliser la fonction parallel qui s'appuie sur l'API Java 7 fork/join [3] et fourni la possibilité de paralléliser les traitements sur les éléments du stream afin d'en améliorer les performances d’exécution. Il sera peut être utile dans un article futur de nous intéresser a cette fameuse API fork/join.


1
2
Stream<Double> stream2= DoubleStream.iterate(0.00, x -> x + 1 ).boxed();
Set<Double> s2=stream2.parallel().map(x -> 4*(x+Math.random())+3).limit(200000).collect(Collectors.toSet());

Voila, nous avons fait le tour de l'API Stream de Java 8. Elle utilise largement les lambda expressions et implémente une nouvelle manière de penser le traitement des données tout en fournissant des outils simples de manipulation (map/reduce) sans omettre le besoin de performance (parallel). Pour ceux voulant d'autres exemples d'utilisation je vous invite a consulter [4], [5], [6] et [7].

Pour avoir le code source (en vrac) c'est ici [8].

Référence:

  • [1] https://un-est-tout-et-tout-est-un.blogspot.com/2017/11/evolution-java-8.html
  • [2] https://docs.oracle.com/javase/8/docs/api/java/util/stream/package-summary.html
  • [3] http://blog.paumard.org/2011/07/05/java-7-fork-join/
  • [4] https://winterbe.com/posts/2014/07/31/java8-stream-tutorial-examples/
  • [5] https://blog.axopen.com/2014/05/java-8-api-stream-introduction-collections/
  • [6] https://www.tutorialspoint.com/java8/java8_streams.htm
  • [7] https://www.baeldung.com/java-8-streams
  • [8] https://github.com/collonville-tom/tc-un-est-tout-et-tout-est-un/tree/master/Java8-Streams/src

Aucun commentaire:

Enregistrer un commentaire