J'ai quelques souvenirs de la conception initiale de l'API Streams qui pourraient éclairer la justification de la conception.
En 2012, nous ajoutions des lambdas au langage et nous voulions un ensemble d'opérations orientées collections ou «données en masse», programmées à l'aide de lambdas, qui faciliteraient le parallélisme. L'idée d'enchaîner les opérations paresseusement était bien établie à ce stade. Nous ne voulions pas non plus que les opérations intermédiaires stockent les résultats.
Les principaux problèmes dont nous avions besoin pour décider étaient à 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 des données provenant d'un fichier ou du réseau, ou des données générées à la volée, par exemple, à partir d'un générateur de nombres aléatoires.
Il y avait de nombreuses influences du travail existant sur la conception. Parmi les plus influents se trouvaient la bibliothèque Google de Guava et la bibliothèque des collections Scala. (Si quelqu'un est surpris par l'influence de la goyave, notez que Kevin Bourrillion , développeur principal de la goyave, faisait partie du groupe d'experts JSR-335 Lambda .) Sur les collections Scala, nous avons trouvé cette conférence de Martin Odersky particulièrement intéressante: Future- Vérification des collections Scala: de Mutable à Persistant à Parallèle . (Stanford EE380, 1er juin 2011)
Notre conception de prototype à l'époque était basée sur Iterable
. Les opérations familières filter
, map
et ainsi de suite sont des méthodes d'extension (par défaut) sur Iterable
. L'appel de l'un a ajouté une opération à la chaîne et en a renvoyé une autre Iterable
. Une opération terminale comme celle count
qui appelle iterator()
la chaîne à la source, et les opérations ont été mises en œuvre dans l'itérateur de chaque étape.
Comme ce sont des Iterables, vous pouvez appeler la iterator()
méthode plusieurs fois. Que devrait-il se passer alors?
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 d'itérateur distincte qui est indépendante de toute autre instance active, et chacune traverse la collection indépendamment. Génial.
Maintenant, que se passe-t-il si la source est à un coup, comme la lecture de lignes d'un fichier? Peut-être que le premier itérateur devrait obtenir toutes les valeurs, mais le second et les suivants devraient être vides. Peut-être que les valeurs devraient être entrelacées entre les itérateurs. Ou peut-être que chaque itérateur devrait avoir toutes les mêmes valeurs. Alors, que se passe-t-il si vous avez deux itérateurs et que l'un est plus avancé que l'autre? Quelqu'un devra mettre en mémoire tampon les valeurs dans le deuxième itérateur jusqu'à ce qu'elles soient lues. Pire, que se passe-t-il si vous obtenez un Iterator et lisez toutes les valeurs, et seulement alors obtenez un deuxième Iterator. D'où viennent les valeurs maintenant? Y a-t-il une exigence pour que tous soient mis en mémoire tampon au cas où quelqu'un voudrait un deuxième itérateur?
De toute évidence, 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 les traversées multiples, ce qui rend les pipelines à un coup.
Nous avons également vu d'autres se heurter à ces problèmes. Dans le JDK, la plupart des Iterables sont des collections ou des objets de type collection, qui permettent une traversée multiple. Il n'est spécifié nulle part, mais il semble y avoir une attente non écrite selon laquelle les Iterables autorisent une traversée multiple. Une exception notable est l' interface NIO DirectoryStream . Sa spécification inclut cet avertissement intéressant:
Bien que DirectoryStream étende Iterable, il ne s'agit pas d'un Iterable à usage général car il ne prend en charge qu'un seul Iterator; l'appel de la méthode itérateur pour obtenir un deuxième itérateur ou un itérateur suivant 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 une seule fois. Cela nous a éloignés de l'utilisation d'Iterable.
À cette époque, un article de Bruce Eckel est apparu 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 Registrant
objets et les imprime deux fois. Sauf qu'il ne les imprime qu'une seule fois. Il s'avère qu'il pensait que registrants
c'était une collection, alors qu'en fait c'est un itérateur. Le deuxième appel à foreach
rencontre un itérateur vide, dont toutes les valeurs ont été épuisées, il n'imprime donc rien.
Ce type d'expérience nous a convaincus qu'il était très important d'avoir des résultats clairement prévisibles en cas de tentative de traversée multiple. Il a également souligné l'importance de distinguer les structures de type pipeline paresseux des collections réelles qui stockent des données. À son tour, cela a conduit à la séparation des opérations de pipeline paresseux dans la nouvelle interface Stream et à ne conserver que des opérations mutantes avides directement sur les collections. Brian Goetz a expliqué la raison de cela.
Qu'en est-il d'autoriser la traversée multiple pour les pipelines basés sur la collecte mais de l'interdire pour les pipelines non basés sur la collecte? C'est incohérent, mais c'est raisonnable. Si vous lisez des valeurs sur le réseau, vous ne pouvez bien sûr pas les parcourir à nouveau. Si vous souhaitez les parcourir plusieurs fois, vous devez les tirer explicitement dans une collection.
Mais explorons la possibilité de traverser plusieurs fois à 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' into
opération est maintenant orthographiée collect(toList())
.)
Si la source est une collection, le premier into()
appel 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'itérateurs et exécutera à nouveau les opérations de pipeline . Ce n'est évidemment pas faux, mais cela a pour effet d'effectuer toutes les opérations de filtrage et de mappage une deuxième fois pour chaque élément. Je pense que de nombreux programmeurs auraient été surpris par ce comportement.
Comme je l'ai mentionné ci-dessus, nous avions parlé aux développeurs de Guava. L'une des choses sympas qu'ils ont est un cimetière d'idées où ils décrivent des fonctionnalités qu'ils ont décidé de ne pas implémenter avec les raisons. L'idée de collections paresseuses semble plutôt cool, mais voici ce qu'elles en disent. Considérons une List.filter()
opération qui renvoie un List
:
La plus grande préoccupation ici est que trop d'opérations deviennent des propositions coûteuses en temps linéaire. Si vous souhaitez filtrer une liste et récupérer une liste, et pas seulement une collection ou un Iterable, vous pouvez utiliser ImmutableList.copyOf(Iterables.filter(list, predicate))
ce qui "indique à l'avance" ce qu'il fait et son coût.
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 de ces éléments 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 à chaque opération.
Cela nous semblait trop de paresse. C'est une chose de mettre en place certaines opérations et de différer l'exécution jusqu'à ce que vous "Go". C'est une autre façon de configurer les choses de manière à masquer une quantité potentiellement importante de recalcul.
En proposant de interdire les flux non linéaires ou "sans réutilisation", Paul Sandoz a décrit les conséquences potentielles de leur autorisation comme donnant lieu à "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 de pipeline avec des effets secondaires entraînerait 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 prévu par le programmeur. (Mais les programmeurs Java n'écrivent pas d'expressions lambda avec des effets secondaires, n'est-ce pas?
C'est donc la raison d'être de la conception de l'API Java 8 Streams qui permet une traversée en une seule fois et qui nécessite un pipeline strictement linéaire (sans branchement). Il fournit un comportement cohérent sur plusieurs sources de flux différentes, il sépare clairement les opérations paresseuses des opérations ardues et il 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 j'apprécierais d'être corrigé (doucement) si je tire des conclusions incorrectes. Il semble cependant que IEnumerable
les traversées multiples se comportent différemment avec différentes sources; et il permet une structure de branchement des IEnumerable
opérations imbriquées , ce qui peut entraîner un recalcul important. Bien que j'apprécie que différents systèmes fassent des compromis différents, ce sont deux caractéristiques que nous avons cherché à éviter dans la conception de l'API Java 8 Streams.
L'exemple de tri rapide donné par l'OP est intéressant, déroutant, et je suis désolé de le dire, quelque peu horrible. L'appel QuickSort
prend un IEnumerable
et retourne un IEnumerable
, donc aucun tri n'est fait jusqu'à ce que la finale IEnumerable
soit traversée. Cependant, ce que l'appel semble faire, c'est construire une structure arborescente IEnumerables
qui reflète le partitionnement que le tri rapide ferait, sans le faire réellement. (C'est du calcul paresseux, après tout.) Si la source a N éléments, l'arborescence sera N éléments large à son plus large, et ce sera lg (N) niveaux profonds.
Il me semble - et encore une fois, je ne suis pas un expert C # ou .NET - que cela rendra certains appels d'apparence anodine, tels que la sélection de pivot via ints.First()
, plus chers qu'ils ne le paraissent. Au premier niveau, bien sûr, c'est O (1). Mais considérons une partition profondément dans l'arborescence, sur le bord droit. Pour calculer le premier élément de cette partition, toute la source doit être traversée, une opération O (N). Mais comme les partitions ci-dessus sont paresseuses, elles doivent être recalculées, nécessitant des comparaisons O (lg N). La sélection du pivot serait donc une opération O (N lg N), qui est aussi coûteuse qu'un tri entier.
Mais nous ne trions pas réellement jusqu'à ce que nous traversions le retour IEnumerable
. Dans l'algorithme de tri rapide standard, chaque niveau de partitionnement double le nombre de partitions. Chaque partition ne fait que la moitié de la taille, donc chaque niveau reste à la complexité O (N). L'arbre des partitions est O (lg N) élevé, donc le travail total est O (N lg N).
Avec l'arborescence des IEnumerables paresseux, au bas de l'arborescence 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) dans l'arborescence. Pour calculer toutes les partitions au bas de l'arborescence, il faut alors des comparaisons O (N ^ 2 lg N).
(Est-ce vrai? Je peux à peine y croire. Quelqu'un, veuillez vérifier cela pour moi.)
En tout cas, c'est en effet cool que l' IEnumerable
on puisse utiliser de cette façon pour construire des structures de calcul compliquées. Mais si cela augmente la complexité de calcul autant que je le pense, il semblerait que la programmation de cette façon soit quelque chose qui devrait être évitée à moins d'être extrêmement prudent.