Ich habe einige Erinnerungen an das frühe Design der Streams-API, die Aufschluss über die Designgründe geben könnten.
Bereits 2012 haben wir der Sprache Lambdas hinzugefügt, und wir wollten einen sammlungsorientierten oder "Bulk-Daten" -Satz von Operationen, die mit Lambdas programmiert wurden und die Parallelität erleichtern. Die Idee, Operationen träge miteinander zu verketten, war zu diesem Zeitpunkt gut etabliert. Wir wollten auch nicht, dass die Zwischenoperationen Ergebnisse speichern.
Die wichtigsten Punkte, die wir entscheiden mussten, waren, wie die Objekte in der Kette in der API aussahen und wie sie mit Datenquellen verbunden wurden. Die Quellen waren oft Sammlungen, aber wir wollten auch Daten unterstützen, die aus einer Datei oder dem Netzwerk stammen, oder Daten, die im laufenden Betrieb generiert werden, z. B. von einem Zufallszahlengenerator.
Es gab viele Einflüsse bestehender Arbeiten auf das Design. Zu den einflussreicheren gehörten die Guava- Bibliothek von Google und die Scala-Sammlungsbibliothek. (Wenn jemand über den Einfluss von Guava überrascht ist, beachten Sie, dass Kevin Bourrillion , Guava-Hauptentwickler, Mitglied der Lambda- Expertengruppe JSR-335 war .) In Bezug auf Scala-Sammlungen fanden wir diesen Vortrag von Martin Odersky von besonderem Interesse: Future- Proofing Scala Collections: von veränderlich über persistent bis parallel . (Stanford EE380, 1. Juni 2011)
Unser damaliges Prototypendesign basierte auf Iterable
. Die bekannten Operationen filter
, map
und so waren nach Verlängerung (default) Methoden auf Iterable
. Durch Aufrufen eines wurde der Kette eine Operation hinzugefügt und eine andere zurückgegeben Iterable
. Eine Terminaloperation wie count
würde iterator()
die Kette zur Quelle aufrufen , und die Operationen wurden innerhalb des Iterators jeder Stufe implementiert.
Da es sich um Iterables handelt, können Sie die iterator()
Methode mehrmals aufrufen . Was soll dann passieren?
Wenn die Quelle eine Sammlung ist, funktioniert dies meistens einwandfrei. Sammlungen sind iterierbar, und jeder Aufruf von iterator()
erzeugt eine eigene Iterator-Instanz, die unabhängig von anderen aktiven Instanzen ist, und jede durchläuft die Sammlung unabhängig. Toll.
Was ist nun, wenn die Quelle einmalig ist, wie das Lesen von Zeilen aus einer Datei? Vielleicht sollte der erste Iterator alle Werte erhalten, aber der zweite und die folgenden sollten leer sein. Vielleicht sollten die Werte zwischen den Iteratoren verschachtelt werden. Oder vielleicht sollte jeder Iterator alle die gleichen Werte erhalten. Was ist dann, wenn Sie zwei Iteratoren haben und einer dem anderen weiter voraus ist? Jemand muss die Werte im zweiten Iterator puffern, bis sie gelesen werden. Schlimmer noch, was ist, wenn Sie einen Iterator erhalten und alle Werte lesen und erst dann einen zweiten Iterator erhalten. Woher kommen die Werte jetzt? Müssen sie alle gepuffert werden, nur für den Fall, dass jemand einen zweiten Iterator möchte?
Das Zulassen mehrerer Iteratoren über eine One-Shot-Quelle wirft natürlich viele Fragen auf. Wir hatten keine guten Antworten für sie. Wir wollten ein konsistentes, vorhersehbares Verhalten für das, was passiert, wenn Sie iterator()
zweimal anrufen . Dies brachte uns dazu, mehrere Durchquerungen zu verbieten, wodurch die Pipelines einmalig wurden.
Wir haben auch beobachtet, wie andere auf diese Probleme gestoßen sind. Im JDK sind die meisten Iterables Sammlungen oder sammlungsähnliche Objekte, die mehrere Durchquerungen ermöglichen. Es ist nirgendwo spezifiziert, aber es schien eine ungeschriebene Erwartung zu geben, dass Iterables mehrere Durchquerungen erlauben. Eine bemerkenswerte Ausnahme ist die NIO DirectoryStream- Schnittstelle. Die Spezifikation enthält diese interessante Warnung:
Während DirectoryStream Iterable erweitert, ist es kein universelles Iterable, da es nur einen einzigen Iterator unterstützt. Wenn Sie die Iterator-Methode aufrufen, um einen zweiten oder nachfolgenden Iterator zu erhalten, wird IllegalStateException ausgelöst.
[fett im Original]
Dies schien ungewöhnlich und unangenehm genug, dass wir nicht eine ganze Reihe neuer Iterables erstellen wollten, die möglicherweise nur einmal verfügbar sind. Dies hat uns davon abgehalten, Iterable zu verwenden.
Ungefähr zu dieser Zeit erschien ein Artikel von Bruce Eckel , der einen Punkt beschrieb, an dem er Probleme mit Scala hatte. Er hatte diesen Code geschrieben:
// Scala
val lines = fromString(data).getLines
val registrants = lines.map(Registrant)
registrants.foreach(println)
registrants.foreach(println)
Es ist ziemlich einfach. Es analysiert Textzeilen in Registrant
Objekte und druckt sie zweimal aus. Nur dass sie tatsächlich nur einmal ausgedruckt werden. Es stellt sich heraus, dass er dachte, das registrants
sei eine Sammlung, obwohl es sich tatsächlich um einen Iterator handelt. Der zweite Aufruf foreach
trifft auf einen leeren Iterator, von dem alle Werte erschöpft sind, sodass nichts gedruckt wird.
Diese Art von Erfahrung hat uns überzeugt, dass es sehr wichtig ist, klar vorhersehbare Ergebnisse zu erzielen, wenn versucht wird, mehrere Durchquerungen durchzuführen. Es wurde auch hervorgehoben, wie wichtig es ist, zwischen faulen Pipeline-ähnlichen Strukturen und tatsächlichen Sammlungen zu unterscheiden, in denen Daten gespeichert sind. Dies führte wiederum dazu, dass die verzögerten Pipeline-Operationen in die neue Stream-Schnittstelle aufgeteilt wurden und nur eifrige, mutative Operationen direkt in Sammlungen ausgeführt wurden. Brian Goetz hat die Gründe dafür erläutert .
Wie wäre es, wenn Sie mehrere Sammlungen für sammlungsbasierte Pipelines zulassen, diese jedoch für nicht sammlungsbasierte Pipelines nicht zulassen? Es ist inkonsistent, aber es ist sinnvoll. Wenn Sie Werte aus dem Netzwerk lesen, können Sie diese natürlich nicht erneut durchlaufen. Wenn Sie sie mehrmals durchlaufen möchten, müssen Sie sie explizit in eine Sammlung ziehen.
Aber lassen Sie uns untersuchen, wie Sie mehrere Sammlungen von sammlungsbasierten Pipelines zulassen können. Angenommen, Sie haben dies getan:
Iterable<?> it = source.filter(...).map(...).filter(...).map(...);
it.into(dest1);
it.into(dest2);
(Die into
Operation ist jetzt geschrieben collect(toList())
.)
Wenn die Quelle eine Sammlung ist, erstellt der erste into()
Aufruf eine Kette von Iteratoren zurück zur Quelle, führt die Pipeline-Operationen aus und sendet die Ergebnisse an das Ziel. Der zweite Aufruf von into()
erstellt eine weitere Kette von Iteratoren und führt die Pipeline-Operationen erneut aus . Dies ist offensichtlich nicht falsch, führt jedoch dazu, dass alle Filter- und Zuordnungsoperationen für jedes Element ein zweites Mal ausgeführt werden. Ich denke, viele Programmierer wären von diesem Verhalten überrascht gewesen.
Wie oben erwähnt, hatten wir mit den Guava-Entwicklern gesprochen. Eines der coolen Dinge, die sie haben, ist ein Ideenfriedhof, auf dem sie Funktionen beschreiben, die sie nicht implementieren wollten, zusammen mit den Gründen. Die Idee von faulen Sammlungen klingt ziemlich cool, aber hier ist, was sie dazu zu sagen haben. Stellen Sie sich eine List.filter()
Operation vor, die Folgendes zurückgibt List
:
Die größte Sorge hierbei ist, dass zu viele Operationen zu teuren Vorschlägen mit linearer Zeit werden. Wenn Sie eine Liste filtern und eine Liste zurückerhalten möchten und nicht nur eine Sammlung oder ein Iterable, können Sie verwenden ImmutableList.copyOf(Iterables.filter(list, predicate))
, was "im Voraus " angibt, was es tut und wie teuer es ist.
Um ein konkretes Beispiel zu nennen: Was kostet eine Liste get(0)
oder steht size()
auf einer Liste? Für häufig verwendete Klassen wie ArrayList
sind sie O (1). Wenn Sie jedoch eine dieser Optionen in einer träge gefilterten Liste aufrufen, muss der Filter über die Hintergrundliste ausgeführt werden, und plötzlich sind diese Operationen O (n). Schlimmer noch, es muss bei jeder Operation die Sicherungsliste durchlaufen .
Dies schien uns zu viel Faulheit zu sein. Es ist eine Sache, einige Operationen einzurichten und die tatsächliche Ausführung zu verschieben, bis Sie so "Los" gehen. Es ist eine andere Sache, die Dinge so einzurichten, dass ein potenziell großer Teil der Neuberechnung verborgen bleibt.
Paul Sandoz schlug vor, nichtlineare oder "nicht wiederverwendbare" Streams zu verbieten, und beschrieb die möglichen Konsequenzen, die sich daraus ergeben, dass sie zu "unerwarteten oder verwirrenden Ergebnissen" führen. Er erwähnte auch, dass die parallele Ausführung die Dinge noch schwieriger machen würde. Abschließend möchte ich hinzufügen, dass eine Pipeline-Operation mit Nebenwirkungen zu schwierigen und undurchsichtigen Fehlern führen würde, wenn die Operation unerwartet mehrmals oder zumindest anders oft als vom Programmierer erwartet ausgeführt würde. (Aber Java-Programmierer schreiben keine Lambda-Ausdrücke mit Nebenwirkungen, oder? TUN SIE?)
Dies ist die grundlegende Begründung für das Java 8 Streams-API-Design, das eine einmalige Durchquerung ermöglicht und eine streng lineare (keine Verzweigung) Pipeline erfordert. Es bietet ein konsistentes Verhalten über mehrere verschiedene Stream-Quellen hinweg, trennt träge von eifrigen Vorgängen klar und bietet ein einfaches Ausführungsmodell.
In Bezug auf IEnumerable
bin ich weit entfernt von einem Experten für C # und .NET, daher würde ich es begrüßen, wenn ich (sanft) korrigiert würde, wenn ich falsche Schlussfolgerungen ziehen würde. Es scheint jedoch möglich zu sein, dass sich IEnumerable
mehrere Durchquerungen mit unterschiedlichen Quellen unterschiedlich verhalten. und es erlaubt eine Verzweigungsstruktur von verschachtelten IEnumerable
Operationen, was zu einer signifikanten Neuberechnung führen kann. Obwohl ich zu schätzen weiß, dass unterschiedliche Systeme unterschiedliche Kompromisse eingehen, sind dies zwei Merkmale, die wir beim Entwurf der Java 8 Streams-API vermeiden wollten.
Das vom OP gegebene Quicksort-Beispiel ist interessant, rätselhaft, und ich muss leider sagen, dass es etwas schrecklich ist. Der Aufruf QuickSort
nimmt ein IEnumerable
und gibt ein zurück IEnumerable
, so dass keine Sortierung durchgeführt wird, bis das Finale IEnumerable
durchlaufen ist. Der Aufruf scheint jedoch eine Baumstruktur aufzubauen IEnumerables
, die die Partitionierung widerspiegelt, die Quicksort ausführen würde, ohne dies tatsächlich zu tun. (Dies ist schließlich eine verzögerte Berechnung.) Wenn die Quelle N Elemente enthält, ist der Baum an seiner breitesten Stelle N Elemente breit und lg (N) Ebenen tief.
Es scheint mir - und ich bin wieder kein C # - oder .NET-Experte -, dass dies dazu führen wird, dass bestimmte harmlos aussehende Aufrufe, wie z. B. die Pivot-Auswahl über ints.First()
, teurer sind als sie aussehen. Auf der ersten Ebene ist es natürlich O (1). Betrachten Sie jedoch eine Trennwand tief im Baum am rechten Rand. Um das erste Element dieser Partition zu berechnen, muss die gesamte Quelle durchlaufen werden, eine O (N) -Operation. Da die obigen Partitionen jedoch faul sind, müssen sie neu berechnet werden, was O (lg N) -Vergleiche erfordert. Die Auswahl des Drehpunkts wäre also eine O (N lg N) -Operation, die so teuer ist wie eine ganze Sortierung.
Aber wir sortieren nicht wirklich, bis wir die zurückgegebenen durchqueren IEnumerable
. Beim Standard-Quicksort-Algorithmus verdoppelt jede Partitionierungsebene die Anzahl der Partitionen. Jede Partition ist nur halb so groß, sodass jede Ebene bei der Komplexität O (N) bleibt. Der Partitionsbaum ist O (lg N) hoch, daher ist die Gesamtarbeit O (N lg N).
Mit dem Baum der faulen IEnumerables befinden sich am unteren Rand des Baums N Partitionen. Das Berechnen jeder Partition erfordert ein Durchlaufen von N Elementen, von denen jedes Ig (N) -Vergleiche im Baum erfordert. Um alle Partitionen am unteren Rand des Baums zu berechnen, sind O (N ^ 2 lg N) -Vergleiche erforderlich.
(Ist das richtig? Ich kann das kaum glauben. Jemand, bitte überprüfen Sie das für mich.)
In jedem Fall ist es in der Tat cool, IEnumerable
auf diese Weise komplizierte Rechenstrukturen aufzubauen. Wenn es jedoch die Rechenkomplexität so stark erhöht, wie ich denke, sollte die Programmierung auf diese Weise vermieden werden, es sei denn, man ist äußerst vorsichtig.