Collecter les données via l’API Stream

Chapitre 6: Collecter les données via l’API Stream

Ce chapitre couvre

  • Création et utilisation d’un collecteur avec la classe Collectors
  • Réduction des flux de données en une seule valeur
  • La synthèse comme un cas particulier de réduction
  • Regroupement et partitionnement des données
  • Développement de vos propres collecteurs personnalisés

Le code est disponible sur le repository officiel github.

Vous avez appris dans le chapitre précédent que les flux vous aident à traiter des collections avec des opérations de type base de données. Vous pouvez visualiser les flux Java 8 comme des itérateurs fantaisistes paresseux d’ensembles de données. Ils prennent en charge deux types d’opérations : les opérations intermédiaires telles que les opérations de filtrage ou de mapping et celles terminales telles que count, findFirst, forEach et reduce. Les opérations intermédiaires peuvent être chaînées pour convertir un flux en un autre flux. Leur but est de mettre en place un pipeline. En revanche, les opérations terminales consomment un flux pour produire un résultat final (par exemple, renvoyer l’élément le plus grand dans un flux). Elles peuvent souvent raccourcir les calculs en optimisant le pipeline du flux.

Nous avons déjà utilisé l’opération terminale collect sur les flux dans les chapitres 4 et 5, mais nous l’avons principalement utilisée pour combiner tous les éléments d’un flux dans une liste. Dans ce chapitre, vous découvrirez que la collecte est une opération de réduction, tout comme reduce, qui prend comme argument diverses recettes pour accumuler les éléments d’un flux dans un résultat récapitulatif. Ces recettes sont définies par une nouvelle interface Collector, il est donc important de distinguer Collection, Collector et Collect.

Voici quelques exemples de requêtes sur ce que vous pourrez faire avec collect et collectors:

  • Groupez une liste de transactions par devise pour obtenir la somme des valeurs de toutes les transactions avec cette devise (en retournant une Map<Devise, Integer>)
  • Partitionner une liste de transactions en deux groupes : cher et pas cher (renvoyer une Map<Boolean, List <Transaction >>)
  • Créez des regroupements multiniveaux tels que le regroupement des transactions par ville, puis catégorisez-les en fonction de leur coût ou non (renvoyer une Map<String, Map <Boolean, List <Transaction >>>)

Excité ? Génial, commençons par explorer un exemple qui se sert de l’interface Collectors. Imaginez un scénario dans lequel vous avez une liste de transactions, et vous souhaitez les regrouper en fonction de leur devise nominale. Dans Java pré-lambda, même un simple cas d’utilisation comme celui-ci était lourd à mettre en œuvre, comme indiqué dans la figure suivante.

Si vous êtes un développeur Java expérimenté, vous vous sentirez probablement à l’aise d’écrire quelque chose comme ça, mais vous devez admettre que c’est beaucoup de code pour une tâche aussi simple. Pire encore, c’est probablement plus difficile à lire qu’à écrire. La fonction du code n’est pas immédiatement visible au premier coup d’œil, même s’il peut être exprimé de manière simple en anglais : « Groupez une liste de transactions par devise ». Comme vous l’apprendrez dans ce chapitre, vous pouvez : obtenir exactement le même résultat avec une instruction unique en utilisant un paramètre Collector plus général pour la méthode collect sur Stream plutôt que le cas spécial toList utilisé dans le chapitre précédent :

La comparaison est assez embarrassante, n’est-ce pas ?

6.1. Les Collectors en un mot

L’exemple précédent montre clairement l’un des principaux avantages de la programmation fonctionnelle par rapport à une approche impérative : il vous suffit de formuler le résultat que vous voulez obtenir le « quoi » et non les étapes que vous devez effectuer pour l’obtenir. Dans l’exemple précédent, l’argument passé à la méthode collect est une implémentation de l’interface Collector, qui est une recette pour construire un résumé des éléments contenus dans la Stream. Dans le chapitre précédent, la recette toList disait simplement « Faites une liste contenant chaque élément « ; Dans cet exemple, la recette groupingBy indique « Créer une map dont les clés sont des compartiments (de devise) et dont les valeurs sont une liste d’éléments dans ces compartiments ».

La différence entre les versions impératives et fonctionnelles de cet exemple est encore plus prononcée si vous effectuez des regroupements multiniveaux : dans ce cas, le code impératif devient rapidement plus difficile à lire, maintenir et modifier en raison du nombre de boucles et de conditions profondément imbriquées. En comparaison, la version fonctionnelle, comme vous le découvrirez dans la section 6.3, peut facilement être améliorée avec un collecteur supplémentaire.

6.1.1. Collecteurs en tant que réducteurs plus complexes

Cette dernière observation apporte un autre avantage typique d’une API fonctionnelle bien conçue : son plus haut degré de composabilité et de réutilisabilité. Les collecteurs sont extrêmement utiles car ils fournissent une manière concise mais flexible de définir les critères que la méthode collect utilisera pour produire la collection résultante. Plus précisément, l’appel de la méthode collect sur un flux déclenche une opération de réduction (paramétrée par un collecteur) sur les éléments du flux lui-même. Cette opération de réduction, illustrée à la figure 6.1, effectue en interne pour vous ce que vous avez dû impérativement coder dans la figure 6.1. Il traverse chaque élément du flux et permet au collecteur de les traiter.

Généralement, le collecteur applique une fonction de transformation à l’élément (souvent, il s’agit d’une transformation d’identité, qui n’a aucun effet, comme dans toList), et accumule le résultat dans une structure de données qui constitue le résultat final. Par exemple, dans notre exemple de regroupement de transactions présenté précédemment, la fonction de transformation extrait la devise de chaque transaction, puis la transaction elle-même est accumulée dans la map résultante, en utilisant la devise comme clé.

L’implémentation des méthodes de l’interface Collector définit comment effectuer une opération de réduction sur un flux, tel que celui de notre exemple de devise. Nous étudions comment créer des collecteurs personnalisés dans les sections 6.5 et 6.6. Mais la classe d’utilitaires Collectors fournit beaucoup de méthodes d’usine statiques pour créer facilement une instance des collecteurs les plus courants prêts à l’emploi. Le collecteur le plus simple et le plus fréquemment utilisé est la méthode statique toList, qui rassemble tous les éléments d’un flux dans une liste :

6.1.2. Collecteurs prédéfinis

Dans la suite de ce chapitre, nous explorons principalement les fonctionnalités des collecteurs prédéfinis, celles qui peuvent être créées à partir des méthodes d’usine (telles que groupingBy) fournies par la classe Collectors. Celles-ci offrent trois fonctionnalités principales :

 

  • Réduire et résumer les éléments de flux à une seule valeur
  • Grouper des éléments
  • Partitionner les éléments

Nous commençons avec des collecteurs qui vous permettent de réduire et de résumer. Ils sont pratiques dans divers cas d’utilisation, tels que la détermination du montant total des valeurs dans la liste des transactions de l’exemple précédent.

Vous verrez ensuite comment regrouper les éléments d’un flux, en généralisant l’exemple précédent à plusieurs niveaux de regroupement ou en combinant différents collecteurs pour appliquer d’autres opérations de réduction sur chacun des sous-groupes résultants. Nous décrirons également le partitionnement comme un cas particulier de regroupement, utilisant un prédicat (une fonction à un argument retournant un booléen) comme clé.

À la fin de la section 6.4, vous trouverez un tableau résumant tous les collecteurs prédéfinis explorés dans ce chapitre. Enfin, dans la section 6.5, vous en apprendrez plus sur l’interface de Collector avant de découvrir (section 6.6) comment vous pouvez créer vos propres collecteurs personnalisés à utiliser dans les cas non couverts par les méthodes d’usine de la classe Collectors.

6.2. Réduire et résumer

Pour illustrer la gamme des instances de collection possibles qui peuvent être créées à partir de la classe d’usine Collectors, nous réutiliserons le domaine que nous avons présenté dans le chapitre précédent : un menu composé d’une liste de plats délicieux.

Comme vous venez de l’apprendre, les collecteurs (les paramètres de la méthode Stream collect) sont généralement utilisés dans les cas où il est nécessaire de réorganiser les éléments du flux dans une collection. Mais plus généralement, ils peuvent être utilisés chaque fois que vous voulez combiner tous les éléments du flux en un seul résultat. Ce résultat peut être de n’importe quel type, aussi complexe qu’une Map multiniveau représentant un arbre ou aussi simple qu’un seul entier – représentant peut-être la somme de toutes les calories contenues dans le menu. Nous examinerons ces deux types de résultats : les entiers simples dans la section 6.2.2 et le regroupement multiniveau dans la section 6.3.1.

Comme premier exemple simple, comptons le nombre de plats dans le menu, en utilisant le collecteur retourné par la méthode d’usine count :

Vous pouvez écrire ceci plus directement

Mais le collecteur counting peut être particulièrement utile lorsqu’il est utilisé en combinaison avec d’autres collecteurs, comme nous le montrerons plus tard.

Dans la suite de ce chapitre, nous supposons que vous avez importé toutes les méthodes statiques de la classe Collectors avec

Donc vous pouvez écrire counting() au lieu de Collectors.counting() et ainsi de suite.

Continuons d’explorer des collecteurs prédéfinis simples en examinant comment vous pouvez trouver les valeurs maximales et minimales dans un flux.

6.2.1. Trouver le maximum et le minimum dans un flux de valeurs

Supposons que vous vouliez trouver le plat le plus calorique du menu. Vous pouvez utiliser deux collecteurs, Collectors.maxBy et Collectors.minBy, pour calculer la valeur maximale ou minimale dans un flux. Ces deux collecteurs utilisent un comparateur comme argument pour comparer les éléments du flux. Ici, vous créez un comparateur comparant les plats en fonction de leur teneur en calories et les transmettez à Collectors.maxBy:

Vous vous demandez peut-être ce qu’est l’Optional<Dish>. Pour répondre à cette question, nous devons nous poser la question « Et si le menu était vide ? » Il n’y a pas de plat à retourner. Java 8 introduit Optional, qui est un conteneur qui peut contenir ou non une valeur. Ici, il représente parfaitement l’idée qu’il peut ne pas avoir de plats à retourner. Nous l’avons brièvement mentionné au chapitre 5 lorsque vous avez rencontré la méthode findAny. Ne vous en faites pas pour l’instant ; nous consacrons le chapitre 10 à l’étude de Optional<T> et de ses opérations.

Une autre opération de réduction courante qui renvoie une seule valeur consiste à additionner les valeurs d’un champ numérique des objets d’un flux. Alternativement, vous pouvez vouloir faire la moyenne des valeurs. De telles opérations sont appelées opérations de résumé. Voyons comment vous pouvez les exprimer en utilisant des collecteurs.

6.2.2. Récapitulation

La classe Collectors fournit une méthode d’usine spécifique pour la sommation : Collectors .summingInt. Il accepte une fonction qui mappe un objet en int qui doit être additionné et renvoie un collecteur qui, lorsqu’il est transmis à la méthode de collecte habituelle, effectue le résumé demandé. Ainsi, par exemple, vous pouvez trouver le nombre total de calories dans votre liste de menu avec:

Ici, le processus de collecte se déroule comme illustré à la figure 6.2. Tout en traversant le flux, chaque plat est mappé en son nombre de calories, et ce nombre est ajouté à un accumulateur à partir d’une valeur initiale (dans ce cas, la valeur est 0).

Les méthodes Collectors.summingLong et Collectors.summingDouble se comportent exactement de la même manière et peuvent être utilisées lorsque le champ à sommer est respectivement un long ou un double.

Mais parmis les opérations de resumé il n’y a pas que de simples sommations ; on a aussi un Collectors .averagingInt, avec ses contreparties averageagingLong et averageDouble, pour calculer la moyenne du même ensemble de valeurs numériques:

Jusqu’à présent, vous avez vu comment utiliser les collecteurs pour compter les éléments dans un flux, trouver les valeurs maximum et minimum d’une propriété numérique de ces éléments ensuite calculer leur somme et leur moyenne. Assez souvent, cependant, vous voudrez peut-être récupérer deux ou plusieurs de ces résultats, et vous voudrez peut-être le faire en une seule opération. Dans ce cas, vous pouvez utiliser le collecteur renvoyé par la méthode factory summarizingInt. Par exemple, vous pouvez compter les éléments dans le menu et obtenir la somme, la moyenne, le maximum et le minimum des calories contenues dans chaque plat avec une seule opération de synthèse :

Ce collecteur rassemble toutes ces informations dans une classe appelée IntSummaryStatistics qui fournit des méthodes getter pratiques pour accéder aux résultats. L’impression de l’objet menuStatistics produit la sortie suivante :

Comme d’habitude, il existe des méthodes summarizingLong et summarizingDouble factory avec les types associés LongSummaryStatistics et DoubleSummaryStatistics ; elles sont utilisées lorsque la propriété à collecter est de type primitif long ou double.

6.2.3. Joindre des String

Le collecteur renvoyé par la méthode joining concatène en une seule String toutes les chaînes résultant de l’invocation de la méthode toString sur chaque objet du flux. Cela signifie que vous pouvez concaténer les noms de tous les plats dans le menu comme suit :

Notez que la méthode joining utilise en interne une StringBuilder pour ajouter les chaînes générées en une seule. Notez également que si la classe Dish avait une méthode toString retournant le nom du plat, vous obtiendriez le même résultat sans avoir besoin de faire un mapping sur le flux original qui extraie le nom de chaque plat :

Les deux produisent la chaîne suivante:

Ce qui n’est pas très lisible. Heureusement, la méthode joining a une version surchargée qui accepte une chaîne de délimitation entre deux éléments consécutifs, de sorte que vous puissiez obtenir une liste de noms de plats séparés par des virgules avec

qui, comme prévu, va générer:

Jusqu’à présent, nous avons exploré divers collecteurs qui réduisent un flux à une seule valeur. Dans la section suivante, nous montrons comment tous les processus de réduction de cette forme sont des cas particuliers du collecteur de réduction plus général fourni par la méthode d’usine Collectors.reducing.

6.2.4. Synthèse généralisée de l’opération de réduction

Tous les collecteurs dont nous avons parlé jusqu’ici ne sont, en réalité, que des spécialisations pratiques d’un processus de réduction qui peut être défini en utilisant la méthode reducing. La méthode Collectors.reducing est une généralisation. Tous les cas spéciaux discutés précédemment sont sans doute fournis uniquement pour la commodité du programmeur. (Mais rappelez-vous que la commodité et la lisibilité du programmeur sont ce qui importe véritablement) Par exemple, il est possible de calculer le nombre total de calories dans votre menu avec un collecteur créé à partir de la méthode de reducing comme ceci :

Il prend trois arguments :

  • Le premier argument est la valeur de départ de l’opération de réduction et sera également la valeur retournée dans le cas d’un flux sans éléments, donc clairement 0 est la valeur appropriée dans le cas d’une somme numérique.
  • Le second argument est la même fonction que vous avez utilisée dans la section 6.2.2 pour transformer un plat en un int représentant son contenu calorique.
  • Le troisième argument est un BinaryOperator qui agrège deux éléments en une seule valeur du même type. Ici, il ne fait que sommer deux entiers.

De même, vous pouvez trouver le plat le plus calorique en utilisant la version à un argument de réduction comme ceci :

Vous pouvez penser au collecteur créé avec la méthode reducing à un argument comme un cas particulier de la méthode à trois arguments, qui utilise le premier élément du flux comme point de départ et une fonction d’identité (c’est-à-dire une fonction ne faisant rien plus que de retourner son argument d’entrée tel quel) en tant que fonction de transformation. Cela implique également que le collecteur reducing à un argument n’ait aucun point de départ lorsqu’il sera passé à la méthode de collect d’un flux vide et, comme nous l’avons expliqué dans la section 6.2.1, il renverra pour cette raison un objet Optional <Dish>.



Collect vs reduce

Nous avons beaucoup discuté des réductions dans le chapitre précédent et celui-ci. Vous pouvez naturellement vous demander quelles sont les différences entre les méthodes de collecte et de réduction de l’interface Stream, car vous pouvez souvent obtenir les mêmes résultats en utilisant l’une ou l’autre méthode. Par exemple, vous pouvez réaliser ce qui est fait par toList Collector en utilisant la méthode reduce comme ceci :

Cette solution a deux problèmes : un problème sémantique et un problème pratique. Le problème sémantique réside dans le fait que la méthode reduce vise à combiner deux valeurs et à en produire une nouvelle ; c’est une réduction immuable. En revanche, la méthode collect est conçue pour muter un conteneur afin d’accumuler le résultat qu’il est censé produire. Cela signifie que l’extrait de code précédent utilise à mauvais escient la méthode de réduction, car elle mute la liste utilisée comme accumulateur. Comme vous le verrez plus en détail dans le chapitre suivant, l’utilisation de la méthode reduce avec la mauvaise sémantique est également la cause d’un problème pratique : ce processus de réduction ne peut pas fonctionner en parallèle car la modification simultanée de la même structure de données par plusieurs threads peut corrompre la liste elle-même. Dans ce cas, si vous voulez une sécurité au niveau des threads, vous devrez allouer une nouvelle liste à chaque fois, ce qui affecterait les performances du fait de l’allocation d’objet. C’est la principale raison pour laquelle la méthode collecte est utile pour exprimer la réduction en travaillant sur un conteneur mutable, mais surtout de façon parallèle, comme vous le verrez plus tard dans le chapitre.

Flexibilité de l’API Collection: faire la même opération de différentes manières

Vous pouvez encore simplifier l’exemple sum précédent en utilisant le collecteur réducteur en utilisant une référence à la méthode sum de la classe Integer au lieu de l’expression lambda que vous avez utilisée pour coder la même opération. On obtient:

Logiquement, cette opération de réduction se déroule comme le montre la figure 6.3, où un accumulateur, initialisé avec une valeur de départ, est combiné itérativement, en utilisant une fonction d’agrégation, avec le résultat de l’application de la fonction de transformation sur chaque élément du flux.

 

Le collecteur counting que nous avons mentionné au début de la section 6.2 est, en réalité, mis en œuvre de manière similaire en utilisant la méthode reducing à trois arguments. Il transforme chaque élément du flux en un objet de type Long avec la valeur 1, puis les somme tous :



Utilisation du générique? wildcard

Dans l’extrait de code que vous venez de voir, vous avez probablement remarqué le « ? », wildcard, utilisé comme deuxième type générique dans la signature du collecteur retourné par la méthode count. Vous devriez déjà être familier avec cette notation, surtout si vous utilisez le Framework Collection de Java assez fréquemment. Mais ici, cela signifie seulement que le type de l’accumulateur du collecteur est inconnu ou, en d’autres termes, que l’accumulateur lui-même peut être de n’importe quel type. Nous l’avons utilisé ici pour signaler exactement la signature de la méthode telle que définie à l’origine dans la classe Collectors, mais dans le reste du chapitre, nous évitons toute notation générique pour garder la discussion aussi simple que possible.



Nous avons déjà observé au chapitre 5 qu’il existe une autre façon d’effectuer la même opération sans utiliser de collecteur : en mappant le flux de plats dans le nombre de calories de chaque plat puis en réduisant le flux résultant avec la même référence de méthode que dans la version précédente :

Choisir la meilleure solution pour votre situation

Une fois de plus, cela démontre comment la programmation fonctionnelle en général fournit souvent plusieurs façons d’effectuer la même opération. Cet exemple montre également que les collecteurs sont un peu plus complexes à utiliser que les méthodes directement disponibles sur l’interface Streams, mais en échange ils offrent des niveaux plus élevés d’abstraction et de généralisation et sont plus réutilisables et personnalisables.

Notre suggestion est d’explorer le plus grand nombre de solutions possibles pour le problème en question, mais toujours choisir le plus spécialisé qui soit qui est en général suffisant pour le résoudre. C’est souvent la meilleure décision pour des raisons de lisibilité et de performance. Par exemple, pour calculer le nombre total de calories dans notre menu, nous préférerions la dernière solution (en utilisant IntStream) parce que c’est la plus concise et probablement la plus lisible. En même temps, c’est aussi celle qui fonctionne le mieux, car IntStream nous permet d’éviter toutes les opérations d’auto-boxing, ou les conversions implicites d’Integer en int, qui sont inutiles dans ce cas.

Ensuite, prenez le temps de tester votre compréhension de la façon dont la réduction peut être utilisée comme une généralisation d’autres collecteurs en travaillant à travers l’exercice du questionnaire 6.1.

Quiz 6.1: Joindre des String avec reducing

Lesquels des énoncés suivants utilisant le collecteur reducing sont des remplacements valides pour l’opération joining (tel qu’utilisé à la section 6.2.3) ?

1.String shortMenu = menu.stream().Map(Dish :: getName).collect(réducing ((s1, s2) -> s1 + s2)) .get ();

2.String shortMenu = menu.stream().collect(réducing ((d1, d2) -> d1.getName () + d2.getName())) .get();

3.String shortMenu = menu.stream().collect (réducing («  », Dish :: getName, (s1, s2) -> s1 + s2));

Réponse:

Les déclarations 1 et 3 sont valides, alors que 2 ne compile pas.

  1. Celle-ci convertit chaque plat en son nom, comme fait lors de l’introduction de la méthode joining, puis réduit le flux de String résultant, en utilisant une String comme accumulateur et en y ajoutant les noms des plats un par un.
  2. Celle-ci ne compile pas parce que le seul argument que la réduction accepte est un BinaryOperator <T> qui est une BiFunction <T, T, T>. Cela signifie qu’il veut une fonction prenant deux arguments et renvoie une valeur du même type, mais l’expression lambda utilisée ici a deux plats comme arguments mais renvoie une String.
  3. Celle-ci commence le processus de réduction avec une String vide comme accumulateur, et lors de la traversée du flux de plats, il convertit chaque plat à son propre nom et ajoute ce nom à l’accumulateur. Notez que, comme nous l’avons mentionné, la réduction n’a pas besoin des trois arguments pour retourner une Optional car dans le cas d’un flux vide, elle peut renvoyer une valeur plus significative, qui est une String vide utilisée comme valeur initiale de l’accumulateur.

Notez que même si les instructions 1 et 3 sont des remplacements valides pour joining, elles ont été utilisées ici pour montrer comment reducing peut être vue, au moins conceptuellement, comme une généralisation de tous les autres collecteurs abordés dans ce chapitre. Néanmoins, à toutes fins pratiques, nous suggérons toujours d’utiliser le collecteur joining pour des raisons de lisibilité et de performance.



6.3. Regroupement

Une opération de base de données commune consiste à regrouper des éléments dans un ensemble, en fonction d’une ou de plusieurs propriétés. Comme vous l’avez vu dans l’exemple précédent de transactions-currency-grouping, cette opération peut être fastidieuse, verbeuse et sujette aux erreurs lorsqu’elle est implémentée avec un style impératif. Mais elle peut être facilement traduit en une seule déclaration très lisible en la réécrivant dans un style plus fonctionnel comme encouragé par Java 8. Comme deuxième exemple de comment cette fonctionnalité peut être utilisée, supposons que vous voulez classer les plats dans le menu en fonction de leur type, en mettant ceux qui contiennent de la viande dans un groupe, ceux avec du poisson dans un autre groupe, et tous les autres dans un troisième groupe. Vous pouvez facilement effectuer cette tâche à l’aide d’un collecteur renvoyé par la méthode Collectors.groupingBy comme ceci :

Cela produira la Map suivante :

Ici, vous passez à la méthode groupingBy une fonction (exprimée sous la forme d’une référence de méthode) qui extrait le type du plat de chaque élément dans le flux. Nous appelons cette méthode une fonction de classification car elle est utilisée pour classer les éléments du flux dans différents groupes. Le résultat de cette opération de regroupement, représenté sur la figure 6.4, est une Map ayant comme clé, la valeur renvoyée par la fonction de classification et comme valeur de map correspondante une liste de tous les éléments du flux ayant cette valeur classifiée. Dans l’exemple de menu-classification, la clé est le type de plat, et la valeur est une liste contenant tous les plats de ce type.

Mais il n’est pas toujours possible d’utiliser une référence de méthode comme fonction de classification, car vous souhaiterez peut-être classer en utilisant quelque chose de plus complexe qu’un simple accesseur de propriété. Par exemple, vous pouvez décider de classer comme « régime » tous les plats avec 400 calories ou moins, et « normal » les plats ayant entre 400 et 700 calories, et mettre à «gras» ceux avec plus de 700 calories. Parce que l’auteur de la classe Dish n’a pas fourni une telle opération en tant que méthode, vous ne pouvez pas utiliser une référence de méthode dans ce cas, mais vous pouvez exprimer cette logique dans une expression lambda :

Maintenant, vous avez vu comment regrouper les plats dans le menu, à la fois par leur type et par les calories, mais que se passe-t-il si vous voulez utiliser les deux critères en même temps ? Le regroupement est puissant car il compose efficacement. Voyons voir comment faire cela.

6.3.1. Groupement à plusieurs niveaux

Vous pouvez obtenir un regroupement multiniveau en utilisant un collecteur créé avec une version à deux arguments de la méthode Collectors.groupingBy, qui accepte un second argument de type collector en plus de la fonction de classification habituelle. Ainsi, pour effectuer un regroupement à deux niveaux, vous pouvez passer un groupingBy interne au groupingBy externe, en définissant un critère de second niveau pour classer les éléments du flux, comme indiqué dans la figure suivante :

 

Le résultat de ce regroupement à deux niveaux est une Map à deux niveaux comme celle-ci:

Ici la Map externe a comme clés les valeurs générées par la fonction de classification de premier niveau : « poisson, viande, autre ». Les valeurs de cette Map sont à leur tour d’autres Map, ayant comme clés les valeurs générées par la fonction de classification de second niveau. : « Normal, diet, or fat. » Enfin, les Maps de second niveau ont comme valeurs la liste des éléments du flux retournant les valeurs de clés de premier et de second niveau correspondantes respectivement appliquées aux première et seconde fonction de classification : « Saumon, pizza, etc. » Cette opération de regroupement multiniveau peut être étendue à un nombre quelconque de niveaux, et un regroupement à n niveaux a pour résultat une map à n niveaux modélisant une structure arborescente à n niveaux.

La figure 6.5 montre comment cette structure est également équivalente à une table à n dimensions, mettant en évidence le but de classification de l’opération de regroupement.

En général, il est utile de penser que le regroupement fonctionne en termes de «s eaux ». Le premier groupeBy crée un seau pour chaque clé. Vous collectez ensuite les éléments dans chaque seau avec le collecteur en aval et ainsi de suite pour obtenir des regroupements à n niveaux.

6.3.2. Collecte des données dans les sous-groupes

Dans la section précédente, vous avez vu qu’il est possible de passer un deuxième groupingBy à celui à l’extérieur pour réaliser un regroupement multiniveau. Mais plus généralement, le deuxième collecteur passé au premier groupingBy peut être n’importe quel type de collecteur, pas seulement un autre groupingBy. Par exemple, il est possible de compter le nombre de plats dans le menu pour chaque type, en passant le collecteur counting comme deuxième argument au collecteur groupingBy :

Le résultat est la Map suivante:

Notez également que le groupingBy(f), à un argument régulier, où f est la fonction de classification, est en réalité un raccourci pour groupingBy (f, toList()).

Pour donner un autre exemple, vous pouvez retravailler le collecteur que vous avez déjà utilisé pour trouver le plat le plus calorique dans le menu pour obtenir un résultat similaire, mais maintenant classé par type de plat :

Le résultat de ce regroupement est alors clairement une Map, ayant comme clés les types de plats disponibles et comme valeur un Optional<Dishes>, enveloppant le plat le plus calorique correspondant pour un type donné :



Les valeurs dans cette Map sont des Optionals car c’est le type résultant du collecteur généré par la méthode maxBy, mais en réalité s’il n’y a pas de plats dans le menu pour un type donné, ce type n’aura pas de Optional.empty() comme valeur ; il ne sera pas présent du tout comme une clé dans la Map. Le collecteur groupingBy ajoute paresseusement une nouvelle clé dans la Map de regroupement seulement la première fois qu’il trouve un élément dans le flux, produisant cette clé en y appliquant les critères de regroupement utilisés. Cela signifie que dans ce cas, l’encapsuleur optionnel n’est pas très utile, car il ne modélise pas une valeur qui pourrait éventuellement être absente. Sauf que c’est le type retourné par le collecteur réducteur. Donc on n’a pas tellement le choix.



Étant donné que les Optional qui enveloppent toutes les valeurs de la Map résultant de la dernière opération de regroupement ne sont pas très utiles dans ce cas, vous souhaiterez peut-être vous en débarrasser. Pour ce faire, vous pouvez utiliser le collecteur renvoyé par la méthode Collectors.collectingAndThen, comme indiqué dans la liste suivante.

Cette méthode prend deux arguments, le collecteur à adapter et une fonction de transformation, et renvoie un autre collecteur. Ce collecteur supplémentaire agit comme un wrapper pour l’ancien et mappe la valeur qu’il renvoie en utilisant la fonction de transformation en tant que dernière étape de l’opération de collecte. Dans ce cas, le collecteur enveloppé est maxBy, et la fonction de transformation, Optional:: get, elle extrait la valeur contenue dans l’Optional retournée. Comme nous l’avons dit, ici c’est sûr que l’Optional contient une valeur, car le collecteur maxBy ne retournera jamais un Optional.empty(). Le résultat est la map suivante :

Il est assez courant d’utiliser plusieurs collecteurs imbriqués. Au début, la façon dont ils interagissent n’est pas toujours évidente. La figure 6.6 vous aide à visualiser comment ils travaillent ensemble. De la couche la plus à l’extérieur en se déplaçant vers l’intérieur :

  • Les collecteurs sont représentés par les lignes pointillées, donc groupingBy est le plus externe et regroupe le flux de menu en trois sous-flux selon les différents types de plats.
  • Le collecteur groupingBy enveloppe le collecteur collectingAndThen, de sorte que chaque sous-flux résultant de l’opération de regroupement soit encore réduit par ce second collecteur.
  • Le collecteur CollectionAndThen enveloppe à son tour un troisième collecteur, le maxBy.
  • L’opération de réduction sur les sous-flux est ensuite exécutée par le collecteur de réduction, mais le collecteur collectiongAndThen qui le contient applique la fonction de transformation Optional :: get à son résultat.
  • Les trois valeurs transformées, étant les Plats les plus caloriques pour un type donné, seront les valeurs associées aux clés de classification respectives, les types de Plats, dans la map renvoyé par le collecteur groupingBy.

D’autres exemples de collecteurs utilisés conjointement avec grouping.

Plus généralement, le collecteur passé en second argument à la méthode groupingBy, sera utilisée pour effectuer une opération de réduction supplémentaire sur tous les éléments du flux classés dans le même groupe. Par exemple, vous pouvez également réutiliser le collecteur créé pour additionner les calories de tous les plats du menu afin d’obtenir un résultat similaire, mais cette fois pour chaque groupe de plats :

Un autre collecteur, communément utilisé avec groupingBy, est celui qui est généré par la méthode de mapping. Cette méthode prend deux arguments : une fonction transformant les éléments dans un flux et un autre collecteur accumulant les objets résultant de cette transformation. Son but est d’adapter un collecteur acceptant des éléments d’un type donné à un autre collecteur travaillant sur des objets d’un type différent, en appliquant une fonction de mapping, à chaque élément d’entrée, avant de les accumuler. Pour voir un exemple pratique d’utilisation de ce collecteur, supposons que vous vouliez savoir quels CaloricLevels sont disponibles dans le menu pour chaque type de Plats. Vous pouvez obtenir ce résultat en combinant groupingBy et un collecteur de mapping comme ceci :

Ici, la fonction de transformation transmise à la méthode de mapping associe un Plat à son CaloricLevel, comme vous l’avez vu précédemment. Le flux résultant de CaloricLevels est ensuite transmis à un collecteur toSet, analogue à celui de toList, mais accumulant les éléments d’un flux dans un ensemble(Set) plutôt que dans une liste, pour ne garder que les valeurs distinctes. Comme dans les exemples précédents, ce collecteur de mapping sera ensuite utilisé pour collecter les éléments de chaque sous-flux généré par la fonction de regroupement, ce qui vous permettra d’obtenir la map suivante :

Ici, vous pouvez facilement comprendre vos choix. Si vous voulez du poisson et que vous suivez un régime, vous pouvez facilement trouver un plat ; De même, si vous avez très faim et que vous voulez quelque chose avec beaucoup de calories, vous pouvez satisfaire votre appétit robuste en choisissant quelque chose dans la section viande du menu. Notez que dans l’exemple précédent, il n’y a aucune garantie sur le type de Set retourné. Mais en utilisant toCollection, vous pouvez avoir plus de contrôle. Par exemple, vous pouvez demander un HashSet en lui passant une référence de constructeur :

6.4. Partitionnement

Le partitionnement est un cas particulier du regroupement : avec un prédicat (une fonction renvoyant un booléen), appelée fonction de partitionnement, en tant que fonction de classification. Le fait que la fonction de partitionnement renvoie un booléen signifie que la Map de regroupement qui en résulte aura un booléen comme type de clé et qu’il peut donc y avoir au plus deux groupes différents – un pour vrai et un autre pour faux. Par exemple, si vous êtes végétarien ou avez invité un ami non-végétarien à dîner avec vous, vous pourriez vouloir partager le menu en plats végétariens et non-végétariens :

Cela retournera la Map suivante :

Ainsi, vous pouvez récupérer tous les plats végétariens en obtenant à partir de cette Map les valeurs indexées avec la clé true :

Notez que vous pouvez obtenir le même résultat en filtrant simplement le flux créé à partir du menu Liste avec le même prédicat que celui utilisé pour le partitionnement, puis en collectant le résultat dans une liste supplémentaire :

6.4.1. Avantages du partitionnement

Le partitionnement a l’avantage de conserver les deux listes des éléments de flux, pour lesquels l’application de la fonction de partitionnement renvoie true ou false. Ainsi, dans l’exemple précédent, vous pouvez obtenir la liste des plats non-végétariens en accédant à la valeur de la clé false dans la table partitionedMenu, en utilisant deux opérations de filtrage distinctes: une avec le prédicat et une avec sa négation. En outre, comme vous l’avez déjà vu pour le regroupement, la méthode d’installation partitioningBy comporte une version surchargée à laquelle vous pouvez passer un second collecteur, comme illustré ici:

Cela produira une Map à deux niveaux:

Ici, le regroupement des plats par leur type est appliqué individuellement aux deux sous-flux de plats végétariens et non-végétariens résultant du partitionnement, produisant ainsi une Map à deux niveaux semblables à celle que vous avez obtenue lorsque vous avez effectué le regroupement à deux niveaux dans la section 6.3.1. Comme autre exemple, vous pouvez réutiliser votre code précédent pour trouver le plat le plus calorique parmi les plats végétariens et non-végétariens :

Cela produira le résultat suivant:

Nous avons commencé cette section en disant que vous pouvez considérer le partitionnement comme un cas particulier de regroupement. Les analogies entre les collecteurs groupingBy et partitioningBy ne s’arrêtent pas là ; Comme vous le verrez dans le quiz suivant, vous pouvez également effectuer un partitionnement à plusieurs niveaux de manière similaire à ce que vous avez fait pour le regroupement dans la section 6.3.1.



Quiz 6.2: Utilisation de partitioningBy

Comme vous l’avez vu, comme le collecteur groupingBy, le collecteur partitioningBy peut être utilisé en combinaison avec d’autres collecteurs. En particulier, il pourrait être utilisé avec un second collector partitioningBy pour réaliser un partitionnement multi-niveau. Quel sera le résultat des partitionnements multiniveaux suivants ?

1.menu.stream (). collecter (partitioningBy (Dish :: isVegetarian,partitioningBy (d -> d.getCalories ()> 500)));

2.menu.stream (). collecter (partitioningBy (Dish :: isVegetarian,partitioningBy (Dish :: getType)));

3.menu.stream (). collecter (partitioningBy (Dish :: isVegetarian,counting()));

Réponse :

  1. Il s’agit d’un partitionnement multiniveau valide, produisant la Map à deux niveaux suivante :

{false = {false = [poulet, crevettes, saumon], vrai = [porc, boeuf]},
true = {false = [riz, fruits de saison], true = [frites, pizza]}}

  1. Ceci ne compilera pas car partitioningBy requiert un prédicat, une fonction renvoyant un booléen. Et la méthode de référence Dish :: getType ne peut pas être utilisée comme prédicat.
  2. Cela compte le nombre d’éléments dans chaque partition, résultant en la Map suivante : {faux = 5, vrai = 4}


Pour donner un dernier exemple de la façon dont vous pouvez utiliser le collecteur partitioningBy, nous allons mettre de côté le modèle de données de menu et regarder quelque chose d’un peu plus complexe mais aussi plus intéressant : partitionner les nombres en nombres premiers et non premiers.

6.4.2. Partitionner une liste de nombres en premiers et non premiers

Supposons que vous souhaitiez écrire une méthode acceptant comme argument un int n et partitionnant les n premiers nombres naturels en nombres premiers et non premiers. Mais d’abord, il sera utile de développer un prédicat qui teste si un nombre candidat donné est premier ou non :

Une optimisation simple consiste à tester uniquement les facteurs inférieurs ou égaux à la racine carrée du candidat :

Maintenant, la plus grande partie du travail est terminée. Pour partitionner les n premiers nombres en nombres premiers et non premiers, il suffit de créer un flux contenant ces n nombres et de le réduire avec un collecteur partitioningBy en utilisant comme prédicat la méthode isPrime que vous venez de développer :

Nous avons maintenant couvert tous les collecteurs qui peuvent être créés en utilisant les méthodes statiques de la classe Collectors, en montrant des exemples pratiques de leur fonctionnement. Le tableau 6.1 les rassemble tous, avec le type qu’ils retournent lorsqu’ils sont appliqués à un Stream <T> et un exemple pratique de leur utilisation sur un Stream<Dish> nommé menuStream.

Comme nous l’avons mentionné au début du chapitre, tous ces collecteurs implémentent l’interface Collector, donc dans la partie restante du chapitre, nous étudions cette interface plus en détail. A savoir les méthodes dans cette interface et explorerons ensuite comment vous pouvez implémenter vos propres collecteurs.

6.5. L’interface Collector

L’interface Collector consiste en un ensemble de méthodes qui fournissent un plan d’implémentation d’opérations de réduction spécifiques (c’est-à-dire des collecteurs). Vous avez vu de nombreux collecteurs qui implémentent l’interface Collector, tels que toList ou groupingBy. Cela implique également que vous êtes libre de créer des opérations de réduction personnalisées en fournissant votre propre implémentation de l’interface Collector. Dans la section 6.6, nous montrerons comment vous pouvez implémenter l’interface Collector pour créer un collecteur afin de partitionner un flux de nombres en nombres premiers et non-premier plus efficacement que ce que vous avez vu jusqu’ici.

Pour commencer avec l’interface Collector, nous nous intéressons à l’un des premiers collecteurs que vous avez rencontrés au début de ce chapitre : la méthode toList, qui rassemble tous les éléments d’un flux dans une liste. Nous avons dit que vous utiliserez fréquemment ce collecteur dans votre travail quotidien, mais c’est aussi un concept qui, au moins sur le plan conceptuel, est facile à développer. Étudier plus en détail la façon dont ce collecteur est implémenté est un bon moyen de comprendre comment l’interface Collector est définie et comment les fonctions renvoyées par ses méthodes sont utilisées en interne par la méthode collect.

Commençons par regarder la définition de l’interface Collector dans la figure suivante, qui montre la signature de l’interface avec les cinq méthodes qu’elle déclare.

Dans cette figure, les définitions suivantes s’appliquent :

T est le type générique des éléments du flux à collecter.
A est le type de l’accumulateur, l’objet sur lequel le résultat partiel sera accumulé au cours du processus de collecte.
R est le type de l’objet (généralement, mais pas toujours, la collection) résultant de l’opération de collecte.

Par exemple, vous pouvez implémenter une classe ToListCollector <T> qui rassemble tous les éléments d’un Stream <T> dans une List <T> ayant la signature suivante :

Où, comme nous le verrons bientôt, l’objet utilisé pour le processus d’accumulation sera également le résultat final du processus de collecte.

6.5.1. Donner du sens aux méthodes déclarées par l’interface Collector

Nous pouvons maintenant analyser une à une les cinq méthodes déclarées par l’interface Collector. Vous remarquerez que chacune des quatre premières méthodes retourne une fonction qui sera invoquée par la méthode collect, alors que la cinquième, characteristics, fournit un ensemble de caractéristiques qui est une liste d’indices utilisés par la méthode collect pour savoir quelles optimisations (par exemple, la parallélisation) elle est autorisée à utiliser lors de l’exécution de l’opération de réduction.

Faire un nouveau conteneur de résultats : la méthode supplier

La méthode supplier doit renvoyer un Supplier d’un résultat vide – une fonction sans paramètre qui, lorsqu’elle est invoquée, crée une instance d’un accumulateur vide utilisé pendant le processus de collecte. Clairement, pour un collecteur renvoyant l’accumulateur lui-même comme résultat, comme notre ToListCollector, cet accumulateur vide représentera également le résultat du processus de collecte lorsqu’il est exécuté sur un flux vide. Dans notre ToListCollector, la méthode supplier() retournera une liste vide comme ceci :

Notez que vous pouvez également passer une référence de constructeur:

Ajouter un élément à un conteneur de résultats: la méthode accumulateur

La méthode accumulator renvoie la fonction qui effectue l’opération de réduction. Lors de la traversée du nième élément du flux, cette fonction est appliquée avec deux arguments : l’accumulateur étant le résultat de la réduction (après avoir recueilli les premiers n-1 éléments du flux) et le nième élément lui-même. La fonction renvoie void car l’accumulateur est modifié sur place (lors de l’exécution de la méthode), ce qui signifie que son état interne est modifié par l’application de fonction sur l’élément traversé. Pour ToListCollector, cette fonction doit simplement ajouter l’élément en cours à la liste contenant les éléments déjà traversés :

Vous pourriez plutôt utiliser une référence de méthode, plus concise:

Application de la transformation finale au conteneur de résultats: la méthode finisher

Après avoir complètement traversé le flux, la méthode finisher doit renvoyer une fonction qui sera invoquée à la fin du processus d’accumulation, afin de transformer l’objet accumulateur en résultat final. Souvent, comme dans le cas de ToListCollector, l’objet accumulateur coïncide déjà avec le résultat final attendu. En conséquence, il n’y a pas besoin d’effectuer une transformation, donc la méthode de finition doit simplement retourner la fonction d’identité :

Ces trois premières méthodes sont suffisantes pour exécuter une réduction séquentielle du flux qui, du moins d’un point de vue logique, pourrait se dérouler comme dans la figure 6.7. Les détails de mise en œuvre sont un peu plus difficiles en pratique en raison à la fois de la nature paresseuse du flux, qui pourrait nécessiter un pipeline d’autres opérations intermédiaires à exécuter avant l’opération collect, et de la possibilité d’effectuer la réduction en parallèle.

Fusion de deux conteneurs de résultats: la méthode combiner

La méthode combiner, la dernière des quatre méthodes qui renvoient une fonction utilisée par l’opération de réduction. Elle définit comment les accumulateurs résultant de la réduction des différentes sous-parties du flux sont combinés lorsque les sous-parties sont traitées en parallèle. Dans le cas de toList, l’implémentation de cette méthode est simple ; il suffit d’ajouter la liste contenant les éléments recueillis de la deuxième sous-partie du flux à la fin de la liste obtenue lors de la traversée de la première sous-partie :

L’ajout de cette quatrième méthode permet une réduction parallèle du flux. Elle utilise le framework fork / join introduit dans Java 7 et l’abstraction de Spliterator que vous découvrirez dans le prochain chapitre. Il suit un processus similaire à celui de la figure 6.8 et décrit en détail ici :

  • Le flux original est divisé récursivement en sous-flux jusqu’à ce qu’une condition définissant si un flux doit être divisé devient fausse (le calcul parallèle est souvent plus lent que le calcul séquentiel lorsque les unités de travail distribuées sont trop petites et il est inutile de générer plus tâches que vous avez de processeurs).
  • À ce stade, tous les sous-flux peuvent être traités en parallèle, chacun d’entre eux utilisant l’algorithme de réduction séquentielle illustré à la figure 6.7.

Enfin, tous les résultats partiels sont combinés par paire en utilisant la fonction renvoyée par la méthode combiner du collecteur. Ceci est fait en combinant les résultats correspondant aux sous-flux associés à chaque division du flux original.

Méthode characteristics

La dernière méthode, characteristics, renvoie un ensemble immuable de Characteristics, définissant le comportement du collecteur, en fournissant notamment des indications sur la possibilité de réduire le flux en parallèle et quelles optimisations sont valides à cet effet. Les caractéristiques sont une énumération contenant trois éléments :

UNORDERED : le résultat de la réduction n’est pas affecté par l’ordre dans lequel les éléments du flux sont traversés et accumulés.
CONCURRENT : la fonction accumulateur peut être appelée simultanément à partir de plusieurs threads, ensuite ce collecteur peut effectuer une réduction parallèle du flux. Si le collecteur n’est pas également marqué comme UNORDERED, il ne peut effectuer une réduction parallèle uniquement lorsqu’il est appliqué à une source de données non ordonnée.
IDENTITY_FINISH: indique que la fonction renvoyée par la méthode de finition est celle de l’identité et que son application peut être omise. Dans ce cas, l’objet accumulateur est directement utilisé comme résultat final du processus de réduction. Cela implique également que c’est sans danger de faire un cast de l’accumulateur A au résultat R.

Le ToListCollector développé jusqu’à présent est de type IDENTITY_FINISH, car la liste utilisée pour accumuler les éléments dans le flux est déjà le résultat final attendu et n’a pas besoin de transformation supplémentaire, mais il n’est pas de type UNORDERED étant donné que si vous l’appliquez à un flux ordonné vous souhaitez que cet ordre soit conservé dans la liste résultante. Et enfin, il est de type CONCURRENT, mais suite à ce que nous venons de dire, le flux ne sera traité en parallèle que si la source de données sous-jacente n’est pas ordonnée.

6.5.2. Mettre toutes les méthodes ensemble

Les cinq méthodes analysées dans la sous-section précédente sont tout ce dont vous avez besoin pour développer votre propre ToListCollector, vous pouvez donc l’implémenter en les regroupant, comme le montre la liste suivante. Le code du collecteur personnalisé, ToListCollectore est disponible sur github.

Notez que cette implémentation n’est pas identique à celle renvoyée par la méthode Collectors. toList, mais qu’elle ne diffère que dans certaines optimisations mineures. Ces optimisations sont principalement liées au fait que le collecteur fourni par l’API Java utilise le singleton Collections.emptyList() lorsqu’il doit renvoyer une liste vide. Cela signifie qu’il pourrait être utilisé en toute sécurité à la place du collecteur Java original (toList()):

La différence restante entre ceci et la norme:

est que toList est une méthode statique fournie, alors que vous devez utiliser new pour instancier votre ToListCollector.

Effectuer une collecte personnalisée sans créer d’implémentation de collecteur

Dans le cas d’une opération de collecte IDENTITY_FINISH, il existe une autre possibilité d’obtenir le même résultat sans développer une implémentation complètement nouvelle de l’interface Collector. Stream a une méthode de collecte surchargée acceptant les trois autres fonctions – supplier, accumulator et combiner – ayant exactement la même sémantique que celles renvoyées par les méthodes correspondantes de l’interface du collecteur. Ainsi, par exemple, il est possible de rassembler dans une liste tous les éléments d’un flux de plats comme ceci :

 

Nous croyons que cette seconde forme, même si elle est plus compacte et concise que l’ancienne, est moins lisible. En outre, le développement d’une implémentation de votre collecteur personnalisé dans une classe appropriée favorise sa réutilisation et évite la duplication de code. Il est également important de noter que vous n’êtes pas autorisé à transmettre des characteristics à cette deuxième méthode collect, donc elle se comportera toujours comme un collecteur IDENTITY_FINISH et CONCURRENT mais pas UNORDERED.

Dans la section suivante, vous passerez au niveau supérieur dans la mise en œuvre des collecteurs. Vous développerez votre propre collecteur personnalisé pour un cas d’utilisation plus complexe mais, espérons-le, plus spécifique et convaincant.

6.6. Développer votre propre collecteur pour de meilleures performances

Dans la section 6.4, où nous avons discuté du partitionnement, vous avez créé un collecteur en utilisant l’une des nombreuses méthodes fournies par la classe Collectors, qui divise les n premiers nombres naturels en nombres premiers et non premiers, comme indiqué dans la figure suivante.

Là vous avez réalisé une amélioration par rapport à la méthode isPrime originale en limitant le nombre de diviseurs à uniquement ceux qui ne sont pas plus grands que la racine carrée du nombre reçu en paramètre (le candidat) :

Y a-t-il un moyen d’obtenir des performances encore meilleures ? La réponse est oui, mais pour cela, vous devrez développer un collecteur personnalisé.

6.6.1. Diviser uniquement par les nombres premiers

Une optimisation possible consiste à tester uniquement si le nombre candidat est divisible par des nombres premiers. Il est inutile de le tester contre un diviseur qui n’est pas lui-même premier. Vous pouvez donc limiter le test aux seuls nombres premiers trouvés avant le candidat actuel. Le problème avec les collecteurs prédéfinis que vous avez utilisés jusqu’à présent, et la raison pour laquelle vous devez en développer un personnalisé, est que pendant le processus de collecte, vous n’avez pas accès au résultat partiel. Cela signifie que lorsque vous testez si un nombre de candidats donné est premier ou non, vous n’avez pas accès à la liste des autres nombres premiers trouvés jusqu’à présent.

Supposons que vous avez cette liste ; vous pouvez la passer à la méthode isPrime et la réécrire comme ceci:

En outre, vous devez implémenter la même optimisation que vous avez utilisée auparavant et tester uniquement avec des nombres premiers inférieurs à la racine carrée du nombre candidat. Il vous faut donc un moyen d’arrêter de tester si le candidat est divisible par un nombre premier dès que le nombre premier suivant est supérieur à la racine du candidat. Malheureusement, cette méthode n’est pas disponible dans l’API Streams. Vous pouvez utiliser filter (p -> p <= candidateRoot) pour filtrer les nombres premiers inférieurs à la racine candidate. Mais le filtre traiterait l’ensemble du flux avant de renvoyer le flux adéquat. Si la liste des nombres premiers et le nombre candidate étaient très importants, cela poserait problème. Vous n’avez pas besoin de faire cela. Tout ce que vous voulez, c’est arrêter une fois que vous avez trouvé un nombre premier supérieur à la racine du candidat. Par conséquent, vous allez créer une méthode appelée takeWhile, qui, étant donné une liste triée et un prédicat, renvoie le préfixe le plus long de cette liste dont les éléments satisfont le prédicat:

En utilisant cette méthode, vous pouvez optimiser la méthode isPrime en testant uniquement le candidat avec seulement les nombres premiers qui ne sont pas plus grands que sa racine carrée :

Notez qu’il s’agit d’une implémentation enthousiaste de takeWhile. Idéalement, vous voudriez une version paresseuse de takeWhile afin qu’elle puisse être fusionnée avec l’opération noneMatch. Malheureusement, sa mise en œuvre dépasserait le cadre de ce chapitre car vous auriez besoin de mieux maîtriser l’implémentation de l’API Streams.

Avec cette nouvelle méthode isPrime, vous êtes maintenant prêt à implémenter votre propre collecteur personnalisé. D’abord, vous devez déclarer une nouvelle classe qui implémente l’interface Collector. Ensuite, vous devez développer les cinq méthodes requises par l’interface Collector.

Étape 1 : Définition de la signature de la classe Collector

Commençons par la signature de la classe, en rappelant que l’interface Collector est définie comme

où T, A et R sont respectivement le type des éléments dans le flux, le type de l’objet utilisé pour accumuler des résultats partiels et le type du résultat final de l’opération de collecte. Dans ce cas, vous souhaitez collecter des flux d’entiers alors que l’accumulateur et les résultats sont tous deux des éléments de type Map <Boolean, List <Integer> (la même Map que vous avez obtenue suite à l’opération de partition précédente dans la liste 6.6). On aura true et false en tant que clé et en tant que valeurs, on aura respectivement les listes des nombres premiers et non-premiers :

Ensuite, vous devez implémenter les cinq méthodes déclarées dans l’interface Collector. La méthode supplier doit renvoyer une fonction qui, lorsqu’elle est appelée, crée l’accumulateur :

Ici, vous créez non seulement la Map que vous utiliserez comme accumulateur, mais vous l’initialisez également avec deux listes vides sous les clés vraies et fausses. C’est ici que vous allez ajouter respectivement les nombres premiers et non premiers au cours du processus de collecte. La méthode la plus importante de votre collecteur est la méthode accumulator, car elle contient la logique définissant la façon dont les éléments du flux doivent être collectés. C’est également la clé de la mise en œuvre de l’optimisation décrite précédemment. A n’importe quelle itération donnée, vous pouvez maintenant accéder au résultat partiel du processus de collecte, qui est l’accumulateur contenant les nombres premiers trouvés jusqu’à présent :

Dans cette méthode, vous appelez la méthode isPrime, en lui passant (avec le nombre que vous voulez tester) aussi la liste des nombres premiers trouvés jusqu’à présent (ce sont les valeurs associées à la clé true dans la map accumulée). Le résultat de cette invocation est ensuite utilisé comme clé pour obtenir la liste des nombres premiers ou non premiers afin que vous puissiez ajouter le nouveau candidat à la bonne liste.

 

Étape 3 : Faire fonctionner le collecteur en parallèle (si possible)

La méthode suivante doit combiner deux accumulateurs partiels dans le cas d’un processus de collecte parallèle. Dans ce cas, il suffit de fusionner les deux Map en ajoutant tous les nombres des listes premiers et non premiers de la seconde Map aux listes correspondantes dans la première Map:

Notez qu’en réalité, ce collecteur ne peut pas être utilisé en parallèle, car l’algorithme est intrinsèquement séquentiel. Cela signifie que la méthode combiner ne sera jamais invoquée et que vous pourriez laisser son implémentation vide (ou mieux, lancer une exception UnsupportedOperation). Nous avons décidé de l’implémenter de toute façon uniquement pour être complet.

Étape 4 : La méthode finisher et la méthode characteristic du collecteur

L’implémentation des deux dernières méthodes est assez simple : comme nous l’avons dit, l’accumulateur coïncide avec le résultat du collecteur, donc il n’aura pas besoin de transformation supplémentaire. La méthode finisher renvoie donc la fonction d’identité :

En ce qui concerne la méthode characteristic, nous avons déjà dit que ce n’est ni CONCURRENT, ni UNORDERED, mais IDENTITY_FINISH :

La liste suivante montre l’implémentation finale de PrimeNumbersCollector.

Vous pouvez maintenant utiliser ce nouveau collecteur personnalisé à la place de l’ancien créé avec la méthode partitioningBy dans la section 6.4 et obtenir exactement le même résultat :

6.6.2. Comparer les performances des collecteurs

Le collecteur créé avec la méthode de partitioningBy et celui personnalisé que vous venez de développer sont fonctionnellement identiques, mais avez-vous atteint votre objectif d’améliorer les performances du partitionningBy collector avec le votre ? Écrivons un bout de code pour vérifier ceci :

Notez qu’une approche plus scientifique de l’analyse comparative consisterait à utiliser un cadre tel que JMH, mais nous ne voulions pas rajouter de la complexité avec l’utilisation d’un tel framework ici et, pour ce cas d’utilisation, les résultats fournis par cette petite classe d’étalonnage sont assez. Cette classe partitionne le premier million de nombres naturels en nombres premiers et non-premiers, en exécutant le collecteur créé avec la méthode partitioningBy, 10 fois et en enregistrant l’exécution la plus rapide. En l’exécutant sur un Intel i5 2.4 GHz, il imprime le résultat suivant :

Maintenant, remplacez partitionPrimes par partitionPrimesWithCustomCollector dans le code, afin de tester les performances du collecteur personnalisé que vous avez développé. Maintenant, le programme imprime :

Pas mal. Cela signifie que vous n’avez pas perdu de temps à développer ce collecteur personnalisé pour deux raisons : d’abord, vous avez appris à implémenter votre propre collecteur, et deuxièmement, vous avez amélioré vos performances d’environ 32%.

Enfin, il est important de noter que, comme vous l’avez fait pour ToListCollector dans la liste 6.5, il est possible d’obtenir le même résultat en passant les trois fonctions implémentant la logique principale de PrimeNumbersCollector à la version surchargée de la méthode collect, en les passant comme arguments :

Comme vous pouvez le voir, vous pouvez éviter de créer une classe complètement nouvelle qui implémente l’interface Collector. Le code résultant est plus compact, même s’il est probablement moins lisible et certainement moins réutilisable.

6.7. Résumé

Voici les concepts clés que vous devriez retenir de ce chapitre :

  • Collect est une opération terminale qui prend comme argument diverses recettes (appelées collecteurs) pour accumuler les éléments d’un flux dans un résultat récapitulatif.
  • Les collecteurs prédéfinis incluent la réduction et la récapitulation des éléments de flux en une seule valeur, telle que le calcul du minimum, du maximum ou de la moyenne. Ces collecteurs sont résumés dans le tableau 6.1.
  • Les collecteurs prédéfinis permettent de regrouper les éléments d’un flux avec les éléments groupingBy et de partitionner un flux avec partitioningBy.
  • Les collecteurs se composent efficacement pour créer des regroupements, des partitions et des réductions à plusieurs niveaux.
  • Vous pouvez développer vos propres collecteurs en implémentant les méthodes définies dans l’interface Collector.