Im Gegensatz zu C # IEnumerable
, bei denen eine Ausführungspipeline so oft ausgeführt werden kann, wie wir möchten, kann ein Stream in Java nur einmal "iteriert" werden.
Jeder Aufruf einer Terminaloperation schließt den Stream und macht ihn unbrauchbar. Diese 'Funktion' nimmt viel Energie weg.
Ich stelle mir vor, der Grund dafür ist nicht technisch. Was waren die Designüberlegungen hinter dieser seltsamen Einschränkung?
Bearbeiten: Um zu demonstrieren, wovon ich spreche, sollten Sie die folgende Implementierung von Quick-Sort in C # in Betracht ziehen:
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));
}
Nun, um sicher zu sein, ich befürworte nicht, dass dies eine gute Implementierung der schnellen Sortierung ist! Es ist jedoch ein großartiges Beispiel für die Ausdruckskraft des Lambda-Ausdrucks in Kombination mit der Stream-Operation.
Und das geht nicht in Java! Ich kann nicht einmal einen Stream fragen, ob er leer ist, ohne ihn unbrauchbar zu machen.
quelle
IEnumerable
mit den Streams vonjava.io.*
Antworten:
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 Operationenfilter
,map
und so waren nach Verlängerung (default) Methoden aufIterable
. Durch Aufrufen eines wurde der Kette eine Operation hinzugefügt und eine andere zurückgegebenIterable
. Eine Terminaloperation wiecount
würdeiterator()
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:
[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:
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, dasregistrants
sei eine Sammlung, obwohl es sich tatsächlich um einen Iterator handelt. Der zweite Aufrufforeach
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:
(Die
into
Operation ist jetzt geschriebencollect(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 voninto()
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ückgibtList
:Um ein konkretes Beispiel zu nennen: Was kostet eine Liste
get(0)
oder stehtsize()
auf einer Liste? Für häufig verwendete Klassen wieArrayList
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 sichIEnumerable
mehrere Durchquerungen mit unterschiedlichen Quellen unterschiedlich verhalten. und es erlaubt eine Verzweigungsstruktur von verschachteltenIEnumerable
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 einIEnumerable
und gibt ein zurückIEnumerable
, so dass keine Sortierung durchgeführt wird, bis das FinaleIEnumerable
durchlaufen ist. Der Aufruf scheint jedoch eine Baumstruktur aufzubauenIEnumerables
, 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.quelle
ints
eine Warnung hinzu : "Mögliche mehrfache Aufzählung von IEnumerable". Die mehrmalige VerwendungIEenumerable
ist verdächtig und sollte vermieden werden. Ich möchte auch auf diese Frage verweisen (die ich beantwortet habe), die einige der Vorbehalte beim .Net-Ansatz zeigt (neben der schlechten Leistung): Liste <T> und IEnumerable UnterschiedHintergrund
Während die Frage einfach erscheint, erfordert die eigentliche Antwort einige Hintergrundinformationen, um einen Sinn zu ergeben. Wenn Sie zum Schluss springen möchten, scrollen Sie nach unten ...
Wählen Sie Ihren Vergleichspunkt - Grundfunktionalität
Unter Verwendung grundlegender Konzepte ist das
IEnumerable
Konzept von C # enger mit dem von JavaIterable
verwandt , mit dem so viele Iteratoren erstellt werden können, wie Sie möchten.IEnumerables
erstellenIEnumerators
. JavaIterable
erstelltIterators
Die Geschichte jedes Konzepts ist insofern ähnlich, als beide
IEnumerable
undIterable
eine grundlegende Motivation haben, eine "für jeden" Stilschleife über die Mitglieder von Datensammlungen zu ermöglichen. Das ist eine übermäßige Vereinfachung, da beide mehr als nur das zulassen und auch über verschiedene Progressionen zu diesem Stadium gekommen sind, aber es ist trotzdem ein bedeutendes gemeinsames Merkmal.Vergleichen wir diese Funktion: Wenn eine Klasse in beiden Sprachen das
IEnumerable
/ implementiertIterable
, muss diese Klasse mindestens eine einzige Methode implementieren (für C # ist esGetEnumerator
und für Java ist esiterator()
). In jedem Fall können Sie mit der von diesem (IEnumerator
/Iterator
) zurückgegebenen Instanz auf die aktuellen und nachfolgenden Mitglieder der Daten zugreifen. Diese Funktion wird in der Syntax für jede Sprache verwendet.Wählen Sie Ihren Vergleichspunkt - Erweiterte Funktionalität
IEnumerable
in C # wurde erweitert, um eine Reihe anderer Sprachfunktionen zu ermöglichen ( hauptsächlich im Zusammenhang mit Linq ). Zu den hinzugefügten Funktionen gehören Auswahlen, Projektionen, Aggregationen usw. Diese Erweiterungen haben eine starke Motivation für die Verwendung in der Mengenlehre, ähnlich wie bei SQL- und relationalen Datenbankkonzepten.In Java 8 wurden außerdem Funktionen hinzugefügt, um ein gewisses Maß an funktionaler Programmierung mithilfe von Streams und Lambdas zu ermöglichen. Beachten Sie, dass Java 8-Streams nicht primär durch die Mengenlehre, sondern durch funktionale Programmierung motiviert sind. Unabhängig davon gibt es viele Parallelen.
Das ist also der zweite Punkt. Die an C # vorgenommenen Verbesserungen wurden als Erweiterung des
IEnumerable
Konzepts implementiert . In Java wurden die vorgenommenen Verbesserungen jedoch implementiert, indem neue Basiskonzepte für Lambdas und Streams erstellt wurden und anschließend eine relativ triviale Methode zum Konvertieren vonIterators
undIterables
zu Streams und umgekehrt erstellt wurde.Der Vergleich von IEnumerable mit dem Stream-Konzept von Java ist daher unvollständig. Sie müssen es mit den kombinierten Streams und Sammlungen-APIs in Java vergleichen.
In Java sind Streams nicht mit Iterables oder Iteratoren identisch
Streams sind nicht dafür ausgelegt, Probleme auf die gleiche Weise zu lösen wie Iteratoren:
Mit a erhalten
Iterator
Sie einen Datenwert, verarbeiten ihn und erhalten dann einen anderen Datenwert.Mit Streams verketten Sie eine Folge von Funktionen miteinander, geben dem Stream einen Eingabewert und erhalten den Ausgabewert aus der kombinierten Folge. Beachten Sie, dass in Java jede Funktion in einer einzelnen
Stream
Instanz gekapselt ist . Mit der Streams-API können Sie eine Folge vonStream
Instanzen so verknüpfen , dass eine Folge von Transformationsausdrücken verkettet wird.Um das
Stream
Konzept zu vervollständigen , benötigen Sie eine Datenquelle, um den Stream zu speisen, und eine Terminalfunktion, die den Stream verbraucht.Die Art und Weise, wie Sie Werte in den Stream einspeisen, kann tatsächlich von einer stammen
Iterable
, aber dieStream
Sequenz selbst ist keineIterable
, sondern eine zusammengesetzte Funktion.A
Stream
soll auch faul sein, in dem Sinne, dass es nur funktioniert, wenn Sie einen Wert von ihm anfordern.Beachten Sie diese wesentlichen Annahmen und Merkmale von Streams:
Stream
in Java ist eine Transformations-Engine, die ein Datenelement in einem Zustand in einen anderen Zustand umwandelt.C # -Vergleich
Wenn Sie bedenken, dass ein Java-Stream nur ein Teil eines Versorgungs-, Stream- und Sammelsystems ist und dass Streams und Iteratoren häufig zusammen mit Sammlungen verwendet werden, ist es kein Wunder, dass es schwierig ist, sich auf dieselben Konzepte zu beziehen Fast alle sind
IEnumerable
in C # in ein einziges Konzept eingebettet .Teile von IEnumerable (und eng verwandten Konzepten) sind in allen Konzepten von Java Iterator, Iterable, Lambda und Stream enthalten.
Es gibt kleine Dinge, die die Java-Konzepte tun können, die in IEnumerable schwieriger sind, und umgekehrt.
Fazit
Durch das Hinzufügen von Streams haben Sie mehr Auswahlmöglichkeiten beim Lösen von Problemen. Dies kann als "Leistungssteigerung" klassifiziert werden, nicht als "Reduzieren", "Wegnehmen" oder "Einschränken".
Warum sind Java Streams einmalig?
Diese Frage ist falsch, da Streams Funktionssequenzen und keine Daten sind. Abhängig von der Datenquelle, die den Stream speist, können Sie die Datenquelle zurücksetzen und denselben oder einen anderen Stream füttern.
Im Gegensatz zu IEnumerable von C #, bei dem eine Ausführungspipeline so oft ausgeführt werden kann, wie wir möchten, kann ein Stream in Java nur einmal "iteriert" werden.
Der Vergleich von a
IEnumerable
mit aStream
ist falsch. Der Kontext, den Sie verwenden, um zu sagen, dass erIEnumerable
so oft ausgeführt werden kann, wie Sie möchten, ist am besten mit Java zu vergleichenIterables
, das so oft wiederholt werden kann, wie Sie möchten. Ein JavaStream
stellt eine Teilmenge desIEnumerable
Konzepts dar und nicht die Teilmenge, die Daten liefert, und kann daher nicht erneut ausgeführt werden.Jeder Aufruf einer Terminaloperation schließt den Stream und macht ihn unbrauchbar. Diese 'Funktion' nimmt viel Energie weg.
Die erste Aussage ist in gewissem Sinne wahr. Die Aussage "Macht wegnehmen" ist es nicht. Sie vergleichen immer noch Streams it IEnumerables. Die Terminaloperation im Stream ist wie eine 'break'-Klausel in einer for-Schleife. Es steht Ihnen jederzeit frei, einen anderen Stream zu haben, wenn Sie möchten und wenn Sie die benötigten Daten erneut bereitstellen können. Wiederum, wenn Sie das
IEnumerable
eher als eine betrachtenIterable
, macht Java es für diese Aussage ganz gut.Ich stelle mir vor, der Grund dafür ist nicht technisch. Was waren die Designüberlegungen hinter dieser seltsamen Einschränkung?
Der Grund ist technisch und aus dem einfachen Grund, dass ein Stream eine Teilmenge dessen ist, was er denkt. Die Stream-Teilmenge steuert nicht die Datenversorgung, daher sollten Sie die Versorgung und nicht den Stream zurücksetzen. In diesem Zusammenhang ist es nicht so seltsam.
QuickSort-Beispiel
Ihr Quicksort-Beispiel hat die Signatur:
Sie behandeln die Eingabe
IEnumerable
als Datenquelle:Darüber hinaus ist auch der Rückgabewert
IEnumerable
eine Datenversorgung, und da es sich um eine Sortieroperation handelt, ist die Reihenfolge dieser Lieferung von Bedeutung. Wenn Sie die Java-Iterable
Klasse als die geeignete Übereinstimmung dafür betrachten, insbesondere dieList
Spezialisierung vonIterable
, da List ein Datenangebot ist, das eine garantierte Reihenfolge oder Iteration aufweist, lautet der Ihrem Code entsprechende Java-Code:Beachten Sie, dass es einen Fehler gibt (den ich reproduziert habe), da die Sortierung doppelte Werte nicht ordnungsgemäß verarbeitet, sondern eine Sortierung mit eindeutigen Werten ist.
Beachten Sie auch, wie der Java-Code Datenquelle (
List
) verwendet und Konzepte an verschiedenen Stellen streamt und dass diese beiden 'Persönlichkeiten' in C # in just ausgedrückt werden könnenIEnumerable
. Auch wenn ichList
als Basistyp verwendet habe, hätte ich den allgemeineren verwenden könnenCollection
, und mit einer kleinen Iterator-zu-Stream-Konvertierung hätte ich den noch allgemeineren verwenden könnenIterable
quelle
Stream
ist ein Zeitpunktkonzept, keine 'Schleifenoperation' .... (Forts.)f(x)
durchzuführen. Der Stream kapselt die Funktion, er kapselt nicht die Daten, die durch ihn fließenIEnumerable
kann auch zufällige Werte liefern, ungebunden sein und aktiv werden, bevor die Daten existieren.IEnumerable<T>
erwarten, dass sie eine endliche Sammlung darstellen, die möglicherweise mehrmals wiederholt wird. Einige Dinge, die iterierbar sind, aber diese Bedingungen nicht erfüllen, werden implementiert,IEnumerable<T>
da keine andere Standardschnittstelle in die Rechnung passt. Methoden, die endliche Sammlungen erwarten, die mehrmals iteriert werden können, können jedoch abstürzen, wenn iterierbare Dinge gegeben werden, die diese Bedingungen nicht erfüllen .quickSort
Beispiel könnte viel einfacher sein, wenn es a zurückgibtStream
; Es würde zwei.stream()
Anrufe und einen.collect(Collectors.toList())
Anruf speichern . Wenn Sie dann ersetzenCollections.singleton(pivot).stream()
mitStream.of(pivot)
dem Code wird fast lesbar ...Stream
s sind umSpliterator
s herum aufgebaut, die zustandsbehaftete, veränderbare Objekte sind. Sie haben keine "Reset" -Aktion, und tatsächlich würde die Notwendigkeit, eine solche Rückspulaktion zu unterstützen, "viel Strom wegnehmen". WieRandom.ints()
sollte mit einer solchen Anfrage umgegangen werden?Andererseits ist es für
Stream
s, die einen rückverfolgbaren Ursprung haben, einfach, ein ÄquivalentStream
zu konstruieren , das wieder verwendet werden soll. Setzen Sie einfach die Schritte ein, um dieStream
in eine wiederverwendbare Methode zu konstruieren . Denken Sie daran, dass das Wiederholen dieser Schritte keine teure Operation ist, da alle diese Schritte verzögerte Operationen sind. Die eigentliche Arbeit beginnt mit dem Terminalbetrieb und je nach tatsächlichem Terminalbetrieb kann ein völlig anderer Code ausgeführt werden.Es liegt an Ihnen, dem Verfasser einer solchen Methode, anzugeben, was das zweimalige Aufrufen der Methode impliziert: Gibt sie genau dieselbe Sequenz wieder, wie es Streams tun, die für ein unverändertes Array oder eine unveränderte Sammlung erstellt wurden, oder erzeugt sie einen Stream mit a ähnliche Semantik, aber unterschiedliche Elemente wie ein Strom von zufälligen Ints oder ein Strom von Konsoleneingabezeilen usw.
By the way, um Verwirrung zu vermeiden, ein Terminalbetrieb verbraucht die ,
Stream
die von verschieden ist Schließung derStream
wie der Aufrufclose()
auf dem Strom ist (die für Ströme benötigt haben Ressourcen verbunden wie zB die durchFiles.lines()
).Es scheint, dass eine Menge Verwirrung von einem fehlgeleiteten Vergleich
IEnumerable
mit herrührtStream
. An stehtIEnumerable
für die Fähigkeit, ein tatsächliches zu liefernIEnumerator
, also ist es wieIterable
in Java. Im Gegensatz dazu ist aStream
eine Art Iterator und vergleichbar mit a.IEnumerator
Es ist daher falsch zu behaupten, dass diese Art von Datentyp in .NET mehrfach verwendet werden kann. Die Unterstützung fürIEnumerator.Reset
ist optional. In den hier diskutierten Beispielen wird eher die Tatsache verwendet, dass einIEnumerable
zum Abrufen neuerIEnumerator
s verwendet werden kann und dass dies auch mit Java funktioniertCollection
. Sie können eine neue bekommenStream
. Wenn die Java-Entwickler beschlossen, dieStream
OperationenIterable
direkt hinzuzufügen , wobei Zwischenoperationen eine andere zurückgebenIterable
, es war wirklich vergleichbar und es könnte genauso funktionieren.Die Entwickler haben sich jedoch dagegen entschieden und die Entscheidung wird in dieser Frage diskutiert . Der größte Punkt ist die Verwirrung über eifrige Sammlungsoperationen und faule Stream-Operationen. Wenn ich mir die .NET-API anschaue, finde ich sie (ja, persönlich) gerechtfertigt. Wenn man es
IEnumerable
alleine betrachtet, sieht es vernünftig aus , wenn eine bestimmte Sammlung viele Methoden enthält, die die Sammlung direkt manipulieren, und viele Methoden, die eine Faulheit zurückgebenIEnumerable
, während die besondere Natur einer Methode nicht immer intuitiv erkennbar ist. Das schlechteste Beispiel, das ich gefunden habe (innerhalb der wenigen Minuten, in denen ich es mir angesehen habe), ist, dassList.Reverse()
sein Name genau mit dem Namen des geerbten übereinstimmt (ist dies der richtige Terminus für Erweiterungsmethoden?),Enumerable.Reverse()
Während er ein völlig widersprüchliches Verhalten aufweist.Dies sind natürlich zwei unterschiedliche Entscheidungen. Der erste, der
Stream
einen Typ vonIterable
/ unterscheidet,Collection
und der zweite, derStream
eine Art einmaligen Iterator anstelle einer anderen Art von iterierbar macht. Aber diese Entscheidung wurde zusammen getroffen und es könnte sein, dass die Trennung dieser beiden Entscheidungen nie in Betracht gezogen wurde. Es wurde nicht erstellt, um mit .NET vergleichbar zu sein.Die eigentliche Entscheidung für das API-Design bestand darin, einen verbesserten Iteratortyp hinzuzufügen, den
Spliterator
.Spliterator
s kann durch die altenIterable
s (wie diese nachgerüstet wurden) oder durch völlig neue Implementierungen bereitgestellt werden . DannStream
wurde als High-Level-Frontend zu den eher Low-Level-Spliterator
s hinzugefügt . Das ist es. Sie können darüber diskutieren, ob ein anderes Design besser wäre, aber das ist nicht produktiv, es wird sich nicht ändern, wenn man bedenkt, wie sie jetzt entworfen werden.Es gibt noch einen weiteren Implementierungsaspekt, den Sie berücksichtigen müssen.
Stream
s sind keine unveränderlichen Datenstrukturen. Jede Zwischenoperation kann eine neueStream
Instanz zurückgeben, die die alte kapselt, aber sie kann stattdessen auch ihre eigene Instanz manipulieren und sich selbst zurückgeben (was nicht ausschließt, auch nur beide für dieselbe Operation auszuführen). Allgemein bekannte Beispiele sind Operationen wieparallel
oder,unordered
die keinen weiteren Schritt hinzufügen, sondern die gesamte Pipeline manipulieren. Eine solch veränderliche Datenstruktur zu haben und zu versuchen, sie wiederzuverwenden (oder noch schlimmer, sie mehrmals gleichzeitig zu verwenden), spielt nicht gut…Der Vollständigkeit halber finden Sie hier Ihr QuickSort-Beispiel, das in die Java-
Stream
API übersetzt wurde. Es zeigt, dass es nicht wirklich „viel Kraft wegnimmt“.Es kann wie verwendet werden
Sie können es noch kompakter schreiben als
quelle
Stream
während das Zurücksetzen der QuelleSpliterator
impliziert wäre. Und ich bin mir ziemlich sicher, ob das möglich war, es gab Fragen zu SO wie "Warum führt eincount()
zweimaliger Anruf bei aStream
jedes Mal zu anderen Ergebnissen" usw.Stream
s ergibt sich aus dem Versuch, ein Problem durch mehrmaliges Aufrufen von Terminaloperationen zu lösen (offensichtlich, sonst bemerken Sie es nicht), was zu einer stillschweigend fehlerhaften Lösung führte, wenn dieStream
API dies zuließ mit unterschiedlichen Ergebnissen bei jeder Bewertung. Hier ist ein schönes Beispiel .Ich denke, es gibt nur sehr wenige Unterschiede zwischen den beiden, wenn man genau hinschaut.
Auf den ersten Blick
IEnumerable
scheint ein wiederverwendbares Konstrukt zu sein:Der Compiler leistet jedoch tatsächlich ein wenig Arbeit, um uns zu helfen. Es wird der folgende Code generiert:
Jedes Mal, wenn Sie die Aufzählung tatsächlich durchlaufen, erstellt der Compiler einen Aufzähler. Der Enumerator ist nicht wiederverwendbar. Weitere Aufrufe von
MoveNext
geben nur false zurück, und es gibt keine Möglichkeit, es auf den Anfang zurückzusetzen. Wenn Sie die Zahlen erneut durchlaufen möchten, müssen Sie eine weitere Enumerator-Instanz erstellen.Um besser zu veranschaulichen, dass der IEnumerable dieselbe 'Funktion' wie ein Java-Stream hat (haben kann), betrachten Sie einen Enumerable, dessen Quelle der Zahlen keine statische Sammlung ist. Zum Beispiel können wir ein aufzählbares Objekt erstellen, das eine Folge von 5 Zufallszahlen erzeugt:
Jetzt haben wir einen sehr ähnlichen Code wie die vorherige Array-basierte Aufzählung, jedoch mit einer zweiten Iteration über
numbers
:Beim zweiten Durchlauf erhalten
numbers
wir eine andere Zahlenfolge, die nicht im gleichen Sinne wiederverwendbar ist. Oder wir hätten das schreiben könnenRandomNumberStream
um eine Ausnahme auszulösen, wenn Sie versuchen, mehrmals darüber zu iterieren, wodurch die Aufzählung tatsächlich unbrauchbar wird (wie ein Java-Stream).Was bedeutet Ihre auf Aufzählungen basierende schnelle Sortierung, wenn sie auf a angewendet wird
RandomNumberStream
?Fazit
Der größte Unterschied besteht also darin, dass Sie mit .NET eine wiederverwenden können,
IEnumerable
indem Sie implizit eine neue erstellenIEnumerator
im Hintergrund wenn auf Elemente in der Sequenz zugegriffen werden muss.Dieses implizite Verhalten ist oft nützlich (und "mächtig", wie Sie sagen), da wir eine Sammlung wiederholt durchlaufen können.
Aber manchmal kann dieses implizite Verhalten tatsächlich Probleme verursachen. Wenn Ihre Datenquelle nicht statisch ist oder der Zugriff teuer ist (z. B. eine Datenbank oder eine Website), müssen viele Annahmen
IEnumerable
verworfen werden. Wiederverwendung ist nicht so einfachquelle
Es ist möglich, einige der "Einmal ausführen" -Schutzmaßnahmen in der Stream-API zu umgehen. Zum Beispiel können wir
java.lang.IllegalStateException
Ausnahmen vermeiden (mit der Meldung "Stream wurde bereits bearbeitet oder geschlossen"), indem wir auf dasSpliterator
(und nichtStream
direkt) verweisen und es wiederverwenden .Dieser Code wird beispielsweise ohne Ausnahme ausgeführt:
Die Ausgabe ist jedoch auf begrenzt
anstatt die Ausgabe zweimal zu wiederholen. Dies liegt daran, dass der
ArraySpliterator
alsStream
Quelle verwendete Status statusbehaftet ist und seine aktuelle Position speichert. Wenn wir dies wiederholenStream
, beginnen wir am Ende erneut.Wir haben eine Reihe von Optionen, um diese Herausforderung zu lösen:
Wir könnten eine zustandslose
Stream
Erstellungsmethode wie verwendenStream#generate()
. Wir müssten den Status extern in unserem eigenen Code verwalten und zwischenStream
"Wiederholungen" zurücksetzen :Eine andere (etwas bessere, aber nicht perfekte) Lösung besteht darin, eine eigene
ArraySpliterator
(oder ähnliche)Stream
Quelle zu schreiben , die eine gewisse Kapazität zum Zurücksetzen des aktuellen Zählers enthält. Wenn wir es verwenden würden, um das zu generierenStream
, könnten wir sie möglicherweise erfolgreich wiedergeben.Die beste Lösung für dieses Problem (meiner Meinung nach) besteht darin, eine neue Kopie aller
Spliterator
in derStream
Pipeline verwendeten Statefuls zu erstellen, wenn neue Operatoren auf der Pipeline aufgerufen werdenStream
. Dies ist komplexer und aufwändiger zu implementieren. Wenn Sie jedoch nichts dagegen haben, Bibliotheken von Drittanbietern zu verwenden, verfügt cyclops-react über eineStream
Implementierung, die genau dies tut. (Offenlegung: Ich bin der Hauptentwickler für dieses Projekt.)Dies wird gedruckt
wie erwartet.
quelle