Dlaczego strumienie Java są jednorazowe?

239

W przeciwieństwie do C # IEnumerable, gdzie potok wykonania może być wykonywany tyle razy, ile chcemy, w Javie strumień można „iterować” tylko raz.

Każde wywołanie operacji terminalowej zamyka strumień, co czyni go bezużytecznym. Ta „funkcja” zabiera dużo energii.

Wyobrażam sobie, że powód tego nie jest techniczny. Jakie były uwagi projektowe stojące za tym dziwnym ograniczeniem?

Edycja: aby pokazać, o czym mówię, rozważ następującą implementację szybkiego sortowania w C #:

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

  int pivot = ints.First();

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

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

Teraz, dla pewności, nie opowiadam się za tym, aby była to dobra implementacja szybkiego sortowania! Jest to jednak świetny przykład ekspresyjnej mocy ekspresji lambda w połączeniu z operacją strumieniową.

I nie można tego zrobić w Javie! Nie mogę nawet zapytać strumienia, czy jest pusty, nie czyniąc go bezużytecznym.

Witalij
źródło
4
Czy możesz podać konkretny przykład, w którym zamknięcie strumienia „odbiera energię”?
Rogério
23
Jeśli chcesz użyć danych ze strumienia więcej niż raz, musisz zrzucić je do kolekcji. Tak to w zasadzie musi działać: albo musisz powtórzyć obliczenia, aby wygenerować strumień, albo musisz zapisać wynik pośredni.
Louis Wasserman,
5
Ok, ale powtórzenie tego samego obliczenia w tym samym strumieniu brzmi źle. Strumień jest tworzony z danego źródła przed wykonaniem obliczeń, podobnie jak iteratory są tworzone dla każdej iteracji. Nadal chciałbym zobaczyć konkretny konkretny przykład; w końcu założę się, że istnieje czysty sposób na rozwiązanie każdego problemu ze strumieniami jednorazowego użytku, przy założeniu, że istnieje odpowiedni sposób z wyliczeniami C #.
Rogério,
2
Na początku było to dla mnie mylące, ponieważ myślałem, że to pytanie odniesie C # IEnumerabledo strumienijava.io.*
SpaceTrucker
9
Zauważ, że wielokrotne używanie IEnumerable w C # jest delikatnym wzorem, więc przesłanka pytania może być nieco błędna. Wiele implementacji IEnumerable pozwala, ale niektóre nie! Narzędzia do analizy kodu mają tendencję do ostrzegania przed robieniem czegoś takiego.
Sander

Odpowiedzi:

368

Mam kilka wspomnień z wczesnego projektu interfejsu API Streams, które mogą rzucić nieco światła na uzasadnienie projektu.

W 2012 r. Dodawaliśmy lambdy do tego języka i chcieliśmy zbioru operacji opartych na kolekcjach lub „zbiorczych danych”, zaprogramowanych przy użyciu lambd, które ułatwiłyby równoległość. Pomysł leniwego łączenia operacji został w tym miejscu dobrze przyjęty. Nie chcieliśmy też, aby operacje pośrednie zapisywały wyniki.

Głównymi problemami, które musieliśmy podjąć, były: jak wyglądały obiekty w łańcuchu w interfejsie API i jak podłączyły się do źródeł danych. Źródłami były często kolekcje, ale chcieliśmy również obsługiwać dane pochodzące z pliku lub sieci lub dane generowane w locie, np. Z generatora liczb losowych.

Prace nad projektem miały wiele wpływów. Bardziej wpływowe były między innymi biblioteka Google Guava i biblioteka kolekcji Scala. (Jeśli ktoś jest zaskoczony wpływem Guavy , zauważ, że Kevin Bourrillion , główny programista Guava, był w grupie ekspertów JSR-335 Lambda .) W kolekcjach Scali stwierdziliśmy, że ta rozmowa Martina Oderskiego jest szczególnie interesująca: Future- Sprawdzanie kolekcji Scala: od Zmiennych przez Trwałe do Równoległych . (Stanford EE380, 1 czerwca 2011 r.)

Nasz ówczesny projekt prototypu opierał się wokół Iterable. Znajome operacje filter, mapi tak dalej były przedłużające (domyślnie) na metody Iterable. Wywołanie jednego dodało operację do łańcucha i zwróciło inną Iterable. Operacja terminalowa, jak countwywołałaby iterator()łańcuch do źródła, a operacje zostały zaimplementowane w Iteratorze każdego etapu.

Ponieważ są to Iterables, możesz wywołać tę iterator()metodę więcej niż jeden raz. Co zatem powinno się stać?

Jeśli źródłem jest kolekcja, działa to głównie dobrze. Kolekcje są Iterowalne, a każde wywołanie iterator()tworzy odrębną instancję Iteratora, która jest niezależna od wszelkich innych aktywnych instancji, i każda z nich niezależnie przechodzi przez kolekcję. Wspaniały.

Co teraz, jeśli źródłem jest jedno ujęcie, na przykład czytanie linii z pliku? Może pierwszy iterator powinien otrzymać wszystkie wartości, ale drugi i kolejne powinny być puste. Może wartości powinny być przeplatane między iteratorami. A może każdy Iterator powinien otrzymać te same wartości. A co, jeśli masz dwa iteratory, a jeden z nich wyprzedza drugi? Ktoś będzie musiał buforować wartości w drugim Iteratorze, dopóki nie zostaną odczytane. Gorzej, co jeśli zdobędziesz jeden Iterator i przeczytasz wszystkie wartości, a dopiero potem dostaniesz drugi Iterator. Skąd pochodzą te wartości? Czy istnieje wymóg buforowania ich wszystkich na wypadek, gdyby ktoś chciał mieć drugi iterator?

Oczywiste jest, że dopuszczenie wielu iteratorów w jednym źródle budzi wiele pytań. Nie mieliśmy dla nich dobrych odpowiedzi. Chcieliśmy spójnego, przewidywalnego zachowania w przypadku tego, co nastąpi, jeśli zadzwonisz iterator()dwukrotnie. To popchnęło nas w kierunku niedopuszczenia do wielokrotnych przejść, co sprawiło, że rurociągi były jednym strzałem.

Zauważyliśmy również, że inni wpadali na te problemy. W JDK większość Iterabeli to kolekcje lub obiekty podobne do kolekcji, które umożliwiają wielokrotne przechodzenie. Nigdzie nie jest to określone, ale wydawało się, że istnieje niepisane oczekiwanie, że Iterables zezwoli na wielokrotne przechodzenie. Godnym uwagi wyjątkiem jest interfejs NIO DirectoryStream . Jego specyfikacja zawiera to interesujące ostrzeżenie:

Chociaż DirectoryStream rozszerza Iterable, nie jest to Iterable ogólnego przeznaczenia, ponieważ obsługuje tylko jeden Iterator; wywołanie metody iteratora w celu uzyskania drugiego lub kolejnego iteratora zgłasza IllegalStateException.

[pogrubiony w oryginale]

Wydawało się to dość niezwykłe i nieprzyjemne, że nie chcieliśmy tworzyć całej gamy nowych Iterabeli, które mogą być jednorazowe. To odepchnęło nas od korzystania z Iterable.

Mniej więcej w tym czasie ukazał się artykuł Bruce'a Eckela, który opisał problem z Scalą. Napisał ten kod:

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

To całkiem proste. Analizuje wiersze tekstu na Registrantobiekty i drukuje je dwukrotnie. Tyle że drukuje je tylko raz. Okazuje się, że myślał, że registrantsto zbiór, podczas gdy w rzeczywistości jest to iterator. Drugie wywołanie foreachnapotyka pusty iterator, z którego wszystkie wartości zostały wyczerpane, więc nic nie drukuje.

Tego rodzaju doświadczenie przekonało nas, że bardzo ważne jest, aby uzyskać wyraźnie przewidywalne wyniki, jeśli podjęto próbę wielokrotnego przejścia. Podkreślono także znaczenie odróżnienia leniwych struktur przypominających potoki od rzeczywistych kolekcji przechowujących dane. To z kolei doprowadziło do rozdzielenia leniwych operacji potokowych na nowy interfejs Stream i utrzymywanie tylko chętnych, mutatywnych operacji bezpośrednio na kolekcjach. Brian Goetz wyjaśnił uzasadnienie tego.

Co powiesz na zezwolenie na wielokrotne przechodzenie dla rurociągów opartych na kolekcji, ale nie zezwalanie na rurociągi nie oparte na kolekcji? To niespójne, ale rozsądne. Jeśli czytasz wartości z sieci, oczywiście nie możesz przejść ponownie. Jeśli chcesz przemierzać je wiele razy, musisz jawnie wciągnąć je do kolekcji.

Ale zbadajmy, pozwalając na wielokrotne przechodzenie z rurociągów opartych na kolekcjach. Powiedzmy, że to zrobiłeś:

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

( intoOperacja jest teraz pisana collect(toList()).)

Jeśli źródło jest kolekcją, pierwsze into()wywołanie utworzy łańcuch Iteratorów z powrotem do źródła, wykona operacje potokowe i wyśle ​​wyniki do miejsca docelowego. Drugie wywołanie into()spowoduje utworzenie kolejnego łańcucha Iteratorów i ponowne wykonanie operacji potoku . Nie jest to oczywiście złe, ale powoduje, że wszystkie operacje filtrowania i mapowania wykonywane są po raz drugi dla każdego elementu. Myślę, że wielu programistów byłoby zaskoczonych takim zachowaniem.

Jak wspomniałem powyżej, rozmawialiśmy z programistami Guava. Jedną z fajnych rzeczy, jakie mają, jest Cmentarz pomysłów, w którym opisują funkcje, których nie zdecydowali się wdrożyć wraz z uzasadnieniem. Pomysł na leniwe kolekcje brzmi całkiem fajnie, ale oto, co mają do powiedzenia na ten temat. Rozważ List.filter()operację, która zwraca List:

Największym problemem jest tutaj to, że zbyt wiele operacji staje się kosztownymi propozycjami w czasie liniowym. Jeśli chcesz przefiltrować listę i odzyskać listę, a nie tylko kolekcję lub Iterable, możesz użyć ImmutableList.copyOf(Iterables.filter(list, predicate)), który „z góry określa”, co robi i jak jest drogi.

Aby podać konkretny przykład, jaki jest koszt get(0)lub size()na liście? Dla często używanych klas, takich jak ArrayList, są O (1). Ale jeśli wywołasz jedną z nich na leniwie odfiltrowanej liście, musi ona uruchomić filtr nad listą kopii zapasowych i nagle te operacje są O (n). Co gorsza, musi on przechodzić przez listę kopii zapasowych przy każdej operacji.

Wydawało nam się to zbyt dużym lenistwem. Jedną rzeczą jest skonfigurowanie niektórych operacji i odłożenie rzeczywistego wykonania, dopóki nie „przejdziesz”. Kolejnym jest ustawienie rzeczy w taki sposób, aby ukryć potencjalnie dużą liczbę ponownych obliczeń.

Proponując niedopuszczenie do strumieni nieliniowych lub strumieni „bez ponownego użycia”, Paul Sandoz opisał potencjalne konsekwencje dopuszczenia ich jako powodujące „nieoczekiwane lub mylące wyniki”. Wspomniał również, że równoległe wykonywanie sprawi, że będzie to jeszcze trudniejsze. Na koniec dodam, że operacja potokowa z efektami ubocznymi prowadziłaby do trudnych i niejasnych błędów, gdyby operacja była nieoczekiwanie wykonywana wiele razy lub przynajmniej inną liczbę razy, niż oczekiwał programista. (Ale programiści Java nie piszą wyrażeń lambda z efektami ubocznymi, prawda?

Jest to więc podstawowe uzasadnienie dla zaprojektowania interfejsu API Java 8 Streams, który umożliwia jednorazowe przejście i który wymaga ściśle liniowego (bez rozgałęzienia) potoku. Zapewnia spójne zachowanie dla wielu różnych źródeł strumienia, wyraźnie oddziela leniwe od chętnych operacji i zapewnia prosty model wykonania.


Jeśli chodzi o IEnumerable, jestem daleki od eksperta w C # i .NET, więc byłbym wdzięczny za poprawienie (delikatnie), jeśli wyciągnę niepoprawne wnioski. Wydaje się jednak, że IEnumerablepozwala wielokrotnemu przechodzeniu zachowywać się inaczej z różnymi źródłami; i pozwala na rozgałęzioną strukturę IEnumerableoperacji zagnieżdżonych , co może spowodować pewne znaczące ponowne obliczenia. Chociaż doceniam fakt, że różne systemy powodują różne kompromisy, są to dwie cechy, których staraliśmy się unikać w projekcie interfejsu API Java 8 Streams.

Przykład Quicksort podany przez OP jest interesujący, zagadkowy i przykro mi to powiedzieć, nieco przerażający. Wywołanie QuickSortwymaga IEnumerablei zwraca an IEnumerable, więc sortowanie nie jest wykonywane, dopóki finał nie IEnumerablezostanie przemierzony. Wydaje się jednak, że wywołanie polega na utworzeniu struktury drzewa IEnumerablesodzwierciedlającej partycjonowanie, które wykonałby quicksort, bez faktycznego wykonania tego. (W końcu to leniwe obliczenie.) Jeśli źródło ma N elementów, drzewo będzie miało N elementów w najszerszym miejscu i będzie miało głębokość poziomów lg (N).

Wydaje mi się - i po raz kolejny nie jestem ekspertem w C # ani .NET - że spowoduje to, że niektóre niewinnie wyglądające połączenia, takie jak wybór przestawny ints.First(), będą droższe niż się wydaje. Na pierwszym poziomie jest oczywiście O (1). Ale rozważ partycję głęboko w drzewie, po prawej stronie. Aby obliczyć pierwszy element tej partycji, należy przejść całe źródło, operacja O (N). Ale ponieważ powyższe partycje są leniwe, należy je ponownie obliczyć, wymagając porównań O (lg N). Zatem wybranie osi przestawnej byłoby operacją O (N lg N), która jest tak samo droga jak cały rodzaj.

Ale tak naprawdę nie sortujemy, dopóki nie przejdziemy zwróconych IEnumerable. W standardowym algorytmie szybkiego sortowania każdy poziom partycjonowania podwaja liczbę partycji. Każda partycja ma tylko połowę wielkości, więc każdy poziom ma złożoność O (N). Drzewo partycji ma wysokość O (lg N), więc całkowita praca to O (N lg N).

Z drzewem leniwych IEnumerables na dole drzewa znajduje się N partycji. Obliczenie każdej partycji wymaga przejścia N elementów, z których każdy wymaga porównania lg (N) w górę drzewa. Aby obliczyć wszystkie partycje w dolnej części drzewa, wymaga porównań O (N ^ 2 lg N).

(Czy to prawda? Nie mogę w to uwierzyć. Ktoś, proszę, sprawdź to dla mnie.)

W każdym razie naprawdę fajnie IEnumerablejest wykorzystać tę metodę do tworzenia skomplikowanych struktur obliczeniowych. Ale jeśli zwiększy to złożoność obliczeniową tak bardzo, jak mi się wydaje, wydaje się, że programowania w ten sposób należy unikać, chyba że ktoś jest bardzo ostrożny.

Znaki Stuarta
źródło
35
Przede wszystkim dziękuję za wspaniałą i nie protekcjonalną odpowiedź! Jest to zdecydowanie najdokładniejsze i trafne wyjaśnienie, jakie otrzymałem. Jeśli chodzi o przykład QuickSort, wydaje się, że masz rację co do ints. Pierwsze wzdęcie w miarę wzrostu poziomu rekurencji. Wierzę, że można to łatwo naprawić, obliczając „gt” i „lt” z niecierpliwością (zbierając wyniki za pomocą ToArray). To powiedziawszy, z pewnością potwierdza twój punkt widzenia, że ​​ten styl programowania może spowodować nieoczekiwaną cenę wydajności. (Kontynuuj w drugim komentarzu)
Vitaliy
18
Z drugiej strony na podstawie mojego doświadczenia z C # (ponad 5 lat) mogę stwierdzić, że zlikwidowanie „zbędnych” obliczeń nie jest tak trudne, gdy natrafisz na problem z wydajnością (lub zabronione, jeśli ktoś uczynił to nie do pomyślenia i wprowadził wpływ na bok). Wydawało mi się, że dokonano zbyt wielu kompromisów, aby zapewnić czystość API kosztem możliwości podobnych do C #. Zdecydowanie pomogłeś mi dostosować mój punkt widzenia.
Vitaliy
7
@Vitaliy Dzięki za uczciwą wymianę pomysłów. Nauczyłem się trochę o C # i .NET, badając i pisząc tę ​​odpowiedź.
Stuart Marks
10
Mały komentarz: ReSharper to rozszerzenie Visual Studio, które pomaga w C #. Z powyższym kodem QuickSort ReSharper dodaje ostrzeżenie dla każdego zastosowaniaints : „Możliwe wielokrotne wyliczenie IEnumerable”. Używanie tego samego IEenumerablewięcej niż raz jest podejrzane i należy tego unikać. Chciałbym również wskazać na to pytanie (na które odpowiedziałem), które pokazuje niektóre zastrzeżenia dotyczące podejścia .Net (oprócz słabej wydajności): List <T> i IE niezliczona różnica
Kobi
4
@Kobi Bardzo interesujące, że w ReSharper jest takie ostrzeżenie. Dzięki za wskaźnik do twojej odpowiedzi. Nie znam C # / .NET, więc będę musiał go starannie przejrzeć, ale wydaje się, że wykazuje problemy podobne do problemów projektowych, o których wspomniałem powyżej.
Stuart Marks
122

tło

Chociaż pytanie wydaje się proste, faktyczna odpowiedź wymaga nieco tła, aby miało sens. Jeśli chcesz przejść do wniosku, przewiń w dół ...

Wybierz punkt porównania - podstawowa funkcjonalność

Używając podstawowych pojęć, koncepcja C # IEnumerablejest ściślej powiązana z JavąIterable , która jest w stanie stworzyć tyle iteratorów, ile chcesz. IEnumerablesstworzyć IEnumerators. Java IterabletworzyćIterators

Historia każdej koncepcji jest podobna, pod tym względem, IEnumerablei Iterablemają podstawową motywację, aby umożliwić zapętlenie stylu „dla każdego” nad elementami kolekcji danych. Jest to nadmierne uproszczenie, ponieważ oba pozwalają na więcej niż tylko to, a także osiągnęli ten etap poprzez różne postępy, ale jest to znacząca wspólna cecha niezależnie od tego.

Porównajmy tę funkcję: w obu językach, jeśli klasa implementuje IEnumerable/ Iterable, to klasa ta musi implementować co najmniej jedną metodę (dla C #, GetEnumeratora dla Javy to iterator()). W każdym przypadku instancja zwrócona z tego ( IEnumerator/ Iterator) umożliwia dostęp do bieżących i kolejnych członków danych. Ta funkcja jest używana w składni dla każdego języka.

Wybierz punkt porównania - Ulepszona funkcjonalność

IEnumerablew C # został rozszerzony, aby umożliwić szereg innych funkcji językowych ( głównie związanych z Linq ). Dodano funkcje, takie jak selekcje, prognozy, agregacje itp. Te rozszerzenia mają silną motywację z zastosowania w teorii mnogości, podobnej do koncepcji SQL i relacyjnych baz danych.

W Javie 8 dodano także funkcjonalność, która umożliwia programowanie funkcjonalne przy użyciu strumieni i Lambdas. Zauważ, że strumienie Java 8 nie są przede wszystkim motywowane teorią zbiorów, ale programowaniem funkcjonalnym. Niezależnie od tego istnieje wiele podobieństw.

To jest drugi punkt. Ulepszenia wprowadzone w języku C # zostały zaimplementowane jako ulepszenie IEnumerablekoncepcji. Jednak w Javie wprowadzone ulepszenia zostały zaimplementowane poprzez stworzenie nowych podstawowych koncepcji Lambdas i strumieni, a następnie stworzenie względnie trywialnego sposobu konwersji z Iteratorsi Iterablesna strumienie i odwrotnie.

Zatem porównanie IEnumerable z koncepcją Stream Java jest niepełne. Musisz porównać go z połączonymi interfejsami API strumieni i kolekcji w Javie.

W Javie strumienie nie są takie same jak Iterables lub Iterators

Strumienie nie są zaprojektowane do rozwiązywania problemów w taki sam sposób, jak iteratory:

  • Iteratory są sposobem na opisanie sekwencji danych.
  • Strumienie to sposób opisu sekwencji transformacji danych.

Za pomocą Iterator, otrzymujesz wartość danych, przetwarzasz ją, a następnie otrzymujesz inną wartość danych.

Dzięki strumieniom łączysz sekwencję funkcji razem, a następnie podajesz wartość wejściową do strumienia i uzyskujesz wartość wyjściową z połączonej sekwencji. Uwaga: w języku Java każda funkcja jest zamknięta w jednym Streamwystąpieniu. Interfejs API strumieni pozwala ci połączyć sekwencję Streaminstancji w sposób, który łączy sekwencję wyrażeń transformacji.

Aby ukończyć Streamkoncepcję, potrzebujesz źródła danych do zasilania strumienia oraz funkcji terminala, która zużywa strumień.

Sposób, w jaki podajesz wartości do strumienia, może być w rzeczywistości z Iterable, ale Streamsama sekwencja nie jest an Iterable, jest funkcją złożoną.

A Streamma być również leniwy, w tym sensie, że działa tylko wtedy, gdy zażądasz od niego wartości.

Zwróć uwagę na te istotne założenia i cechy strumieni:

  • A Streamw Javie to silnik transformacji, który przekształca element danych w jednym stanie w inny.
  • strumienie nie mają pojęcia o kolejności ani pozycji danych, po prostu przekształcają wszystko, o co są proszone.
  • strumienie mogą być dostarczane z danymi z wielu źródeł, w tym z innych strumieni, iteratorów, iterów, kolekcji,
  • nie możesz „zresetować” strumienia, to byłoby jak „przeprogramowanie transformacji”. Prawdopodobnie chcesz zresetować źródło danych.
  • logicznie jest tylko 1 element danych „w locie” w strumieniu w dowolnym momencie (chyba że strumień jest strumieniem równoległym, w którym to punkcie jest jeden element na wątek). Jest to niezależne od źródła danych, które może mieć więcej niż bieżące elementy „gotowe” do dostarczenia do strumienia lub kolektora strumienia, który może wymagać agregacji i zmniejszenia wielu wartości.
  • Strumienie mogą być nieograniczone (nieskończone), ograniczone tylko przez źródło danych lub moduł zbierający (który również może być nieskończony).
  • Strumienie są „łańcuchowe”, wynikiem filtrowania jednego strumienia jest inny strumień. Wartości wprowadzane i przetwarzane przez strumień mogą być z kolei dostarczane do innego strumienia, który dokonuje innej transformacji. Dane w stanie przekształconym przepływają z jednego strumienia do drugiego. Nie musisz interweniować i pobierać danych z jednego strumienia i podłączać go do następnego.

Porównanie C #

Jeśli weźmiesz pod uwagę, że strumień Java jest tylko częścią systemu zaopatrzenia, przesyłania strumieniowego i zbierania oraz że strumienie i iteratory są często używane razem z kolekcjami, nic dziwnego, że trudno jest odnieść się do tych samych pojęć, które są prawie wszystkie są osadzone w jednej IEnumerablekoncepcji w języku C #.

Części IEnumerable (i bliskie pokrewne pojęcia) są widoczne we wszystkich koncepcjach Iterator Java, Iterable, Lambda i Stream.

Istnieją małe rzeczy, które mogą zrobić koncepcje Java, które są trudniejsze w IEnumerable i odwrotnie.


Wniosek

  • Nie ma tu problemu projektowego, tylko problem z dopasowaniem pojęć między językami.
  • Strumienie rozwiązują problemy w inny sposób
  • Strumienie dodają funkcjonalność do Javy (dodają inny sposób robienia rzeczy, nie odbierają funkcjonalności)

Dodanie strumieni daje większy wybór przy rozwiązywaniu problemów, co można słusznie zaklasyfikować jako „zwiększenie mocy”, a nie „zmniejszenie”, „zabranie” lub „ograniczenie”.

Dlaczego strumienie Java są jednorazowe?

To pytanie jest błędne, ponieważ strumienie są sekwencjami funkcji, a nie danymi. W zależności od źródła danych, które zasila strumień, możesz zresetować źródło danych i podać ten sam lub inny strumień.

W przeciwieństwie do IEnumerable C #, gdzie potok wykonania może być wykonywany tyle razy, ile chcemy, w Javie strumień może być „iterowany” tylko raz.

Porównanie IEnumerabledo a Streamjest mylące. Kontekst, którego używasz do powiedzenia, IEnumerablemoże być wykonywany tyle razy, ile chcesz, najlepiej w porównaniu z Javą Iterables, którą można powtarzać tyle razy, ile chcesz. Java Streamreprezentuje podzbiór IEnumerablekoncepcji, a nie podzbiór dostarczający dane, a zatem nie może być „ponownie uruchomiony”.

Każde wywołanie operacji terminalowej zamyka strumień, co czyni go bezużytecznym. Ta „funkcja” zabiera dużo energii.

Pierwsze stwierdzenie jest w pewnym sensie prawdziwe. Oświadczenie „zabiera moc” nie jest. Nadal porównujesz strumienie IEnumerables. Operacja terminalowa w strumieniu przypomina klauzulę „break” w pętli for. Zawsze możesz mieć inny strumień, jeśli chcesz i jeśli możesz ponownie dostarczyć potrzebne dane. Ponownie, jeśli uważasz, że IEnumerablejest to bardziej podobne do Iterable, w tym stwierdzeniu Java jest w porządku.

Wyobrażam sobie, że powód tego nie jest techniczny. Jakie były uwagi projektowe stojące za tym dziwnym ograniczeniem?

Powód jest techniczny i z tego prostego powodu, że Strumień jest podzbiorem tego, co myśli. Podzbiór strumienia nie kontroluje dostarczania danych, dlatego należy zresetować źródło, a nie strumień. W tym kontekście nie jest to takie dziwne.

Przykład QuickSort

Twój przykład Quicksort ma podpis:

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

Traktujesz dane wejściowe IEnumerablejako źródło danych:

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

Ponadto zwracana jest IEnumerablerównież wartość , która jest dostawą danych, a ponieważ jest to operacja sortowania, kolejność tej dostawy jest znacząca. Jeśli uważasz, że Iterableklasa Java jest do tego odpowiednia, szczególnie Listspecjalizacja Iterable, ponieważ List jest dostawą danych o gwarantowanej kolejności lub iteracji, to kod Java równoważny z twoim kodem to:

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

Zauważ, że istnieje błąd (który odtworzyłem), ponieważ sortowanie nie obsługuje z powodzeniem zduplikowanych wartości, jest to sortowanie według „wartości unikatowych”.

Zwróć także uwagę na to, w jaki sposób kod Java korzysta ze źródła danych ( List) i przesyła strumieniowo koncepcje w innym punkcie, oraz że w języku C # te dwie „osobowości” można wyrazić za pomocą just IEnumerable. Ponadto, chociaż używam Listjako typu podstawowego, mógłbym użyć bardziej ogólnej Collection, a przy małej konwersji iteratora do strumienia mógłbym użyć nawet bardziej ogólnejIterable

rolfl
źródło
9
Jeśli myślisz o „iteracji” strumienia, robisz to źle. Strumień reprezentuje stan danych w określonym momencie w łańcuchu transformacji. Dane wchodzą do systemu w źródle strumienia, a następnie przepływają z jednego strumienia do drugiego, zmieniając stan w miarę upływu czasu, aż zostaną zebrane, zredukowane lub zrzucone na końcu. A Streamjest koncepcją punktu w czasie, a nie „operacją w pętli” .... (cd.)
rolfl
7
W strumieniu masz dane wchodzące do strumienia wyglądającego jak X i wychodzące ze strumienia wyglądającego jak Y. Istnieje funkcja, którą wykonuje strumień, który wykonuje tę transformację f(x)Strumień kapsułkuje funkcję, nie kapsułkuje danych, które przepływa
rolfl
4
IEnumerablemoże również podawać losowe wartości, być niezwiązany i stać się aktywny, zanim dane będą istnieć.
Arturo Torres Sánchez
6
@Vitaliy: Wiele otrzymywanych metod IEnumerable<T>oczekuje, że będzie to zbiór skończony, który może być wielokrotnie powtarzany. Niektóre rzeczy, które są iterowalne, ale nie spełniają tych warunków, są implementowane, IEnumerable<T>ponieważ żaden inny standardowy interfejs nie pasuje do rachunku, ale metody, które oczekują skończonych kolekcji, które mogą być wielokrotnie iterowane, są podatne na awarie, jeśli otrzyma się iterowalne rzeczy, które nie spełniają tych warunków .
supercat
5
Twój quickSortprzykład może być znacznie prostszy, jeśli zwróci a Stream; zaoszczędziłoby to dwóch .stream()połączeń i jednego .collect(Collectors.toList())połączenia. Jeśli następnie zastąpić Collections.singleton(pivot).stream()z Stream.of(pivot)kodu staje się niemal czytelny ...
Holger
22

Streamsą zbudowane wokół Spliterators, które są stanowymi, zmiennymi obiektami. Nie mają akcji „resetowania” i w rzeczywistości wymaganie wsparcia takiej akcji cofania „zabrałoby dużo mocy”. Jak miałoby Random.ints()się obsłużyć takie żądanie?

Z drugiej strony, w przypadku Streams, które mają źródło pochodzenia, łatwo jest zbudować ekwiwalent Streamdo ponownego użycia. Wystarczy umieścić kroki wykonane, aby skonstruować Streammetodę wielokrotnego użytku. Należy pamiętać, że powtórzenie tych kroków nie jest kosztowną operacją, ponieważ wszystkie te kroki są operacjami leniwymi; faktyczna praca rozpoczyna się od operacji terminalu i w zależności od faktycznej operacji terminalu może zostać wykonany zupełnie inny kod.

To do ciebie, autora takiej metody, należy określenie tego, co wywołuje metoda dwukrotnie: czy odtwarza dokładnie tę samą sekwencję, jak czynią to strumienie utworzone dla niezmodyfikowanej tablicy lub kolekcji, czy tworzy strumień z podobna semantyka, ale różne elementy, takie jak strumień losowych liczb całkowitych lub strumień linii wejściowych konsoli itp.


Nawiasem mówiąc, aby uniknąć pomyłek, operacja terminalowa zużywa to, Streamco różni się od zamykania, tak Streamjak close()robi to wywołanie strumienia (co jest wymagane w przypadku strumieni o powiązanych zasobach, np. Wytwarzanych przez Files.lines()).


Wydaje się, że wiele nieporozumień wynika z błędnego porównania IEnumerablez Stream. An IEnumerableoznacza zdolność do dostarczenia rzeczywistej IEnumerator, więc jest jak Iterablew Javie. W przeciwieństwie do tego, a Streamjest rodzajem iteratora i jest porównywalne z IEnumeratortak, więc błędem jest twierdzenie, że tego rodzaju danych można używać wiele razy w .NET, obsługa IEnumerator.Resetjest opcjonalna. W omawianych tutaj przykładach wykorzystano raczej fakt, że IEnumerablemożna pobrać nowy IEnumerator s, a to działa również z Javą Collection; możesz dostać nowy Stream. Jeśli programiści Java postanowili dodać Streamoperacje Iterablebezpośrednio, operacje pośrednie zwracają kolejneIterable, był naprawdę porównywalny i mógł działać w ten sam sposób.

Jednak deweloperzy postanowili tego nie robić i decyzja jest omawiana w tym pytaniu . Największym punktem jest zamieszanie związane z chętnymi operacjami Collection i leniwymi operacjami Stream. Patrząc na API .NET, (tak osobiście) uznaję to za uzasadnione. Chociaż wygląda to rozsądnie, patrząc IEnumerablesamemu, konkretna kolekcja będzie miała wiele metod manipulujących kolekcją bezpośrednio i wiele metod zwracających leniwość IEnumerable, podczas gdy szczególny charakter metody nie zawsze jest intuicyjnie rozpoznawalny. Najgorszym przykładem, jaki znalazłem (w ciągu kilku minut, na które spojrzałem) jest to, List.Reverse()czyja nazwa dokładnie odpowiada nazwie odziedziczonej (czy to właściwy termin dla metod rozszerzenia?) Enumerable.Reverse(), Zachowując się jednak całkowicie przeciwnie.


Oczywiście są to dwie odrębne decyzje. Pierwszy z nich, aby Streamrodzaj odrębny od Iterable/ Collectioni drugi, aby Streamrodzaj jednorazowo iterator raczej niż inny rodzaj iterable. Ale te decyzje zostały podjęte razem i być może przypadek oddzielenia tych dwóch decyzji nigdy nie był brany pod uwagę. Nie został stworzony z myślą o porównywalności z platformą .NET.

Rzeczywista decyzja dotycząca projektu interfejsu API polegała na dodaniu ulepszonego typu iteratora, czyli Spliterator. Spliterators mogą być dostarczane przez stare Iterable(w taki sposób zostały one zmodernizowane) lub całkowicie nowe implementacje. Następnie Streamdodano jako front-end wysokiego poziomu do raczej niskich poziomów Spliterators. Otóż ​​to. Możesz dyskutować o tym, czy inny projekt byłby lepszy, ale to nie jest produktywne, nie zmieni się, biorąc pod uwagę sposób, w jaki są teraz zaprojektowane.

Jest jeszcze jeden aspekt wdrożenia, który należy wziąć pod uwagę. nieStream są niezmiennymi strukturami danych. Każda operacja pośrednia może zwrócić nową instancję enkapsulującą starą, ale może również manipulować własną instancją i zwrócić samą siebie (co nie wyklucza wykonania nawet obu operacji dla tej samej operacji). Powszechnie znanymi przykładami są operacje takie jak lub, które nie dodają kolejnego kroku, ale manipulują całym potokiem). Posiadanie takiej zmiennej struktury danych i prób ponownego użycia (lub nawet gorzej, wielokrotnego użycia w tym samym czasie) nie działa dobrze…Streamparallelunordered


Dla kompletności, oto twój przykład Quicksort przetłumaczony na StreamAPI Java . To pokazuje, że tak naprawdę „nie odbiera dużej mocy”.

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

Może być używany jak

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

Możesz napisać to jeszcze bardziej kompaktowo jako

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
źródło
1
Cóż, zużywa lub nie, próba ponownego zużycia generuje wyjątek, że strumień został już zamknięty , a nie wykorzystany. Jeśli chodzi o problem z resetowaniem strumienia losowych liczb całkowitych, jak powiedziałeś - to autor biblioteki musi określić dokładny kontrakt operacji resetowania.
Vitaliy,
2
Nie, komunikat brzmi „strumień został już wykorzystany lub zamknięty” i nie mówiliśmy o operacji „resetowania”, ale wywołanie dwóch lub więcej operacji terminalowych, Streampodczas gdy Spliteratorsugerowane byłoby zresetowanie źródła . I jestem całkiem pewien, czy było to możliwe, pojawiły się pytania na temat SO: „Dlaczego count()podwójne wywołanie za Streamkażdym razem daje inne wyniki” itp.
Holger
1
Jest absolutnie poprawne dla count (), aby dać różne wyniki. count () jest zapytaniem w strumieniu, a jeśli strumień można modyfikować (a ściślej mówiąc, strumień reprezentuje wynik zapytania w kolekcji podlegającej zmianom), należy się spodziewać. Spójrz na API C #. Z wdzięcznością rozwiązują wszystkie te problemy.
Witalij
4
To, co nazywacie „absolutnie poprawnym”, jest działaniem sprzecznym z intuicją. W końcu jest to główna motywacja do wielokrotnego korzystania ze strumienia w celu przetworzenia wyniku, który ma być taki sam na różne sposoby. Każde pytanie dotyczące SO dotyczące jednorazowego charakteru Streams wynika z próby rozwiązania problemu poprzez wielokrotne wywoływanie operacji terminalowych (oczywiście, inaczej nie zauważysz), co doprowadziło do cicho zepsutego rozwiązania, jeśli StreamAPI na to pozwoliło z różnymi wynikami dla każdej oceny. Oto dobry przykład .
Holger
3
W rzeczywistości twój przykład doskonale pokazuje, co się stanie, jeśli programista nie zrozumie konsekwencji zastosowania wielu operacji terminalowych. Pomyśl tylko, co się stanie, gdy każda z tych operacji zostanie zastosowana do zupełnie innego zestawu elementów. Działa to tylko wtedy, gdy źródło strumienia zwróciło te same elementy przy każdym zapytaniu, ale jest to dokładnie błędne założenie, o którym mówiliśmy.
Holger,
8

Myślę, że jest bardzo niewiele różnic między tymi dwoma, jeśli spojrzysz wystarczająco uważnie.

Na pierwszy IEnumerablerzut oka wygląda na konstrukcję wielokrotnego użytku:

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

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

Jednak kompilator w rzeczywistości wykonuje trochę pracy, aby nam pomóc; generuje następujący kod:

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

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

Za każdym razem, gdy faktycznie przeprowadzasz iterację w polu enumerable, kompilator tworzy moduł wyliczający. Moduł wyliczający nie jest wielokrotnego użytku; kolejne wywołania MoveNextzwrócą tylko fałsz i nie ma sposobu, aby zresetować go do początku. Jeśli chcesz ponownie wykonać iterację liczb, musisz utworzyć kolejną instancję modułu wyliczającego.


Aby lepiej zilustrować, że IEnumerable ma (może mieć) tę samą „funkcję”, co strumień Java, rozważ wyliczenie, którego źródłem liczb nie jest kolekcja statyczna. Na przykład możemy stworzyć obiekt, który można wyliczyć, który generuje sekwencję 5 liczb losowych:

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

Teraz mamy bardzo podobny kod do poprzedniego wyliczalnego opartego na tablicy, ale z drugą iteracją numbers:

IEnumerable<int> numbers = new RandomNumberStream();

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

Za drugim razem, gdy będziemy powtarzać numbers, otrzymamy inną sekwencję liczb, która nie może być ponownie użyta w tym samym sensie. Albo moglibyśmy napisać, RandomNumberStreamaby zgłosić wyjątek, jeśli spróbujesz iterować go wiele razy, co sprawi, że wyliczenie będzie faktycznie bezużyteczne (jak Java Stream).

Co też oznacza twoje szybkie sortowanie oparte na wyliczeniach, gdy zastosowane do RandomNumberStream?


Wniosek

Największą różnicą jest to, że .NET pozwala na ponowne użycie IEnumerablepoprzez niejawne tworzenie nowego IEnumeratorw tle, ilekroć będzie musiał uzyskać dostęp do elementów w sekwencji.

To niejawne zachowanie jest często przydatne (i „potężne”, jak twierdzisz), ponieważ możemy wielokrotnie iterować kolekcję.

Ale czasami to niejawne zachowanie może faktycznie powodować problemy. Jeśli twoje źródło danych nie jest statyczne lub dostęp do niego jest kosztowny (np. Baza danych lub strona internetowa), wówczas wiele założeń IEnumerablenależy odrzucić; ponowne użycie nie jest takie proste

Andrew Vermie
źródło
2

Możliwe jest ominięcie niektórych zabezpieczeń „uruchom raz” w interfejsie API Stream; na przykład możemy uniknąć java.lang.IllegalStateExceptionwyjątków (z komunikatem „strumień był już obsługiwany lub zamknięty”) przez odwołanie się do i ponowne użycie Spliterator(zamiast Streambezpośrednio).

Na przykład ten kod będzie działał bez zgłaszania wyjątku:

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

Jednak wydajność będzie ograniczona do

prefix-hello
prefix-world

zamiast powtarzać dane wyjściowe dwa razy. Jest tak, ponieważ ArraySpliteratorużyty jako Streamźródło jest stanowy i przechowuje swoją bieżącą pozycję. Kiedy to odtwarzamy Stream, zaczynamy od nowa na końcu.

Mamy wiele opcji, aby rozwiązać to wyzwanie:

  1. Możemy skorzystać z bezpaństwowej Streammetody tworzenia, takiej jak Stream#generate(). Musielibyśmy zarządzać stanem zewnętrznie w naszym własnym kodzie i resetować między Stream„powtórkami”:

    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. Innym (nieco lepszym, ale nie idealnym) rozwiązaniem tego jest napisanie własnego ArraySpliterator(lub podobnego Streamźródła), które zawiera pewną zdolność do resetowania bieżącego licznika. Gdybyśmy go użyli do wygenerowania Stream, moglibyśmy potencjalnie odtworzyć je z powodzeniem.

    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. Najlepszym rozwiązaniem tego problemu (moim zdaniem) jest utworzenie nowej kopii dowolnego stanowego Spliterators używanego w Streampotoku, gdy wywoływani są nowi operatorzy w sieci Stream. Jest to bardziej złożone i wymaga wdrożenia, ale jeśli nie masz nic przeciwko korzystaniu z bibliotek stron trzecich, Cyclops-React ma Streamimplementację, która właśnie to robi. (Ujawnienie: Jestem głównym programistą tego projektu.)

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

To zostanie wydrukowane

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

zgodnie z oczekiwaniami.

John McClean
źródło