web-dev-qa-db-fra.com

Pourquoi les Java Streams sont-ils uniques?

Contrairement à IEnumerable de C #, où un pipeline d'exécution peut être exécuté autant de fois que nous le souhaitons, dans Java, un flux ne peut être "itéré" qu'une seule fois.

Tout appel à une opération de terminal ferme le flux, le rendant inutilisable. Cette "fonctionnalité" enlève beaucoup de pouvoir.

J'imagine que la raison en est pas technique. Quelles sont les considérations de conception derrière cette restriction étrange?

Edit: afin de démontrer de quoi je parle, considérons l'implémentation suivante de Quick-Sort en C #:

IEnumerable<int> QuickSort(IEnumerable<int> ints)
{
  if (!ints.Any()) {
    return Enumerable.Empty<int>();
  }

  int pivot = ints.First();

  IEnumerable<int> lt = ints.Where(i => i < pivot);
  IEnumerable<int> gt = ints.Where(i => i > pivot);

  return QuickSort(lt).Concat(new int[] { pivot }).Concat(QuickSort(gt));
}

Maintenant, pour être sûr, je ne préconise pas qu'il s'agisse d'une bonne mise en œuvre rapide! C'est cependant un excellent exemple du pouvoir expressif de l'expression lambda associée au fonctionnement du flux.

Et cela ne peut pas être fait en Java! Je ne peux même pas demander à un flux s'il est vide sans le rendre inutilisable.

229
Vitaliy

Je me souviens de la conception initiale de l’API de Streams qui pourrait nous éclairer sur les raisons de la conception.

En 2012, nous ajoutions lambdas au langage et nous souhaitions un ensemble d'opérations axées sur les collections ou "données en bloc", programmées à l'aide de lambdas, qui facilitent le parallélisme. L'idée de chaîner paresseusement les opérations ensemble était bien établie par ce point. Nous ne voulions pas non plus que les opérations intermédiaires stockent les résultats.

Les principaux problèmes que nous devions décider étaient de savoir à quoi ressemblaient les objets de la chaîne dans l'API et comment ils se connectaient aux sources de données. Les sources étaient souvent des collections, mais nous voulions également prendre en charge les données provenant d’un fichier ou du réseau, ou générées à la volée, par exemple à partir d’un générateur de nombres aléatoires.

Les travaux existants ont eu de nombreuses influences sur la conception. Parmi les plus influents, citons la bibliothèque Guava de Google et la bibliothèque de collections Scala. (Si quelqu'un est surpris de l'influence de Guava, notez que Kevin Bourrillion , développeur principal de Guava, faisait partie du groupe d'experts JSR-335 Lambda .) On Scala collections, nous avons trouvé que cet exposé de Martin Odersky présentait un intérêt particulier: Future-Proofing Scala Collections: de mutable à persistant en parallèle . (Stanford EE380, 1er juin 2011)

La conception de notre prototype à l'époque était basée sur Iterable. Les opérations familières filter, map, etc. étaient des méthodes d'extension (par défaut) sur Iterable. L'appel de l'un ajoutait une opération à la chaîne et renvoyait un autre Iterable. Une opération de terminal telle que count appelle iterator() jusqu'à la source et les opérations sont mises en œuvre dans l'itérateur de chaque étape.

Comme il s’agit de Iterables, vous pouvez appeler la méthode iterator() plusieurs fois. Que devrait-il arriver ensuite?

Si la source est une collection, cela fonctionne généralement très bien. Les collections sont itérables et chaque appel à iterator() produit une instance Iterator distincte, indépendante de toute autre instance active, et chacune parcourt la collection de manière indépendante. Génial.

Maintenant, que se passe-t-il si la source est one-shot, comme lire des lignes d'un fichier? Peut-être que le premier itérateur devrait obtenir toutes les valeurs mais que le second et les suivants devraient être vides. Peut-être que les valeurs devraient être imbriquées parmi les itérateurs. Ou peut-être que chaque itérateur devrait obtenir toutes les mêmes valeurs. Ensuite, que se passe-t-il si vous avez deux itérateurs et que l’un devienne plus loin que l’autre? Quelqu'un devra tamponner les valeurs dans le deuxième itérateur jusqu'à ce qu'elles soient lues. Pire encore, si vous obtenez un Iterator et lisez toutes les valeurs, et seulement alors obtenez un second Iterator. D'où viennent les valeurs maintenant? Est-il nécessaire que tous soient tamponnés juste au cas où quelqu'un voudrait un deuxième Iterator?

Clairement, autoriser plusieurs itérateurs sur une source ponctuelle soulève de nombreuses questions. Nous n'avions pas de bonnes réponses pour eux. Nous voulions un comportement cohérent et prévisible pour ce qui se passe si vous appelez iterator() deux fois. Cela nous a poussés à interdire de multiples traversées, ce qui a rendu les pipelines uniques.

Nous avons également observé que d’autres se heurtaient à ces problèmes. Dans le JDK, la plupart des Iterables sont des collections ou des objets de type collection, qui permettent plusieurs parcours. Ce n'est spécifié nulle part, mais il semblait y avoir une attente non écrite que Iterables autorise plusieurs traversées. Une exception notable est l'interface NIO DirectoryStream . Sa spécification inclut cet avertissement intéressant:

Alors que DirectoryStream étend Iterable, ce n'est pas un Iterable à usage général car il ne prend en charge qu'un seul Iterator; invoquer la méthode iterator pour obtenir un deuxième itérateur ou des suivants, lève IllegalStateException.

[gras dans l'original]

Cela semblait assez inhabituel et désagréable pour que nous ne voulions pas créer tout un tas de nouveaux Iterables qui pourraient être uniques. Cela nous a éloignés d’Iterable.

À peu près à la même époque, un article de Bruce Eckel parut, décrivant un problème qu’il avait eu avec Scala. Il avait écrit ce code:

// Scala
val lines = fromString(data).getLines
val registrants = lines.map(Registrant)
registrants.foreach(println)
registrants.foreach(println)

C'est assez simple. Il analyse les lignes de texte en objets Registrant et les affiche deux fois. Sauf que cela ne les imprime réellement qu'une fois. Il s’avère qu’il pensait que registrants était une collection, alors qu’il s’agit en fait d’un itérateur. Le deuxième appel à foreach rencontre un itérateur vide, à partir duquel toutes les valeurs ont été épuisées, de sorte qu'il n'imprime rien.

Ce type d’expérience nous a convaincus qu’il était très important d’obtenir des résultats clairement prévisibles si l’on tentait plusieurs parcours. Il a également souligné l'importance de distinguer les structures paresseuses de type pipeline des collections réelles qui stockent des données. Cela a ensuite conduit à la séparation des opérations de pipeline paresseux dans la nouvelle interface Stream et au maintien des seules opérations de mutation motivées directement sur Collections. Brian Goetz a expliqué la raison de cela.

Qu'en est-il de permettre la traversée multiple pour les pipelines basés sur la collecte, mais de le refuser pour les pipelines non basés sur la collecte? C'est incohérent, mais c'est raisonnable. Si vous lisez des valeurs du réseau, bien sûr , vous ne pouvez plus les parcourir. Si vous souhaitez les parcourir plusieurs fois, vous devez les extraire explicitement dans une collection.

Mais explorons la possibilité de traversées multiples à partir de pipelines basés sur des collections. Disons que vous avez fait ceci:

Iterable<?> it = source.filter(...).map(...).filter(...).map(...);
it.into(dest1);
it.into(dest2);

(L'opération into est maintenant orthographiée collect(toList()).)

Si source est une collection, le premier appel into() créera une chaîne d'itérateurs vers la source, exécutera les opérations de pipeline et enverra les résultats à la destination. Le deuxième appel à into() créera une autre chaîne d'Iterators et exécutera à nouveau les opérations de pipeline . Ce n’est évidemment pas faux, mais cela a pour effet d’exécuter toutes les opérations de filtrage et de mappage une seconde fois pour chaque élément. Je pense que beaucoup de programmeurs auraient été surpris par ce comportement.

Comme je l'ai mentionné ci-dessus, nous avions parlé aux développeurs de goyave. Une des choses intéressantes qu’ils ont est un Idea Graveyard où ils décrivent les caractéristiques qu’ils ont décidé de ne pas mettre en œuvre avec les raisons . L'idée de collections paresseuses semble assez cool, mais voici ce qu'ils ont à dire à ce sujet. Prenons une opération List.filter() qui renvoie un List:

La principale préoccupation ici est que trop d'opérations deviennent des propositions coûteuses et linéaires. Si vous souhaitez filtrer une liste et obtenir une liste en arrière, et pas seulement une collection ou un itérable, vous pouvez utiliser ImmutableList.copyOf(Iterables.filter(list, predicate)), qui "énonce à l’avance" ce qu’elle fait et combien elle coûte cher.

Pour prendre un exemple spécifique, quel est le coût de get(0) ou size() sur une liste? Pour les classes couramment utilisées comme ArrayList, elles sont O (1). Mais si vous appelez l'un d'entre eux sur une liste filtrée paresseusement, il doit exécuter le filtre sur la liste de sauvegarde, et tout à coup, ces opérations sont O (n). Pire, il doit parcourir la liste de sauvegarde sur chaque opération .

Cela nous a semblé être trop paresse. C’est une chose de configurer certaines opérations et de différer l’exécution jusqu’à ce que vous ayez "Go". C’est une autre chose d’organiser les choses de manière à dissimuler une quantité potentiellement importante de nouveaux calculs.

En proposant d'interdire les flux non linéaires ou "non réutilisables", Paul Sandoz décrit les conséquences potentielles de les autoriser comme générant des "résultats inattendus ou déroutants". Il a également mentionné que l'exécution parallèle rendrait les choses encore plus difficiles. Enfin, j'ajouterais qu'une opération en pipeline avec des effets secondaires conduirait à des bogues difficiles et obscurs si l'opération était exécutée de manière inattendue plusieurs fois, ou au moins un nombre de fois différent de celui attendu par le programmeur. (Mais les programmeurs Java n'écrivent pas d'expressions lambda avec des effets secondaires, n'est-ce pas?

Voilà donc la raison d'être fondamentale de la conception de l'API Java 8 Streams, qui permet une traversée unique et qui nécessite un pipeline strictement linéaire (sans ramification). Il fournit un comportement cohérent sur plusieurs sources de flux différentes, il sépare clairement les opérations paresseuses des opérations les plus désirées et fournit un modèle d'exécution simple.


En ce qui concerne IEnumerable, je suis loin d’être un expert en C # et .NET, donc je souhaiterais être corrigé (avec précaution) si je tire des conclusions incorrectes. Il semble toutefois que IEnumerable permette à plusieurs parcours de se comporter différemment selon les sources; et il permet une structure de branchement d'opérations imbriquées IEnumerable, ce qui peut entraîner un recalcul important. Bien que je sache que différents systèmes font des compromis différents, ce sont deux caractéristiques que nous avons cherché à éviter lors de la conception de l'API Java 8 Streams.

L'exemple de tri rapide donné par le PO est intéressant, déroutant, et je suis désolé de le dire, quelque peu horrible. L'appel de QuickSort prend un IEnumerable et renvoie un IEnumerable, de sorte qu'aucun tri n'est effectué jusqu'à ce que le IEnumerable final soit parcouru. Ce que l'appel semble faire, cependant, est de construire une arborescence de IEnumerables qui reflète le partitionnement que ferait QuickSort, sans le faire réellement. Après tout, il s’agit là d’un calcul lazy.) Si la source a N éléments, l’arbre aura la largeur la plus large pour N éléments et sa profondeur sera égale à lg (N).

Il me semble - et encore une fois, je ne suis pas un expert en C # ou .NET - que certains appels inoffensifs, tels que la sélection de pivot via ints.First(), seront plus coûteux que leur apparence . Au premier niveau, bien sûr, c'est O (1). Mais considérons une partition au fond de l’arbre, au bord droit. Pour calculer le premier élément de cette partition, il faut parcourir l'intégralité de la source, une opération O(N). Mais comme les partitions ci-dessus sont paresseuses, elles doivent être recalculées, ce qui nécessite des comparaisons O (lg N). Donc, sélectionner le pivot serait une opération O (Ng N), qui est aussi chère qu’un tri complet.

Mais nous ne trions pas avant d'avoir traversé le IEnumerable retourné. Dans l'algorithme quicksort standard, chaque niveau de partitionnement double le nombre de partitions. Chaque partition ne représente que la moitié de la taille, de sorte que chaque niveau reste à la complexité O(N). L'arbre de partitions a une hauteur de O (lg N) élevée, ainsi le travail total est de O (N lg N).

Avec l’arbre de IEnumerables paresseux, au bas de l’arbre, il y a N partitions. Le calcul de chaque partition nécessite une traversée de N éléments, chacun nécessitant des comparaisons lg (N) en haut de l'arbre. Pour calculer toutes les partitions au bas de l'arborescence, il faut donc effectuer des comparaisons avec O (N ^ 2 lg N).

(Est-ce exact? J'ai du mal à y croire. Quelqu'un vérifie cela, s'il te plaît.)

Quoi qu’il en soit, c’est vraiment cool que IEnumerable puisse être utilisé de cette manière pour construire des structures de calcul complexes. Mais si cela augmente la complexité de calcul autant que je le pense, il semblerait que la programmation de cette manière est quelque chose qui devrait être évité à moins d’être extrêmement prudent.

360
Stuart Marks

Contexte

Bien que la question semble simple, la réponse nécessite un peu d’arrière-plan pour donner un sens. Si vous voulez passer à la conclusion, faites défiler vers le bas ...

Choisissez votre point de comparaison - Fonctionnalité de base

En utilisant les concepts de base, le concept IEnumerable de C # est plus étroitement lié à Iterable de Java , qui est capable de créer autant d'itérateurs comme vous voulez. IEnumerables créer IEnumerators . Java Iterable create Iterators

L'historique de chaque concept est similaire, en ce sens que IEnumerable et Iterable ont tous les deux une motivation de base pour autoriser une boucle de style "pour chaque" sur les membres des collections de données. C'est une simplification excessive, car ils permettent tous les deux plus que cela, et ils sont également arrivés à ce stade via différentes progressions, mais c'est une caractéristique commune importante malgré tout.

Comparons cette fonctionnalité: dans les deux langages, si une classe implémente IEnumerable/Iterable, cette classe doit implémenter au moins une seule méthode (pour C #, c’est GetEnumerator et pour Java c’est iterator()) . Dans chaque cas, l'instance renvoyée à partir de celle-ci (IEnumerator/Iterator) vous permet d'accéder aux membres actuels et suivants des données. Cette fonctionnalité est utilisée dans la syntaxe de chaque langue.

Choisissez votre point de comparaison - Fonctionnalité améliorée

IEnumerable en C # a été étendu pour permettre un certain nombre d'autres fonctionnalités du langage ( , principalement liées à Linq ). Les fonctionnalités ajoutées incluent des sélections, des projections, des agrégations, etc. Ces extensions ont une forte motivation d'utilisation en théorie des ensembles, similaires aux concepts SQL et Relational Database.

Java 8 a également eu des fonctionnalités ajoutées pour permettre un degré de programmation fonctionnelle en utilisant Streams et Lambdas. Notez que les flux Java 8 ne sont pas principalement motivés par la théorie des ensembles, mais par la programmation fonctionnelle. Quoi qu'il en soit, il y a beaucoup de parallèles.

Donc, ceci est le deuxième point. Les améliorations apportées à C # ont été implémentées pour améliorer le concept IEnumerable. En Java, cependant, les améliorations apportées ont été mises en œuvre en créant de nouveaux concepts de base de Lambdas et Streams, puis en créant un moyen relativement simple de convertir Iterators et Iterables en Streams, et inversement.

Ainsi, comparer IEnumerable au concept de flux de Java est incomplet. Vous devez le comparer aux API de flux et de collections combinées en Java.

En Java, les flux ne sont pas identiques aux Iterables, ou aux itérateurs

Les flux ne sont pas conçus pour résoudre les problèmes de la même manière que les itérateurs:

  • Les itérateurs sont une manière de décrire la séquence de données.
  • Les flux sont une manière de décrire une séquence de transformations de données.

Avec Iterator, vous obtenez une valeur de données, vous la traitez, puis une autre valeur.

Avec Streams, vous enchaînez une séquence de fonctions, vous transmettez ensuite une valeur d'entrée au flux et vous obtenez la valeur de sortie de la séquence combinée. Remarque: en termes Java, chaque fonction est encapsulée dans une seule instance Stream. L'API Streams vous permet de lier une séquence d'instances Stream de manière à chaîner une séquence d'expressions de transformation.

Afin de compléter le concept Stream, vous avez besoin d’une source de données pour alimenter le flux et d’une fonction de terminal consommant le flux.

La façon dont vous introduisez des valeurs dans le flux peut en fait provenir de Iterable, mais la séquence Stream elle-même n’est pas une Iterable, c’est une fonction composée.

Un Stream est également destiné à être paresseux, en ce sens qu'il ne fonctionne que lorsque vous lui demandez une valeur.

Notez ces hypothèses et caractéristiques importantes des flux:

  • Un Stream dans Java est un moteur de transformation, il transforme un élément de données dans un état en un autre.
  • les flux n'ont aucune notion de l'ordre ou de la position des données, mais simplement de transformer tout ce qui leur est demandé.
  • les flux peuvent être alimentés avec des données provenant de nombreuses sources, y compris d’autres flux, Iterators, Iterables, Collections, etc.
  • vous ne pouvez pas "réinitialiser" un flux, ce serait comme "reprogrammer la transformation". Réinitialiser la source de données est probablement ce que vous voulez.
  • il n'y a logiquement qu'un seul élément de données 'en vol' dans le flux à tout moment (sauf si le flux est un flux parallèle, point auquel il y a 1 élément par thread). Cela est indépendant de la source de données qui peut avoir plus d'éléments que "prêts" à fournir au flux, ou du collecteur de flux qui peut avoir besoin d'agréger et de réduire plusieurs valeurs.
  • Les flux peuvent être non liés (infinis), limités uniquement par la source de données ou par le collecteur (qui peut également être infini).
  • Les flux sont 'chaînables', la sortie du filtrage d'un flux est un autre flux. Les valeurs entrées dans un flux et transformées par un flux peuvent à leur tour être fournies à un autre flux qui effectue une transformation différente. Les données, dans leur état transformé, passent d'un flux à un autre. Vous n'avez pas besoin d'intervenir pour extraire les données d'un flux et les brancher au suivant.

Comparaison C #

Lorsque vous considérez qu'un Java Stream n'est qu'une partie d'un système d'approvisionnement, de flux et de collecte, et que les Stream et les itérateurs sont souvent utilisés avec des collections, il n'est pas étonnant qu'il soit difficile d'établir une relation. aux mêmes concepts qui sont presque tous intégrés dans un seul concept IEnumerable en C #.

Des parties de IEnumerable (et des concepts connexes proches) apparaissent dans tous les concepts Java Iterator, Iterable, Lambda et Stream.

Il y a de petites choses que les concepts Java peuvent faire qui sont plus difficiles dans IEnumerable et inversement.


Conclusion

  • Il n'y a pas de problème de conception ici, juste un problème d'appariement des concepts entre les langues.
  • Les cours d'eau résolvent les problèmes d'une manière différente
  • Les flux ajoutent des fonctionnalités à Java (ils ajoutent une manière différente de faire les choses, ils n'enlèvent pas les fonctionnalités)

L'ajout de flux vous donne plus de choix lors de la résolution de problèmes, ce qui est juste à classer comme "accroissant le pouvoir", pas comme "réduisant", "enlevant" ou "restreignant".

Pourquoi les Java Streams sont-ils uniques?

Cette question est erronée car les flux sont des séquences de fonctions et non des données. Selon la source de données qui alimente le flux, vous pouvez réinitialiser la source de données et alimenter le même flux ou un flux différent.

Contrairement à IEnumerable de C #, où un pipeline d’exécution peut être exécuté autant de fois que nous le souhaitons, dans Java, un flux ne peut être "itéré" qu’une seule fois.

Comparer un IEnumerable à un Stream est erroné. Le contexte que vous utilisez pour dire que IEnumerable peut être exécuté autant de fois que vous le souhaitez est mieux comparé à Java Iterables, qui peut être itéré autant de fois que vous le souhaitez. Un Java Stream représente un sous-ensemble du concept IEnumerable, et non le sous-ensemble qui fournit des données, et ne peut donc pas être "réexécuté".

Tout appel à une opération de terminal ferme le flux, le rendant inutilisable. Cette "fonctionnalité" enlève beaucoup de pouvoir.

La première déclaration est vraie, dans un sens. La déclaration 'enlève le pouvoir' ne l'est pas. Vous comparez encore Streams it IEnumerables. L'opération de terminal dans le flux est comme une clause 'break' dans une boucle for. Vous êtes toujours libre d'avoir un autre flux, si vous le souhaitez, et si vous pouvez fournir à nouveau les données dont vous avez besoin. Encore une fois, si vous considérez que IEnumerable ressemble davantage à un Iterable, pour cette instruction, Java ne pose pas de problème.

J'imagine que la raison en est que ce n'est pas technique. Quelles sont les considérations de conception derrière cette restriction étrange?

La raison en est technique, et pour la simple raison qu’un Stream est un sous-ensemble de ce que je pense. Le sous-ensemble de flux ne contrôle pas la fourniture de données. Vous devez donc réinitialiser la fourniture, pas le flux. Dans ce contexte, ce n’est pas si étrange.

Exemple QuickSort

Votre exemple de tri rapide comporte la signature:

IEnumerable<int> QuickSort(IEnumerable<int> ints)

Vous traitez l'entrée IEnumerable comme une source de données:

IEnumerable<int> lt = ints.Where(i => i < pivot);

De plus, la valeur renvoyée est également IEnumerable, qui est une fourniture de données. Etant donné qu'il s'agit d'une opération de tri, l'ordre de cette fourniture est important. Si vous considérez que la classe Java Iterable est la correspondance appropriée, en particulier la spécialisation List de Iterable, étant donné que List est une fourniture de données ayant un ordre ou une itération garanti, l'équivalent Java code pour votre code serait:

Stream<Integer> quickSort(List<Integer> ints) {
    // Using a stream to access the data, instead of the simpler ints.isEmpty()
    if (!ints.stream().findAny().isPresent()) {
        return Stream.of();
    }

    // treating the ints as a data collection, just like the C#
    final Integer pivot = ints.get(0);

    // Using streams to get the two partitions
    List<Integer> lt = ints.stream().filter(i -> i < pivot).collect(Collectors.toList());
    List<Integer> gt = ints.stream().filter(i -> i > pivot).collect(Collectors.toList());

    return Stream.concat(Stream.concat(quickSort(lt), Stream.of(pivot)),quickSort(gt));
}    

Notez qu'il y a un bogue (que j'ai reproduit), en ce sens que le tri ne gère pas les valeurs en double de manière élégante, c'est un tri à "valeur unique".

Notez également comment le code Java utilise la source de données (List) et les concepts de flux à un point différent. En C #, ces deux personnalités peuvent être exprimées dans IEnumerable. De plus, même si j'ai utilisé List comme type de base, j'aurais pu utiliser le plus général Collection, et avec une petite conversion itérateur-en-flux, j'aurais pu utiliser le plus général Iterable

121
rolfl

Streams sont construits autour de Spliterators, qui sont des objets avec état et mutables. Ils n’ont pas d’action de "réinitialisation" et, en fait, obliger à appuyer une telle action de rembobinage "enlèverait beaucoup de pouvoir". Comment Random.ints() serait-il supposé gérer une telle requête?

Par contre, pour Streams qui ont une origine retraçable, il est facile de construire un équivalent Stream qui sera réutilisé. Il suffit de mettre les étapes effectuées pour construire le Stream dans une méthode réutilisable. Gardez à l'esprit que la répétition de ces étapes n'est pas une opération coûteuse, car toutes ces étapes sont des opérations paresseuses. le travail commence avec le fonctionnement du terminal et, en fonction du fonctionnement du terminal, un code totalement différent peut être exécuté.

En tant qu’écrivain d’une telle méthode, c’est à vous qu’il incombe de spécifier ce qu’appelle deux fois la méthode: reproduit-elle exactement la même séquence, comme le font les flux créés pour un tableau ou une collection non modifié, ou produit-elle un flux avec une sémantique similaire mais des éléments différents, comme un flux d'intes aléatoires ou un flux de lignes d'entrée de console, etc.


À propos, pour éviter toute confusion, une opération de terminal consomme le Stream qui est distinct de fermant le Stream comme l'appelant close() sur le flux existe (ce qui est nécessaire pour les flux ayant des ressources associées telles que, par exemple, produites par Files.lines()).


Il semble que beaucoup de confusion résulte de la comparaison erronée de IEnumerable avec Stream. Un IEnumerable indique la possibilité de fournir un IEnumerator, de sorte qu'il ressemble à un Iterable en Java. Par contre, un Stream est une sorte d’itérateur et comparable à un IEnumerator. Il est donc faux de prétendre que ce type de données peut être utilisé plusieurs fois dans .NET. La prise en charge de _IEnumerator.Reset_ est facultative. Les exemples abordés ici utilisent plutôt le fait qu’un IEnumerable peut être utilisé pour extraire de nouveaux IEnumerators et qu’il fonctionne également avec le Collections de Java; vous pouvez obtenir un nouveau Stream. Si les développeurs Java décidaient d'ajouter directement les opérations Stream à Iterable, les opérations intermédiaires renvoyant un autre Iterable, le résultat était comparable et pouvait fonctionner de la même manière.

Cependant, les développeurs ont décidé de ne pas le faire et la décision est discutée dans cette question . Le point le plus important est la confusion entourant les opérations de collecte et les opérations de flux paresseuses. En regardant l'API .NET, je (oui, personnellement) le trouve justifié. Même si cela semble raisonnable de regarder IEnumerable seul, une collection particulière aura beaucoup de méthodes manipulant directement la collection et beaucoup retournant un nom paresseux IEnumerable, alors que la nature particulière d’une méthode n’est pas toujours intuitivement reconnaissable. Le pire exemple que j’ai trouvé (dans les quelques minutes que j’ai regardées) est List.Reverse() dont le nom correspond exactement au nom du hérité (est-ce le bon terminus pour les méthodes d'extension?) Enumerable.Reverse() tout en ayant un comportement totalement contradictoire.


Bien sûr, ce sont deux décisions distinctes. Le premier à faire Stream un type distinct de Iterable/Collection et le second à faire Stream comme une sorte d'itérateur unique plutôt qu'une autre sorte d'iterable. Mais ces décisions ont été prises ensemble et il est possible que la séparation de ces deux décisions n'ait jamais été envisagée. Il n’a pas été créé pour être comparable à .NET.

La décision réelle de conception de l'API consistait à ajouter un type amélioré d'itérateur, le Spliterator. Spliterators peut être fourni par l'ancien Iterables (c'est-à-dire la façon dont ils ont été réaménagés) ou par de nouvelles implémentations. Stream a ensuite été ajouté comme interface de haut niveau au niveau plutôt bas Spliterators. C'est ça. Vous pouvez discuter de la question de savoir si une conception différente serait meilleure, mais cela n’est pas productif, cela ne changera pas, compte tenu de la façon dont ils ont été conçus.

Vous devez prendre en compte un autre aspect de la mise en œuvre. Streams sont pas des structures de données immuables. Chaque opération intermédiaire peut renvoyer une nouvelle instance Stream encapsulant l’ancienne, mais elle peut également manipuler sa propre instance et se renvoyer elle-même (cela n’empêche pas de faire les deux à la fois pour la même opération). Des exemples connus sont des opérations telles que parallel ou unordered qui n’ajoutent pas d’étape supplémentaire, mais manipulent tout le pipeline). Avoir une structure de données aussi modifiable et tenter de le réutiliser (ou pire, l’utiliser plusieurs fois en même temps) ne fonctionne pas bien…


Par souci d'exhaustivité, voici votre exemple quicksort traduit en API Java Stream. Cela montre que cela n’enlève pas vraiment beaucoup de pouvoir.

_static Stream<Integer> quickSort(Supplier<Stream<Integer>> ints) {

  final Optional<Integer> optPivot = ints.get().findAny();
  if(!optPivot.isPresent()) return Stream.empty();

  final int pivot = optPivot.get();

  Supplier<Stream<Integer>> lt = ()->ints.get().filter(i -> i < pivot);
  Supplier<Stream<Integer>> gt = ()->ints.get().filter(i -> i > pivot);

  return Stream.of(quickSort(lt), Stream.of(pivot), quickSort(gt)).flatMap(s->s);
}
_

Il peut être utilisé comme

_List<Integer> l=new Random().ints(100, 0, 1000).boxed().collect(Collectors.toList());
System.out.println(l);
System.out.println(quickSort(l::stream)
    .map(Object::toString).collect(Collectors.joining(", ")));
_

Vous pouvez l'écrire encore plus compact que

_static Stream<Integer> quickSort(Supplier<Stream<Integer>> ints) {
    return ints.get().findAny().map(pivot ->
         Stream.of(
                   quickSort(()->ints.get().filter(i -> i < pivot)),
                   Stream.of(pivot),
                   quickSort(()->ints.get().filter(i -> i > pivot)))
        .flatMap(s->s)).orElse(Stream.empty());
}
_
21
Holger

Je pense qu'il y a très peu de différences entre les deux quand on y regarde de trop près.

En face, une IEnumerable semble être une construction réutilisable:

IEnumerable<int> numbers = new int[] { 1, 2, 3, 4, 5 };

foreach (var n in numbers) {
    Console.WriteLine(n);
}

Cependant, le compilateur fait un peu de travail pour nous aider; il génère le code suivant:

IEnumerable<int> numbers = new int[] { 1, 2, 3, 4, 5 };

IEnumerator<int> enumerator = numbers.GetEnumerator();
while (enumerator.MoveNext()) {
    Console.WriteLine(enumerator.Current);
}

Chaque fois que vous parcourez l'énumérable, le compilateur crée un énumérateur. L'énumérateur n'est pas réutilisable; les appels suivants à MoveNext renverront simplement false, et il n’ya aucun moyen de le réinitialiser au début. Si vous souhaitez parcourir à nouveau les numéros, vous devez créer une autre instance d'énumérateur.


Pour mieux illustrer le fait que IEnumerable a (peut avoir) la même fonctionnalité qu'un flux Java, considérons un énumérable dont la source des nombres n'est pas une collection statique. Par exemple, nous pouvons créer un objet énumérable qui génère une séquence de 5 nombres aléatoires:

class Generator : IEnumerator<int> {
    Random _r;
    int _current;
    int _count = 0;

    public Generator(Random r) {
        _r = r;
    }

    public bool MoveNext() {
        _current= _r.Next();
        _count++;
        return _count <= 5;
    }

    public int Current {
        get { return _current; }
    }
 }

class RandomNumberStream : IEnumerable<int> {
    Random _r = new Random();
    public IEnumerator<int> GetEnumerator() {
        return new Generator(_r);
    }
    public IEnumerator IEnumerable.GetEnumerator() {
        return this.GetEnumerator();
    }
}

Nous avons maintenant un code très similaire à l'énumérable précédent basé sur un tableau, mais avec une seconde itération sur numbers:

IEnumerable<int> numbers = new RandomNumberStream();

foreach (var n in numbers) {
    Console.WriteLine(n);
}
foreach (var n in numbers) {
    Console.WriteLine(n);
}

La deuxième fois que nous itérons sur numbers nous obtiendrons une séquence de nombres différente, qui ne sera pas réutilisable dans le même sens. Ou bien, nous aurions pu écrire le RandomNumberStream pour renvoyer une exception si vous essayez de le parcourir plusieurs fois, rendant ainsi l'énumérable réellement inutilisable (comme un flux Java).

En outre, que signifie votre tri rapide basé sur une énumération lorsqu'il est appliqué à un RandomNumberStream?


Conclusion

Ainsi, la plus grande différence est que .NET vous permet de réutiliser un IEnumerable en créant implicitement un nouveau IEnumerator en arrière-plan chaque fois que vous devez accéder à des éléments de la séquence.

Ce comportement implicite est souvent utile (et "puissant" comme vous le dites), car nous pouvons effectuer plusieurs itérations sur une collection.

Mais parfois, ce comportement implicite peut en réalité causer des problèmes. Si votre source de données n'est pas statique ou son accès coûteux (comme une base de données ou un site Web), de nombreuses hypothèses sur IEnumerable doivent être écartées. la réutilisation n'est pas si simple

8
Andrew Vermie

Il est possible de contourner certaines des protections "exécuter une fois" de l'API Stream; par exemple, nous pouvons éviter les exceptions Java.lang.IllegalStateException (avec le message "le flux a déjà été traité ou fermé") en référençant et en réutilisant le Spliterator (plutôt que le Stream directement).

Par exemple, ce code s'exécutera sans générer d'exception:

    Spliterator<String> split = Stream.of("hello","world")
                                      .map(s->"prefix-"+s)
                                      .spliterator();

    Stream<String> replayable1 = StreamSupport.stream(split,false);
    Stream<String> replayable2 = StreamSupport.stream(split,false);


    replayable1.forEach(System.out::println);
    replayable2.forEach(System.out::println);

Cependant, la sortie sera limitée à

prefix-hello
prefix-world

plutôt que de répéter la sortie deux fois. En effet, la ArraySpliterator utilisée comme source Stream est avec état et stocke sa position actuelle. Quand on rejoue cette Stream on recommence à la fin.

Nous avons plusieurs options pour résoudre ce problème:

  1. Nous pourrions utiliser une méthode de création sans état Stream telle que Stream#generate(). Nous devrions gérer l'état de manière externe dans notre propre code et réinitialiser entre Stream "replays":

    Spliterator<String> split = Stream.generate(this::nextValue)
                                      .map(s->"prefix-"+s)
                                      .spliterator();
    
    Stream<String> replayable1 = StreamSupport.stream(split,false);
    Stream<String> replayable2 = StreamSupport.stream(split,false);
    
    
    replayable1.forEach(System.out::println);
    this.resetCounter();
    replayable2.forEach(System.out::println);
    
  2. Une autre solution (légèrement meilleure mais pas parfaite) consiste à écrire notre propre source ArraySpliterator (ou une source similaire Stream) comportant une certaine capacité de réinitialisation du compteur actuel. Si nous devions l'utiliser pour générer la Stream, nous pourrions potentiellement les rejouer avec succès.

    MyArraySpliterator<String> arraySplit = new MyArraySpliterator("hello","world");
    Spliterator<String> split = StreamSupport.stream(arraySplit,false)
                                            .map(s->"prefix-"+s)
                                            .spliterator();
    
    Stream<String> replayable1 = StreamSupport.stream(split,false);
    Stream<String> replayable2 = StreamSupport.stream(split,false);
    
    
    replayable1.forEach(System.out::println);
    arraySplit.reset();
    replayable2.forEach(System.out::println);
    
  3. La meilleure solution à ce problème (à mon avis) consiste à créer une nouvelle copie de tout Spliterator stateful utilisé dans le pipeline Stream lorsque de nouveaux opérateurs sont appelés sur le Stream. C’est plus complexe et plus complexe à implémenter, mais si vous n’aurez pas besoin d’utiliser des bibliothèques tierces, cyclops-react a une implémentation Stream qui fait exactement cela. (Divulgation: je suis le développeur principal de ce projet.)

    Stream<String> replayableStream = ReactiveSeq.of("hello","world")
                                                 .map(s->"prefix-"+s);
    
    
    
    
    replayableStream.forEach(System.out::println);
    replayableStream.forEach(System.out::println);
    

Cela va imprimer

prefix-hello
prefix-world
prefix-hello
prefix-world

comme prévu.

1
John McClean