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.
źródło
IEnumerable
do strumienijava.io.*
Odpowiedzi:
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 operacjefilter
,map
i tak dalej były przedłużające (domyślnie) na metodyIterable
. Wywołanie jednego dodało operację do łańcucha i zwróciło innąIterable
. Operacja terminalowa, jakcount
wywołałabyiterator()
ł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:
[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:
To całkiem proste. Analizuje wiersze tekstu na
Registrant
obiekty i drukuje je dwukrotnie. Tyle że drukuje je tylko raz. Okazuje się, że myślał, żeregistrants
to zbiór, podczas gdy w rzeczywistości jest to iterator. Drugie wywołanieforeach
napotyka 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ś:
(
into
Operacja jest teraz pisanacollect(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łanieinto()
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 zwracaList
:Aby podać konkretny przykład, jaki jest koszt
get(0)
lubsize()
na liście? Dla często używanych klas, takich jakArrayList
, 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, żeIEnumerable
pozwala wielokrotnemu przechodzeniu zachowywać się inaczej z różnymi źródłami; i pozwala na rozgałęzioną strukturęIEnumerable
operacji 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
QuickSort
wymagaIEnumerable
i zwraca anIEnumerable
, więc sortowanie nie jest wykonywane, dopóki finał nieIEnumerable
zostanie przemierzony. Wydaje się jednak, że wywołanie polega na utworzeniu struktury drzewaIEnumerables
odzwierciedlają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
IEnumerable
jest 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.źródło
ints
: „Możliwe wielokrotne wyliczenie IEnumerable”. Używanie tego samegoIEenumerable
wię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óżnicatł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 #
IEnumerable
jest ściślej powiązana z JavąIterable
, która jest w stanie stworzyć tyle iteratorów, ile chcesz.IEnumerables
stworzyćIEnumerators
. JavaIterable
tworzyćIterators
Historia każdej koncepcji jest podobna, pod tym względem,
IEnumerable
iIterable
mają 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 #,GetEnumerator
a dla Javy toiterator()
). 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ść
IEnumerable
w 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
IEnumerable
koncepcji. 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 zIterators
iIterables
na 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:
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
Stream
wystąpieniu. Interfejs API strumieni pozwala ci połączyć sekwencjęStream
instancji w sposób, który łączy sekwencję wyrażeń transformacji.Aby ukończyć
Stream
koncepcję, 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
, aleStream
sama sekwencja nie jest anIterable
, jest funkcją złożoną.A
Stream
ma 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:
Stream
w Javie to silnik transformacji, który przekształca element danych w jednym stanie w inny.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
IEnumerable
koncepcji 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
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
IEnumerable
do aStream
jest mylące. Kontekst, którego używasz do powiedzenia,IEnumerable
może być wykonywany tyle razy, ile chcesz, najlepiej w porównaniu z JavąIterables
, którą można powtarzać tyle razy, ile chcesz. JavaStream
reprezentuje podzbiórIEnumerable
koncepcji, 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
IEnumerable
jest to bardziej podobne doIterable
, 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:
Traktujesz dane wejściowe
IEnumerable
jako źródło danych:Ponadto zwracana jest
IEnumerable
również wartość , która jest dostawą danych, a ponieważ jest to operacja sortowania, kolejność tej dostawy jest znacząca. Jeśli uważasz, żeIterable
klasa Java jest do tego odpowiednia, szczególnieList
specjalizacjaIterable
, ponieważ List jest dostawą danych o gwarantowanej kolejności lub iteracji, to kod Java równoważny z twoim kodem to: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ą justIEnumerable
. Ponadto, chociaż używamList
jako typu podstawowego, mógłbym użyć bardziej ogólnejCollection
, a przy małej konwersji iteratora do strumienia mógłbym użyć nawet bardziej ogólnejIterable
źródło
Stream
jest koncepcją punktu w czasie, a nie „operacją w pętli” .... (cd.)f(x)
Strumień kapsułkuje funkcję, nie kapsułkuje danych, które przepływaIEnumerable
może również podawać losowe wartości, być niezwiązany i stać się aktywny, zanim dane będą istnieć.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 .quickSort
przykład może być znacznie prostszy, jeśli zwróci aStream
; 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()
zStream.of(pivot)
kodu staje się niemal czytelny ...Stream
są zbudowane wokółSpliterator
s, 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łobyRandom.ints()
się obsłużyć takie żądanie?Z drugiej strony, w przypadku
Stream
s, które mają źródło pochodzenia, łatwo jest zbudować ekwiwalentStream
do ponownego użycia. Wystarczy umieścić kroki wykonane, aby skonstruowaćStream
metodę 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,
Stream
co różni się od zamykania, takStream
jakclose()
robi to wywołanie strumienia (co jest wymagane w przypadku strumieni o powiązanych zasobach, np. Wytwarzanych przezFiles.lines()
).Wydaje się, że wiele nieporozumień wynika z błędnego porównania
IEnumerable
zStream
. AnIEnumerable
oznacza zdolność do dostarczenia rzeczywistejIEnumerator
, więc jest jakIterable
w Javie. W przeciwieństwie do tego, aStream
jest rodzajem iteratora i jest porównywalne zIEnumerator
tak, więc błędem jest twierdzenie, że tego rodzaju danych można używać wiele razy w .NET, obsługaIEnumerator.Reset
jest opcjonalna. W omawianych tutaj przykładach wykorzystano raczej fakt, żeIEnumerable
można pobrać nowyIEnumerator
s, a to działa również z JavąCollection
; możesz dostać nowyStream
. Jeśli programiści Java postanowili dodaćStream
operacjeIterable
bezpoś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
IEnumerable
samemu, 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
Stream
rodzaj odrębny odIterable
/Collection
i drugi, abyStream
rodzaj 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
.Spliterator
s mogą być dostarczane przez stareIterable
(w taki sposób zostały one zmodernizowane) lub całkowicie nowe implementacje. NastępnieStream
dodano jako front-end wysokiego poziomu do raczej niskich poziomówSpliterator
s. 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ę. nie
Stream
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…Stream
parallel
unordered
Dla kompletności, oto twój przykład Quicksort przetłumaczony na
Stream
API Java . To pokazuje, że tak naprawdę „nie odbiera dużej mocy”.Może być używany jak
Możesz napisać to jeszcze bardziej kompaktowo jako
źródło
Stream
podczas gdySpliterator
sugerowane byłoby zresetowanie źródła . I jestem całkiem pewien, czy było to możliwe, pojawiły się pytania na temat SO: „Dlaczegocount()
podwójne wywołanie zaStream
każdym razem daje inne wyniki” itp.Stream
s 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śliStream
API na to pozwoliło z różnymi wynikami dla każdej oceny. Oto dobry przykład .Myślę, że jest bardzo niewiele różnic między tymi dwoma, jeśli spojrzysz wystarczająco uważnie.
Na pierwszy
IEnumerable
rzut oka wygląda na konstrukcję wielokrotnego użytku:Jednak kompilator w rzeczywistości wykonuje trochę pracy, aby nam pomóc; generuje następujący kod:
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
MoveNext
zwró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:
Teraz mamy bardzo podobny kod do poprzedniego wyliczalnego opartego na tablicy, ale z drugą iteracją
numbers
: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ć,RandomNumberStream
aby 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
IEnumerable
poprzez niejawne tworzenie nowegoIEnumerator
w 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ń
IEnumerable
należy odrzucić; ponowne użycie nie jest takie prosteźródło
Możliwe jest ominięcie niektórych zabezpieczeń „uruchom raz” w interfejsie API Stream; na przykład możemy uniknąć
java.lang.IllegalStateException
wyjątków (z komunikatem „strumień był już obsługiwany lub zamknięty”) przez odwołanie się do i ponowne użycieSpliterator
(zamiastStream
bezpośrednio).Na przykład ten kod będzie działał bez zgłaszania wyjątku:
Jednak wydajność będzie ograniczona do
zamiast powtarzać dane wyjściowe dwa razy. Jest tak, ponieważ
ArraySpliterator
użyty jakoStream
źródło jest stanowy i przechowuje swoją bieżącą pozycję. Kiedy to odtwarzamyStream
, zaczynamy od nowa na końcu.Mamy wiele opcji, aby rozwiązać to wyzwanie:
Możemy skorzystać z bezpaństwowej
Stream
metody tworzenia, takiej jakStream#generate()
. Musielibyśmy zarządzać stanem zewnętrznie w naszym własnym kodzie i resetować międzyStream
„powtórkami”:Innym (nieco lepszym, ale nie idealnym) rozwiązaniem tego jest napisanie własnego
ArraySpliterator
(lub podobnegoStream
źródła), które zawiera pewną zdolność do resetowania bieżącego licznika. Gdybyśmy go użyli do wygenerowaniaStream
, moglibyśmy potencjalnie odtworzyć je z powodzeniem.Najlepszym rozwiązaniem tego problemu (moim zdaniem) jest utworzenie nowej kopii dowolnego stanowego
Spliterator
s używanego wStream
potoku, gdy wywoływani są nowi operatorzy w sieciStream
. Jest to bardziej złożone i wymaga wdrożenia, ale jeśli nie masz nic przeciwko korzystaniu z bibliotek stron trzecich, Cyclops-React maStream
implementację, która właśnie to robi. (Ujawnienie: Jestem głównym programistą tego projektu.)To zostanie wydrukowane
zgodnie z oczekiwaniami.
źródło