API Stream - Une nouvelle façon de gérer les Collections en Java 8

Pour l’évènement de la sortie de Java 8, Ippon vous propose plusieurs posts sur les nouveautés du langage. A lire également :- Java 8 – Gestion du temps

Jusqu’à présent, effectuer des traitements sur des Collections ou des tableaux (de type MapReduce) en Java passait essentiellement par l’utilisation du pattern Iterator. Java 8 nous propose l’API Stream pour simplifier ces traitements en introduisant un nouvel objet, Stream.

Un stream se construit à partir d’une source de données (une collection, un tableau ou des sources I/O par exemple), et possède un certain nombre de propriétés spécifiques :

  • Un stream ne stocke pas de données, contrairement à une collection. Il se contente de les transférer d’une source vers une suite d’opérations.
  • Un stream ne modifie pas les données de la source sur laquelle il est construit. S’il doit modifier des données pour les réutiliser, il va construire un nouveau stream à partir du stream initial. Ce point est très important pour garder une cohérence lors de la parallélisation du traitement.
  • Le chargement des données pour des opérations sur un stream s’effectue de façon lazy. Cela permet d’optimiser les performances de nos applications. Par exemple, si l’on recherche dans un stream de chaînes de caractères une chaîne correspondant à un certain pattern, cela nous permettra de ne charger que les éléments nécessaires pour trouver une chaîne qui conviendrait, et le reste des données n’aura alors pas à être chargé.
  • Un stream peut ne pas être borné, contrairement aux collections. Il faudra cependant veiller à ce que nos opérations se terminent en un temps fini – par exemple avec des méthodes comme limit(n) ou findFirst( ).
  • Enfin, un stream n’est pas réutilisable. Une fois qu’il a été parcouru, si l’on veut réutiliser les données de la source sur laquelle il avait été construit, nous serons obligés de reconstruire un nouveau stream sur cette même source.

Il existe deux types d’opérations que l’on peut effectuer sur un stream : les opérations intermédiaires et les opérations terminales.

Les opérations intermédiaires (Stream.map ou Stream.filter par exemple) sont effectuées de façon lazy et renvoient un nouveau stream, ce qui crée une succession de streams que l’on appelle stream pipelines. Tant qu’aucune opération terminale n’aura été appelée sur un stream pipelines, les opérations intermédiaires ne seront pas réellement effectuées.

Quand une opération terminale sera appelée (Stream.reduce ou Stream.collect par exemple), on va alors traverser tous les streams créés par les opérations intermédiaires, appliquer les différentes opérations aux données puis ajouter l’opération terminale. Dès lors, tous les streams seront dit consommés, ils seront détruits et ne pourront plus être utilisés.

List<String> strings = Arrays.asList("girafe", "chameau", "chat", "poisson", "cachalot");

       strings.stream()
               // filtrage

               .filter(x -> x.contains("cha"))
               // mapping : reformatage des chaînes de caractères

               .map(x -> x.substring(0, 1).toUpperCase() + x.substring(1))
               // tri par ordre alphabétique

               .sorted()
               // Outputs:

               // Cachalot

               // Chameau

               // Chat

               .forEach( System.out::println );

Exemple simple de traitement de Collection avec un Stream

Création d’un stream

On peut créer un stream de plusieurs façons. La plus simple consiste à appeler la méthode stream() ou parallelStream() sur une collection, mais un certain nombre de méthodes ont été ajoutées aux classes déjà existantes.

Notons ainsi la méthode chars() de la classe String, qui renvoie un IntStream construit sur les différents caractères de la chaîne de caractères, ou encore la méthode lines() de la classe BufferedReader qui crée un stream de chaînes de caractères à partir des lignes du fichier ouvert. À la classe Random s’ajoute aussi une méthode intéressante, ints(), qui renvoie un stream d’entiers pseudo aléatoires.

L’API propose également des méthodes statiques au sein de la classe Stream. Par exemple, le code suivant : “Stream.iterate(1, x -> x*2)” renverra un stream infini d’entiers contenant la suite des puissances de 2. Le premier argument contient la valeur initiale du stream, et le deuxième la fonction permettant de passer de l’élément n à l’élément n+1 dans le stream.

Parallélisation

L’un des points forts de cette nouvelle API est de nous permettre de paralléliser nos traitements de façon particulièrement aisée. En effet, n’importe quel stream peut être parallélisé en appelant sa méthode parallel() héritée de l’interface BaseStream – de la même façon, un stream peut être rendu séquentiel en invoquant la méthode sequential(). On peut également construire un stream parallèle sur une collection directement en appelant la méthode parallelStream() sur cette collection.

Ces méthodes nous permettent de masquer la répartition du travail, mais ne doivent pas être prises à la légère : en essayant de gagner en performance en parallélisant n’importe quel traitement, on prend le risque de produire l’effet inverse (nous y reviendrons plus tard).

Opérations intermédiaires

Les opérations intermédiaires peuvent être stateful ou stateless. Les opérations stateless effectuent un traitement sur les éléments du stream un à un sans avoir à prendre en compte les autres éléments du stream.

List<Commande> mesCommandes = … ;

List<Client> mesClients = mesCommandes.stream()
     .map( c -> c.getClient() )
     .collect( Collectors.toList() );

collect permet ici simplement de stocker le résultat dans une liste Les opérations stateful quant à elles, ont généralement besoin de connaître l’ensemble du stream pour donner un résultat (par exemple Stream.distinct ou Stream.sorted). Par conséquent, paralléliser un tel traitement risque bien souvent de baisser nos performances au lieu de les améliorer.

List<Commande> mesCommandes = … ;

List<Client> mesClients = mesCommandes.stream()
     .map( c -> c.getClient() )
     .distinct()
     .collect( Collectors.toList() );

On renvoie la liste de nos clients, sans doublons, grâce à l'opération intermédiaire stateful distinct()

Opérations terminales

Nous disposons de deux types de réductions dans l’API Stream. Les opération de réductions simples et les réductions mutables.

Les réductions simples sont celles auxquelles on pourrait penser en premier lieu : La somme d’éléments (Stream.sum), le maximum (Stream.max), ou le nombre d’éléments (Stream.count) sont des réductions simples. Dans sa forme générale, elle se défini de la façon suivante :

<U> U reduce(U identity,

     BiFunction<U, ? super T, U> accumulator,

     BinaryOperator<U> combiner);

L’élément identité est l’élément initial pour la réduction (et l’élément renvoyé si le stream est vide). L’accumulator crée un nouveau résultat partiel à partir d’un résultat partiel et d’un nouvel élément, et le combiner crée un nouveau résultat partiel à partir de deux résultats partiels.

Deux points sont à noter dans cette méthode :

  • Tout d’abord, l’identité doit être une identité au sens mathématique du terme pour la fonction combiner : combiner.apply(u, identity) doit être égal à u quel que soit u.
  • La fonction combiner doit être associative. Cela est une nécessité pour ne pas obtenir de résultat aléatoire lors d’une parallélisation du travail.

La méthode sum peut donc être réécrite en utilisant la méthode reduce :

List<Commande> mesCommandes = …;

int chiffreAffaire = mesCommandes.stream()
     .reduce( 0, 
          (result, commande) -> result + commande.getPrice(),
          (resultA, resultB) -> resultA + resultB );

On peut réécrire la méthode sum() en utilisant la méthode reduce Les réductions mutables généralisent ce concept en accumulant les éléments d’un stream dans un container. Ce dernier peut être une Collection, un StringBuilder, ou même un simple entier (auquel cas nous aurions affaire à une réduction simple).

<R> R collect(Supplier<R> supplier,

     BiConsumer<R, ? super T> accumulator,

     BiConsumer<R, R> combiner);

Nous retrouvons une syntaxe relativement similaire à la syntaxe de la méthode reduce. Cette fois-ci nous devons cependant initialiser un container (supplier), puis définir la méthode accumulator qui ajoutera un élément à un container, et enfin la méthode combiner qui créera un nouveau container à partir de deux container temporaires.

Afin de simplifier notre code, l’API Stream nous propose également une autre classe, Collectors, qui encapsule les trois arguments nécessaire à une réduction pour certaines opérations classiques (récupération des données dans une liste, une map ou un set, concaténer des chaînes de caractères…). On pourrait par exemple modifier notre code précédent pour obtenir le même résultat :

List<Commande> mesCommandes = …;

int chiffreAffaire = mesCommandes.stream()
     .collect( Collectors.summingInt( Commande::getPrice ) );

Conclusion

Cette nouvelle API fournie par le JDK 8 va donc modifier fondamentalement notre façon de traiter les Collections en nous proposant une alternative au pattern Iterator relativement lourd à mettre en place. Celle-ci tire profit de la nouvelle syntaxe des lambdas expressions pour réduire notre code un maximum tout en améliorant nos performances. De plus, la classe Collectors présentée succinctement ici nous offre de nombreux patterns qui remplaceront dans de nombreux cas le pattern Iterator.