Warum sind Java Streams einmalig?

239

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.

Vitaliy
quelle
4
Könnten Sie ein konkretes Beispiel geben, bei dem das Schließen des Streams "Strom wegnimmt"?
Rogério
23
Wenn Sie Daten aus einem Stream mehrmals verwenden möchten, müssen Sie sie in eine Sammlung kopieren. So muss es funktionieren: Entweder müssen Sie die Berechnung wiederholen, um den Stream zu generieren, oder Sie müssen das Zwischenergebnis speichern.
Louis Wasserman
5
Ok, aber das Wiederholen derselben Berechnung im selben Stream klingt falsch. Ein Stream wird aus einer bestimmten Quelle erstellt, bevor eine Berechnung durchgeführt wird, genau wie Iteratoren für jede Iteration erstellt werden. Ich würde immer noch gerne ein konkretes Beispiel sehen. Am Ende gibt es bestimmt einen sauberen Weg, um jedes Problem mit einmal verwendeten Streams zu lösen, vorausgesetzt, es gibt einen entsprechenden Weg mit den Aufzählungen von C #.
Rogério
2
Das war zunächst verwirrend für mich, weil ich dachte, diese Frage würde C # s IEnumerablemit den Streams vonjava.io.*
SpaceTrucker
9
Beachten Sie, dass die mehrfache Verwendung von IEnumerable in C # ein fragiles Muster ist, sodass die Prämisse der Frage möglicherweise leicht fehlerhaft ist. Viele Implementierungen von IEnumerable erlauben dies, einige jedoch nicht! Code-Analyse-Tools warnen Sie in der Regel davor.
Sander

Antworten:

368

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, mapund 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 countwü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 RegistrantObjekte und druckt sie zweimal aus. Nur dass sie tatsächlich nur einmal ausgedruckt werden. Es stellt sich heraus, dass er dachte, das registrantssei eine Sammlung, obwohl es sich tatsächlich um einen Iterator handelt. Der zweite Aufruf foreachtrifft 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 intoOperation 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 ArrayListsind 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 IEnumerablebin 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 IEnumerablemehrere Durchquerungen mit unterschiedlichen Quellen unterschiedlich verhalten. und es erlaubt eine Verzweigungsstruktur von verschachtelten IEnumerableOperationen, 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 QuickSortnimmt ein IEnumerableund gibt ein zurück IEnumerable, so dass keine Sortierung durchgeführt wird, bis das Finale IEnumerabledurchlaufen 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, IEnumerableauf 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.

Stuart Marks
quelle
35
Zunächst einmal vielen Dank für die großartige und nicht herablassende Antwort! Dies ist bei weitem die genaueste und genaueste Erklärung, die ich bekommen habe. Was das QuickSort-Beispiel angeht, scheinen Sie in Bezug auf Ints Recht zu haben. Erstes Aufblähen, wenn die Rekursionsstufe zunimmt. Ich glaube, dies kann leicht behoben werden, indem 'gt' und 'lt' eifrig berechnet werden (indem die Ergebnisse mit ToArray gesammelt werden). Abgesehen davon unterstützt es sicherlich Ihren Standpunkt, dass diese Art der Programmierung einen unerwarteten Leistungspreis verursachen kann. (Weiter im zweiten Kommentar)
Vitaliy
18
Andererseits kann ich aus meiner Erfahrung mit C # (mehr als 5 Jahre) sagen, dass es nicht so schwer ist, die "redundanten" Berechnungen auszurotten, wenn Sie einmal auf ein Leistungsproblem gestoßen sind (oder verboten wurden, wenn jemand das Undenkbare gemacht und ein eingeführt hat) Nebeneffekt dort). Es schien mir nur, dass zu viele Kompromisse eingegangen wurden, um die Reinheit der API auf Kosten von C # -ähnlichen Möglichkeiten sicherzustellen. Sie haben mir definitiv geholfen, meinen Standpunkt anzupassen.
Vitaliy
7
@Vitaliy Danke für den fairen Gedankenaustausch. Ich habe ein wenig über C # und .NET gelernt, indem ich diese Antwort untersucht und geschrieben habe.
Stuart Marks
10
Kleiner Kommentar: ReSharper ist eine Visual Studio-Erweiterung, die bei C # hilft. Mit dem obigen QuickSort-Code fügt ReSharper für jede Verwendungints eine Warnung hinzu : "Mögliche mehrfache Aufzählung von IEnumerable". Die mehrmalige Verwendung IEenumerableist 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 Unterschied
Kobi
4
@Kobi Sehr interessant, dass es in ReSharper eine solche Warnung gibt. Vielen Dank für den Hinweis auf Ihre Antwort. Ich kenne C # /. NET nicht, daher muss ich es sorgfältig durchgehen, aber es scheint Probleme aufzuweisen, die den oben erwähnten Designproblemen ähneln.
Stuart Marks
122

Hintergrund

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 IEnumerableKonzept von C # enger mit dem von JavaIterable verwandt , mit dem so viele Iteratoren erstellt werden können, wie Sie möchten. IEnumerableserstellen IEnumerators. Java IterableerstelltIterators

Die Geschichte jedes Konzepts ist insofern ähnlich, als beide IEnumerableund Iterableeine 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/ implementiert Iterable, muss diese Klasse mindestens eine einzige Methode implementieren (für C # ist es GetEnumeratorund für Java ist es iterator()). 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

IEnumerablein 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 IEnumerableKonzepts 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 von Iteratorsund Iterableszu 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:

  • Iteratoren beschreiben die Datenfolge.
  • Streams beschreiben eine Folge von Datentransformationen.

Mit a erhalten IteratorSie 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 StreamInstanz gekapselt ist . Mit der Streams-API können Sie eine Folge von StreamInstanzen so verknüpfen , dass eine Folge von Transformationsausdrücken verkettet wird.

Um das StreamKonzept 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 die StreamSequenz selbst ist keine Iterable, sondern eine zusammengesetzte Funktion.

A Streamsoll 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:

  • A Streamin Java ist eine Transformations-Engine, die ein Datenelement in einem Zustand in einen anderen Zustand umwandelt.
  • Streams haben kein Konzept für die Datenreihenfolge oder -position, sondern transformieren einfach, worum sie gebeten werden.
  • Streams können mit Daten aus vielen Quellen versorgt werden, einschließlich anderer Streams, Iteratoren, Iterables, Sammlungen,
  • Sie können einen Stream nicht "zurücksetzen", das wäre wie "Neuprogrammieren der Transformation". Das Zurücksetzen der Datenquelle ist wahrscheinlich das, was Sie wollen.
  • Es befindet sich logischerweise immer nur 1 Datenelement 'im Flug' im Stream (es sei denn, der Stream ist ein paralleler Stream. Zu diesem Zeitpunkt gibt es 1 Element pro Thread). Dies ist unabhängig von der Datenquelle, für die möglicherweise mehr als die aktuellen Elemente "bereit" sind, an den Stream geliefert zu werden, oder vom Stream-Kollektor, der möglicherweise mehrere Werte aggregieren und reduzieren muss.
  • Streams können ungebunden (unendlich), nur durch die Datenquelle oder den Kollektor (der auch unendlich sein kann) begrenzt sein.
  • Streams sind verkettbar, die Ausgabe des Filterns eines Streams ist ein anderer Stream. Werte, die in einen Stream eingegeben und von diesem transformiert werden, können wiederum einem anderen Stream zugeführt werden, der eine andere Transformation durchführt. Die Daten fließen in ihrem transformierten Zustand von einem Strom zum nächsten. Sie müssen nicht eingreifen und die Daten von einem Stream abrufen und in den nächsten einstecken.

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 IEnumerablein 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

  • Hier gibt es kein Designproblem, nur ein Problem beim Abgleichen von Konzepten zwischen den Sprachen.
  • Streams lösen Probleme auf andere Weise
  • Streams erweitern Java um Funktionalität (sie bieten eine andere Vorgehensweise, sie nehmen die Funktionalität nicht weg).

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 IEnumerablemit a Streamist falsch. Der Kontext, den Sie verwenden, um zu sagen, dass er IEnumerableso oft ausgeführt werden kann, wie Sie möchten, ist am besten mit Java zu vergleichen Iterables, das so oft wiederholt werden kann, wie Sie möchten. Ein Java Streamstellt eine Teilmenge des IEnumerableKonzepts 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 IEnumerableeher als eine betrachten Iterable, 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:

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

Sie behandeln die Eingabe IEnumerableals Datenquelle:

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

Darüber hinaus ist auch der Rückgabewert IEnumerableeine Datenversorgung, und da es sich um eine Sortieroperation handelt, ist die Reihenfolge dieser Lieferung von Bedeutung. Wenn Sie die Java- IterableKlasse als die geeignete Übereinstimmung dafür betrachten, insbesondere die ListSpezialisierung von Iterable, da List ein Datenangebot ist, das eine garantierte Reihenfolge oder Iteration aufweist, lautet der Ihrem Code entsprechende Java-Code:

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));
}    

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önnen IEnumerable. Auch wenn ich Listals Basistyp verwendet habe, hätte ich den allgemeineren verwenden können Collection, und mit einer kleinen Iterator-zu-Stream-Konvertierung hätte ich den noch allgemeineren verwenden könnenIterable

rolfl
quelle
9
Wenn Sie daran denken, einen Stream zu "iterieren", machen Sie es falsch. Ein Stream repräsentiert den Status von Daten zu einem bestimmten Zeitpunkt in einer Kette von Transformationen. Daten werden in einer Stream-Quelle in das System eingegeben und fließen dann von einem Stream zum nächsten. Dabei ändert sich der Status, bis sie am Ende gesammelt, reduziert oder gelöscht werden. A Streamist ein Zeitpunktkonzept, keine 'Schleifenoperation' .... (Forts.)
rolfl
7
Bei einem Stream haben Sie Daten, die wie X in den Stream eintreten und wie Y aus dem Stream austreten. Es gibt eine Funktion, die der Stream ausführt, um diese Transformation f(x)durchzuführen. Der Stream kapselt die Funktion, er kapselt nicht die Daten, die durch ihn fließen
rolfl
4
IEnumerablekann auch zufällige Werte liefern, ungebunden sein und aktiv werden, bevor die Daten existieren.
Arturo Torres Sánchez
6
@Vitaliy: Viele Methoden, die eine erhalten, 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 .
Supercat
5
Ihr quickSortBeispiel könnte viel einfacher sein, wenn es a zurückgibt Stream; Es würde zwei .stream()Anrufe und einen .collect(Collectors.toList())Anruf speichern . Wenn Sie dann ersetzen Collections.singleton(pivot).stream()mit Stream.of(pivot)dem Code wird fast lesbar ...
Holger
22

Streams sind um Spliterators 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". Wie Random.ints()sollte mit einer solchen Anfrage umgegangen werden?

Andererseits ist es für Streams, die einen rückverfolgbaren Ursprung haben, einfach, ein Äquivalent Streamzu konstruieren , das wieder verwendet werden soll. Setzen Sie einfach die Schritte ein, um die Streamin 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 , Streamdie von verschieden ist Schließung der Streamwie der Aufruf close()auf dem Strom ist (die für Ströme benötigt haben Ressourcen verbunden wie zB die durch Files.lines()).


Es scheint, dass eine Menge Verwirrung von einem fehlgeleiteten Vergleich IEnumerablemit herrührt Stream. An steht IEnumerablefür die Fähigkeit, ein tatsächliches zu liefern IEnumerator, also ist es wie Iterablein Java. Im Gegensatz dazu ist a Streameine Art Iterator und vergleichbar mit a. IEnumeratorEs ist daher falsch zu behaupten, dass diese Art von Datentyp in .NET mehrfach verwendet werden kann. Die Unterstützung für IEnumerator.Resetist optional. In den hier diskutierten Beispielen wird eher die Tatsache verwendet, dass ein IEnumerablezum Abrufen neuer IEnumerator s verwendet werden kann und dass dies auch mit Java funktioniert Collection. Sie können eine neue bekommen Stream. Wenn die Java-Entwickler beschlossen, die StreamOperationen Iterabledirekt 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 IEnumerablealleine 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ückgeben IEnumerable, 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, dass List.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 Streameinen Typ von Iterable/ unterscheidet, Collectionund der zweite, der Streameine 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. Spliterators kann durch die alten Iterables (wie diese nachgerüstet wurden) oder durch völlig neue Implementierungen bereitgestellt werden . Dann Streamwurde als High-Level-Frontend zu den eher Low-Level- Spliterators 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. Streams sind keine unveränderlichen Datenstrukturen. Jede Zwischenoperation kann eine neue StreamInstanz 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 wie paralleloder, unordereddie 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- StreamAPI übersetzt wurde. Es zeigt, dass es nicht wirklich „viel Kraft wegnimmt“.

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);
}

Es kann wie verwendet werden

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(", ")));

Sie können es noch kompakter schreiben als

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());
}
Holger
quelle
1
Ob verbraucht oder nicht, der Versuch, es erneut zu konsumieren, löst eine Ausnahme aus, dass der Stream bereits geschlossen und nicht konsumiert wurde. Was das Problem beim Zurücksetzen eines Stroms zufälliger Ganzzahlen betrifft, wie Sie sagten, ist es Sache des Schreibers der Bibliothek, den genauen Vertrag für eine Rücksetzoperation zu definieren.
Vitaliy
2
Nein, die Meldung lautet "Stream wurde bereits bearbeitet oder geschlossen" und wir sprachen nicht über eine "Reset" -Operation, sondern über das Aufrufen von zwei oder mehr Terminaloperationen, Streamwährend das Zurücksetzen der Quelle Spliteratorimpliziert wäre. Und ich bin mir ziemlich sicher, ob das möglich war, es gab Fragen zu SO wie "Warum führt ein count()zweimaliger Anruf bei a Streamjedes Mal zu anderen Ergebnissen" usw.
Holger
1
Es ist absolut gültig, dass count () unterschiedliche Ergebnisse liefert. count () ist eine Abfrage in einem Stream. Wenn der Stream veränderbar ist (oder genauer gesagt, der Stream ein Ergebnis einer Abfrage in einer veränderlichen Sammlung darstellt), wird dies erwartet. Schauen Sie sich die API von C # an. Sie gehen mit all diesen Themen anmutig um.
Vitaliy
4
Was Sie als "absolut gültig" bezeichnen, ist ein kontraintuitives Verhalten. Schließlich ist es die Hauptmotivation, nach der mehrfachen Verwendung eines Streams zu fragen, um das erwartete Ergebnis auf unterschiedliche Weise zu verarbeiten. Jede Frage zu SO über die bisher nicht wiederverwendbare Natur von Streams 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 die StreamAPI dies zuließ mit unterschiedlichen Ergebnissen bei jeder Bewertung. Hier ist ein schönes Beispiel .
Holger
3
Ihr Beispiel zeigt perfekt, was passiert, wenn ein Programmierer die Auswirkungen der Anwendung mehrerer Terminaloperationen nicht versteht. Denken Sie nur daran, was passiert, wenn jede dieser Operationen auf einen völlig anderen Satz von Elementen angewendet wird. Es funktioniert nur, wenn die Quelle des Streams bei jeder Abfrage dieselben Elemente zurückgegeben hat, aber dies ist genau die falsche Annahme, über die wir gesprochen haben.
Holger
8

Ich denke, es gibt nur sehr wenige Unterschiede zwischen den beiden, wenn man genau hinschaut.

Auf den ersten Blick IEnumerablescheint ein wiederverwendbares Konstrukt zu sein:

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

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

Der Compiler leistet jedoch tatsächlich ein wenig Arbeit, um uns zu helfen. Es wird der folgende Code generiert:

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

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

Jedes Mal, wenn Sie die Aufzählung tatsächlich durchlaufen, erstellt der Compiler einen Aufzähler. Der Enumerator ist nicht wiederverwendbar. Weitere Aufrufe von MoveNextgeben 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:

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();
    }
}

Jetzt haben wir einen sehr ähnlichen Code wie die vorherige Array-basierte Aufzählung, jedoch mit einer zweiten Iteration über numbers:

IEnumerable<int> numbers = new RandomNumberStream();

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

Beim zweiten Durchlauf erhalten numberswir 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, IEnumerableindem 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 IEnumerableverworfen werden. Wiederverwendung ist nicht so einfach

Andrew Vermie
quelle
2

Es ist möglich, einige der "Einmal ausführen" -Schutzmaßnahmen in der Stream-API zu umgehen. Zum Beispiel können wir java.lang.IllegalStateExceptionAusnahmen vermeiden (mit der Meldung "Stream wurde bereits bearbeitet oder geschlossen"), indem wir auf das Spliterator(und nicht Streamdirekt) verweisen und es wiederverwenden .

Dieser Code wird beispielsweise ohne Ausnahme ausgeführt:

    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);

Die Ausgabe ist jedoch auf begrenzt

prefix-hello
prefix-world

anstatt die Ausgabe zweimal zu wiederholen. Dies liegt daran, dass der ArraySpliteratorals StreamQuelle verwendete Status statusbehaftet ist und seine aktuelle Position speichert. Wenn wir dies wiederholen Stream, beginnen wir am Ende erneut.

Wir haben eine Reihe von Optionen, um diese Herausforderung zu lösen:

  1. Wir könnten eine zustandslose StreamErstellungsmethode wie verwenden Stream#generate(). Wir müssten den Status extern in unserem eigenen Code verwalten und zwischen Stream"Wiederholungen" zurücksetzen :

    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. Eine andere (etwas bessere, aber nicht perfekte) Lösung besteht darin, eine eigene ArraySpliterator(oder ähnliche) StreamQuelle 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 generieren Stream, könnten wir sie möglicherweise erfolgreich wiedergeben.

    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. Die beste Lösung für dieses Problem (meiner Meinung nach) besteht darin, eine neue Kopie aller Spliteratorin der StreamPipeline verwendeten Statefuls zu erstellen, wenn neue Operatoren auf der Pipeline aufgerufen werden Stream. Dies ist komplexer und aufwändiger zu implementieren. Wenn Sie jedoch nichts dagegen haben, Bibliotheken von Drittanbietern zu verwenden, verfügt cyclops-react über eine StreamImplementierung, die genau dies tut. (Offenlegung: Ich bin der Hauptentwickler für dieses Projekt.)

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

Dies wird gedruckt

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

wie erwartet.

John McClean
quelle