Le traitement de données dans un style fonctionnel vous permet d'explorer l'API Stream qui vous permet d'écrire du code puissant qui traite une collection de données de manière déclarative.

 

Il est tentant de voir un Stream comme une collection. Les notions de collections et de Stream concernent toutes les deux une séquence d'éléments. La différence entre collection et Stream est la suivante : une collection permet de stocker cette séquence d'éléments et un Stream permet d'exécuter des opérations sur cette séquence d'éléments.

 

A la lecture de cet article, vous aurez une base de connaissances qui vont vous permettre  de comprendre les Stream et comment vous pouvez les utiliser dans votre code pour traiter une collection de données de manière concise et efficace.

 

A la fin on fera un détour sur les nouveautés de l'API Stream de Java 8+.

 

  Cas d’utilisation:

            Imaginons que nous voulons créer une collection de plats afin de représenter un menu, puis la parcourir pour additionner les calories de chaque plat. Nous voulons traiter la collection pour sélectionner uniquement des plats faibles en calories pour un menu spécial sain.

 Prérequis :   maitrise des lambda expressions dans Java 8

 

 

 

 

👉 1) Les Streams

 

          

  Collections est l’API la plus utilisée en java. Que feriez-vous sans collections ? Presque chaque application java fabrique et traite des collections. Elles vous permettent de regrouper et traiter les données. Les collections sont nécessaires pour presque toutes les applications Java. La manipulation des collections est loin d’être parfaite. Alors, que pourraient faire les concepteurs de Java pour manipuler facilement les collections ? Vous avez peut-être deviné : la réponse est : Stream.

 

 

A/ C’est quoi l’API Stream?

 

 

L’API Stream est introduite dans java 8 et est utilisé pour traiter les collections d’objets.

 

Les Stream vous permettent de manipuler des collections de données de manière déclarative. Une collection permet de stocker cette séquence d'éléments.

 

Un Stream n’est pas une collection ou une structure de données, c’est une séquence d’éléments sur laquelle on peut effectuer un groupe d’opérations de manière séquentielle ou parallèle.

 

Voyons un aperçu de l’utilisation des Stream.

Supposons que l’on souhaite  avoir le nom des plats à faible teneur en calorie, classé par nombre de calories. Un plat à faible de teneur de calories est un plat ayant des calories inférieures à 400.

Il est question à travers cet exemple de montrer l’importance et surtout les performances des Stream à travers un code écrit en java 7 et l’autre écrit en Java 8.

 

 

 

Code écrit en Java 7 :

 

Ce code écrit en java 7. Ce code utilise un conteneur jetable. Ce conteneur jetable est : la variable platsFaibleEnCalories.

 

 

Le même écrit en Java 8 en utilisant les Stream.

Pour exploiter l’architecture multicoeurs et exécuter ce code en parallèle, il suffit de remplacer stream() par parallelStream()

 

 

 

Même code écrit en Java 8

 

 

 

 

Explication des méthodes utilisées dans le code écrit en Java 8 :

 

filter() est utilisée pour sélectionner les éléments selon le prédicat passé en argument. Le cas d’utilisation précédent utilise le filter() pour obtenir une flux(Stream)  composé de tous les plats ayant des calories inférieurs à 400.

 

sorted() est utilisé pour trier le flux(Stream).

 

map() est utilisé pour renvoyer un Stream ou flux de noms constitué des résultats du trie.

 

collect() est utilisé pour renvoyer le résultat effectué sur le Stream.

 

 

 

Soit une liste de 6 plats suivant :

 

 

On veut la moyenne des prix des desserts. Pour illustrer la puissance des Stream, nous allons vous présenter deux codes, l’un écrit en  Java 7 et l’autre en Java 8.

 

 

 

Code écrit en java 7          

                                                   

 

 

 

 

Code écrit avec les Stream

 

 

 

 

 

 

Dans l'exemple ci-dessus (code écrit avec les Stream), les données traitées par le Stream subissent plusieurs opérations :

 

  • Un filtre qui utilise le prédicat passé en paramètre sous la forme d'une expression Lambda pour ne conserver que les données concernant le type dessert
  •   Le nouveau Stream obtenu est transformé en utilisant la méthode mapToInt() qui utilise l'interface fonctionnelle ToIntFunction passée en paramètre sous la forme d'une expression lambda pour obtenir un nouveau Stream qui ne contient que les prix des  desserts sous forme d'entier
  • La méthode d'agrégation average() du Stream de type IntStream obtenu permet de calculer la moyenne de ces valeurs entières
  • Le résultat est une instance de type OptionalDouble dont la méthode getAsDouble() permet d'obtenir la valeur sous la forme d'une valeur flottante de type double si une valeur est présente.

 

 

L’API Stream nous permet d’écrire du code déclaratif, plus concis et lisible, du code composable et parallélisable.

 

L’utilisation de l’interface de collection nécessite une itération par l’utilisateur (par exemple en utilisant for-each), c’est ce qu’on appelle l’itération externe. Les Stream utilisent une itération interne : elle effectue l’itération pour vous et prend soin de stocker la valeur de flux résultante quelque part. Vous vous contenter de fournir une fonction en disant ce qui doit être fait.

 

Un Stream peut avoir zéro ou plusieurs opérations intermédiaires et a une opération terminale.

 

 

 

B/ Les opérations pour définir les traitements d’un Stream

 

 

Les opérations d'un Stream permettent de décrire des traitements à exécuter sur un ensemble de données.

 

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 sont effectuées de façon lazy et renvoient un nouveau Stream, ce qui crée une succession de Stream que l’on appelle Stream pipelines. Elles ne réalisent aucun traitement tant que l'opération terminale n'est invoquée.

 

Quand une opération terminale sera appelée on va alors traverser tous les Stream 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 Stream seront dit consommés, ils seront détruits et ne pourront plus être utilisés. Elles renvoient une valeur différente d'un Stream (ou pas de valeur) et ferme le Stream à la fin de leur exécution.

 

Opération intermédiaire : filter,map,limit

Opération terminal : collect

 

 

Dans l’exemple précédent, plusieurs optimisations sont mise en œuvre par le Stream. L’opération limit(2) renvoie un Stream qui contient au maximum 2 éléments.

 

Les opérations n'itèrent pas individuellement sur les éléments du Stream. Ceci permet d'optimiser au mieux les traitements à réaliser pour obtenir le résultat de manière efficiente.

 

 

 

L'utilisation d'un Stream implique généralement trois choses :

 

  • Une source qui va alimenter le Stream avec les éléments à traiter
  • Un ensemble d'opérations intermédiaires qui vont décrire les traitements à effectuer
  • Une opération terminale qui va exécuter les opérations et produire le résultat.

 

On veut créer une liste des 8 premières personnes dont le nom commence par un 'RO'.

 

 

 

 

Cet exemple utilise trois opérations intermédiaires :

 

  • map() : transforme chaque élément de type Personne pour renvoyer uniquement le nom
  • filter() : permet de limiter les éléments pour ne conserver que ceux qui commencent par 'RO'
  • limit() : arrête les traitements dès que le Stream contient 8 éléments

 

L'opération terminale collect() transforme les résultats de l'exécution dans une collection de type List.

 

 

 

Pour filtrer des données, un Stream propose plusieurs opérations :

 

  • filter(Predicate) : renvoie un Stream qui contient les éléments pour lesquels l'évaluation du Predicate passé en paramètre vaut true
  • distinct() : renvoie un Stream qui ne contient que les éléments uniques (elle retire les doublons).
  • limit(n) : renvoie un Stream que ne contient comme éléments que le nombre fourni en paramètre
  • skip(n) : renvoie un Stream dont les n premiers éléments sont ignorés

 

 

 

Pour rechercher une correspondance avec des éléments, un Stream propose plusieurs opérations :

 

  • anyMatch(Predicate) : renvoie un booléen qui précise si l'évaluation du Predicate sur au moins un élément vaut true
  • allMatch(Predicate) : renvoie un booléen qui précise si l'évaluation du Predicate sur tous les éléments vaut true
  • noneMatch(Predicate) : renvoie un booléen qui précise si l'évaluation du Predicate sur tous les éléments vaut false
  • findAny() : renvoie un objet de type Optional qui encapsule un élément du Stream s'il existe
  • findFirst() : renvoie un objet de type Optional qui encapsule le premier élément du Stream s'il existe

 

 

 

Pour transformer des données, un Stream propose plusieurs opérations :

 

  • map(Function) : applique la Function fournie en paramètre pour transformer l'élément en créant un nouveau
  • flatMap(Function) : applique la Function fournie en paramètre pour transformer l'élément en créant zéro, un ou plusieurs éléments

 

 

 

Pour réduire les données et produire un résultat, un Stream propose plusieurs opérations :

 

  • reduce() : applique une Function pour combiner les éléments afin de produire le résultat
  • collect() : permet de transformer un Stream qui contiendra le résultat des traitements de réduction dans un conteneur mutable

 

 

 

C/ Opérations de Map et flatMap()

 

 

Une opération de mapping permet de réaliser une transformation des éléments traités par le Stream.

 

La méthode map() permet d'opérer une transformation en passant chacun des éléments du Stream à la Function fournie en paramètre. Le résultat est un nouveau Stream contenant le résultat de l'application de la fonction à chaque élément. C'est une transformation de type un pour un.

 

La map permet aussi d’effectuer un traitement sur une liste sans la modifier réellement.

 

 

 

 

On aura au niveau de la console :

AAAA

BBBB

CCCC.

 

 

 

Les opérations map() et flatMap() servent toutes les deux à réaliser des transformations mais il existe une différence majeure :

 

  • La méthode map() renvoie un seul élément
  • La méthode flatMap() renvoie un Stream qui peut contenir zéro, un ou plusieurs éléments

 

L'opération map() permet de transformer un élément du Stream mais elle possède une limitation : le résultat de la transformation doit obligatoirement produire un unique élément qui sera ajouté au Stream renvoyé.

 

L'opération flatMap() transforme chaque élément en un Stream d'autres éléments : ainsi chaque élément va être transformé en zéro, un ou plusieurs autres éléments. Le contenu des Streams issus du résultat de la transformation de chaque objet est agrégé pour constituer le Stream retourné par la méthode flatMap().

 

L'opération flatMap() attend en paramètre une Function qui renvoie un Stream. La Function est appliquée sur chaque élément qui produit chacun un Stream. Le résultat renvoyé par la méthode est un Stream qui est l'agréation de tous les Streams produits par les invocations de la Function.

 

 

 

 

👉 2) Recommandations sur l’utilisation de l’API Stream

 

 

Certaines utilisations de l’API Stream peuvent rendre le code moins lisible et compréhensible ou moins performant.

 

 

Parcours des éléments :

 

 Inutile d’avoir recours à un Stream pour appliquer un traitement sur les éléments d’une collection. Cela est d’autant plus vrai si aucune opération intermédiaire n’est utilisée dans le Stream.

 

 

Dans l’exemple précédent, il n’est pas recommandé de faire recours à un Stream.  Si aucune opération intermédiaire est à utiliser, il est préférable d’utilisé la méthode forEach lorsque l’on dois parcourir les éléments d’une collection.  L’exemple précédent deviendra :

 

 

 

 

Détermination du nombre d’éléments d’une collection :

 

Il n’est pas nécessaire d’utiliser un Stream pour uniquement déterminer le nombre d’éléments d’une collection.

 

 

 

 

Dans l’exemple précédent, il n’est utile d’avoir recours à un Stream. Il est préférable d’utiliser la méthode size() de l’interface Collection comme le stipule l’exemple suivant :

 

 

 

 

La vérification de la présence d’un élément dans une collection :

 

Il n’est pas nécessaire d’utiliser une Stream pour uniquement vérifier la présence d’un élément dans une collection.

 

 

 

 

Dans l’exemple précédent, il est inutile d’utiliser une Stream, il est préférable d’utiliser la méthode contains() de l’interface collection comme l’illustre l’exemple suivant :

 

 

 

 

 

👉 3) Améliorations de l’API Stream depuis java 9

 

 

Quatre nouvelles méthodes ont vu le jour dans l’API Stream, il s’agit de takeWhile(), dropWhile(),ofNullable() et iterate().

 

 

takeWhile : Retourne des éléments d'une séquence tant que la condition spécifiée a la valeur true, puis ignore les éléments restants.

 

L'opération dropWhile supprimera des éléments tandis que le prédicat donné pour un élément renvoie true et arrête de supprimer le false du premier prédicat.

 

L'opération itérate modifie légèrement cette méthode en ajoutant un prédicat qui s'applique aux éléments pour déterminer quand le Stream doit se terminer. Son utilisation est très pratique et concise.

 

 

 

 

Références :

https://www.baeldung.com/java-9-stream-api

https://docs.oracle.com/javase/9/docs/api/java/util/stream/Stream.html