Czy mam zwrócić kolekcję czy strumień?

163

Załóżmy, że mam metodę, która zwraca widok tylko do odczytu do listy członków:

class Team {
    private List < Player > players = new ArrayList < > ();

    // ...

    public List < Player > getPlayers() {
        return Collections.unmodifiableList(players);
    }
}

Ponadto przypuśćmy, że wszystko, co robi klient, to powtórzenie listy raz, natychmiast. Może po to, żeby umieścić graczy w JList czy coś. Klient nie przechowuje odniesienia do listy do późniejszego wglądu!

Biorąc pod uwagę ten typowy scenariusz, czy zamiast tego powinienem zwrócić strumień?

public Stream < Player > getPlayers() {
    return players.stream();
}

A może zwracanie strumienia nie jest idiomatyczne w Javie? Czy strumienie były zaprojektowane tak, aby zawsze były „kończone” wewnątrz tego samego wyrażenia, w którym zostały utworzone?

fredoverflow
źródło
12
Zdecydowanie nie ma w tym nic złego jako idiomu. W końcu players.stream()to taka metoda, która zwraca strumień do wywołującego. Prawdziwe pytanie brzmi: czy naprawdę chcesz ograniczyć wywołującego do pojedynczego przejścia, a także odmówić mu dostępu do Twojej kolekcji przez CollectionAPI? Może dzwoniący po prostu chce addAllto do innej kolekcji?
Marko Topolnik
2
To wszystko zależy. Zawsze możesz zrobić collection.stream (), jak również Stream.collect (). To zależy od Ciebie i osoby dzwoniącej, która korzysta z tej funkcji.
Raja Anbazhagan

Odpowiedzi:

222

Odpowiedź brzmi, jak zawsze, „to zależy”. Zależy to od tego, jak duża będzie zwracana kolekcja. Zależy to od tego, czy wynik zmienia się w czasie i jak ważna jest spójność zwracanego wyniku. Zależy to w dużej mierze od tego, jak użytkownik prawdopodobnie użyje odpowiedzi.

Po pierwsze, pamiętaj, że zawsze możesz pobrać kolekcję ze strumienia i odwrotnie:

// If API returns Collection, convert with stream()
getFoo().stream()...

// If API returns Stream, use collect()
Collection<T> c = getFooStream().collect(toList());

Pytanie brzmi, co jest bardziej przydatne dla dzwoniących.

Jeśli Twój wynik może być nieskończony, jest tylko jeden wybór: Stream.

Jeśli twój wynik może być bardzo duży, prawdopodobnie wolisz Stream, ponieważ może nie mieć żadnej wartości w materializacji tego wszystkiego naraz, a zrobienie tego może spowodować znaczną presję na stertę.

Jeśli wszystko, co ma zamiar wywołać, to iteracja (wyszukiwanie, filtrowanie, agregowanie), powinieneś preferować Stream, ponieważ Stream ma już te wbudowane i nie ma potrzeby materializacji kolekcji (zwłaszcza jeśli użytkownik może nie przetwarzać cały wynik). Jest to bardzo częsty przypadek.

Nawet jeśli wiesz, że użytkownik dokona iteracji wiele razy lub w inny sposób zachowa go w pobliżu, nadal możesz chcieć zamiast tego zwrócić strumień, z prostego powodu, że niezależnie od kolekcji, w której zdecydujesz się go umieścić (np. ArrayList), może nie być formularz, który chcą, a następnie dzwoniący i tak musi go skopiować. jeśli zwrócisz strumień, mogą to zrobić collect(toCollection(factory))i uzyskać dokładnie taką formę, jaką chcą.

Powyższe przypadki „preferuj Stream” wynikają głównie z faktu, że Stream jest bardziej elastyczny; możesz później powiązać się z tym, jak go używasz, bez ponoszenia kosztów i ograniczeń związanych z materializacją go w kolekcji.

Jedynym przypadkiem, w którym musisz zwrócić kolekcję, jest sytuacja, gdy istnieją wysokie wymagania dotyczące spójności i musisz stworzyć spójną migawkę poruszającego się celu. Następnie będziesz chciał umieścić elementy w kolekcji, która się nie zmieni.

Powiedziałbym więc, że w większości przypadków Stream jest właściwą odpowiedzią - jest bardziej elastyczny, nie narzuca zwykle niepotrzebnych kosztów materializacji iw razie potrzeby można go łatwo przekształcić w wybraną przez Ciebie Kolekcję. Ale czasami może być konieczne zwrócenie kolekcji (powiedzmy, ze względu na rygorystyczne wymagania dotyczące spójności) lub możesz chcieć zwrócić kolekcję, ponieważ wiesz, w jaki sposób użytkownik będzie z niej korzystać, i wiesz, że jest to dla niego najwygodniejsze.

Brian Goetz
źródło
6
Jak powiedziałem, jest kilka przypadków, w których nie poleci, na przykład te, w których chcesz zwrócić migawkę w czasie poruszającego się celu, zwłaszcza gdy masz duże wymagania dotyczące spójności. Jednak w większości przypadków Stream wydaje się być bardziej ogólnym wyborem, chyba że wiesz coś konkretnego na temat tego, jak będzie używany.
Brian Goetz,
8
@Marko Nawet jeśli ograniczysz swoje pytanie tak wąsko, nadal nie zgadzam się z twoim wnioskiem. Być może zakładasz, że tworzenie strumienia jest w jakiś sposób znacznie droższe niż opakowanie kolekcji niezmiennym opakowaniem? (A nawet jeśli tego nie zrobisz, widok strumienia, który uzyskasz na opakowaniu, jest gorszy niż to, co otrzymujesz z oryginału; ponieważ UnmodifiableList nie przesłania spliteratora (), skutecznie stracisz wszelką równoległość.) Konkluzja: uwaga: stronniczości za swojskość; Znasz kolekcję od lat i to może sprawić, że nie ufasz nowicjuszowi.
Brian Goetz,
5
@MarkoTopolnik Jasne. Moim celem było zajęcie się ogólnym pytaniem dotyczącym projektowania interfejsu API, które staje się często zadawanymi pytaniami. Jeśli chodzi o koszty, pamiętaj, że jeśli nie masz jeszcze zmaterializowanej kolekcji, możesz ją zwrócić lub zapakować (OP ma, ale często nie ma), zmaterializowanie kolekcji w metodzie pobierającej nie jest tańsze niż zwrócenie strumienia i pozwolenie wywołujący zmaterializuje jeden (i oczywiście wczesna materializacja może być znacznie droższa, jeśli dzwoniący go nie potrzebuje lub jeśli zwrócisz ArrayList, ale dzwoniący chce TreeSet). Ale Stream jest nowy i ludzie często zakładają, że jego więcej $$$ niż to jest.
Brian Goetz
4
@MarkoTopolnik Chociaż in-memory jest bardzo ważnym przypadkiem użycia, istnieją również inne przypadki, które mają dobrą obsługę równoległości, takie jak generowane strumienie nieuporządkowane (np. Stream.generate). Jednak tam, gdzie strumienie są słabo dopasowane, jest przypadek użycia reaktywnego, w którym dane docierają z losowym opóźnieniem. W tym celu sugerowałbym RxJava.
Brian Goetz
4
@MarkoTopolnik Myślę, że się nie zgadzamy, może poza tym, że być może spodobało Ci się, że nieco inaczej skupiliśmy nasze wysiłki. (Jesteśmy do tego przyzwyczajeni; nie możemy uszczęśliwić wszystkich ludzi.) Centrum projektowania strumieni zajmowało się strukturami danych w pamięci; Centrum projektowania dla RxJava koncentruje się na zdarzeniach generowanych zewnętrznie. Obie są dobrymi bibliotekami; również oba nie wypadają zbyt dobrze, gdy próbujesz zastosować je do przypadków poza ich centrum projektowania. Ale tylko dlatego, że młotek jest okropnym narzędziem do igły, nie oznacza to, że coś jest nie tak z młotkiem.
Brian Goetz,
63

Chciałbym dodać kilka uwag do doskonałej odpowiedzi Briana Goetza .

Dość często zwraca się Stream z wywołania metody w stylu „getter”. Zobacz stronę Wykorzystanie strumienia w Java 8 javadoc i poszukaj „metod ..., które zwracają Stream” dla pakietów innych niż java.util.Stream. Te metody są zwykle na klasach, które reprezentują lub mogą zawierać wiele wartości lub agregacji czegoś. W takich przypadkach interfejsy API zwykle zwracają ich kolekcje lub tablice. Ze wszystkich powodów, które Brian zauważył w swojej odpowiedzi, dodawanie tutaj metod zwracających strumień jest bardzo elastyczne. Wiele z tych klas ma już metody zwracające kolekcje lub tablice, ponieważ są one wcześniejsze niż interfejs API strumieni. Jeśli projektujesz nowy interfejs API i sensowne jest zapewnienie metod zwracających strumień, może nie być również konieczne dodawanie metod zwracających kolekcje.

Brian wspomniał o koszcie „materializacji” wartości w kolekcji. Aby wzmocnić ten punkt, istnieją tutaj dwa koszty: koszt przechowywania wartości w kolekcji (alokacja pamięci i kopiowanie), a także koszt tworzenia wartości w pierwszej kolejności. Ten ostatni koszt można często zmniejszyć lub uniknąć, wykorzystując lenistwo poszukiwania przez Strumień. Dobrym tego przykładem są interfejsy API w java.nio.file.Files:

static Stream<String>  lines(path)
static List<String>    readAllLines(path)

Nie tylko readAllLinesmusi przechowywać całą zawartość pliku w pamięci, aby zapisać go na liście wyników, ale także musi odczytać plik do samego końca, zanim zwróci listę. linesMetoda może powrócić niemal natychmiast po tym, jak wykonać pewne konfiguracji, pozostawiając czytanie pliku i linia łamania dopiero później, gdy jest to konieczne - albo wcale. To ogromna korzyść, jeśli np. Dzwoniącego interesują tylko pierwsze dziesięć linii:

try (Stream<String> lines = Files.lines(path)) {
    List<String> firstTen = lines.limit(10).collect(toList());
}

Oczywiście można zaoszczędzić sporo miejsca w pamięci, jeśli wywołujący przefiltruje strumień, aby zwrócić tylko wiersze pasujące do wzorca itp.

Idiom, który wydaje się pojawiać, polega na nazywaniu metod zwracających strumień po liczbie mnogiej nazwy rzeczy, które reprezentuje lub zawiera, bez getprzedrostka. Ponadto, chociaż stream()jest to rozsądna nazwa metody zwracającej strumień, gdy istnieje tylko jeden możliwy zestaw wartości do zwrócenia, czasami istnieją klasy, które mają agregacje wielu typów wartości. Na przykład załóżmy, że masz jakiś obiekt, który zawiera zarówno atrybuty, jak i elementy. Możesz dostarczyć dwa interfejsy API zwracające strumień:

Stream<Attribute>  attributes();
Stream<Element>    elements();
Stuart Marks
źródło
3
Świetne punkty. Czy możesz powiedzieć więcej o tym, gdzie widzisz powstający idiom nazewnictwa i jak silna jest przyczepność (para?)? Podoba mi się idea nazewnictwa, dzięki której staje się oczywiste, że otrzymujesz strumień, a nie zbiór - chociaż często oczekuję, że uzupełnienie IDE po „get” powie mi, co mogę uzyskać.
Joshua Goldberg
1
Jestem również bardzo zainteresowany tym idiomem nazewnictwa
wybierz
5
@JoshuaGoldberg Wydaje się, że JDK przyjął ten idiom nazewnictwa, choć nie wyłącznie. Zastanów się: CharSequence.chars () i .codePoints (), BufferedReader.lines () i Files.lines () istniały w Javie 8. W Javie 9 dodano: Process.children (), NetworkInterface.addresses ( ), Scanner.tokens (), Matcher.results (), java.xml.catalog.Catalog.catalogs (). Dodano inne metody zwracania strumienia, które nie używają tego idiomu - przychodzi mi na myśl Scanner.findAll () - ale wydaje się, że w JDK został użyty idiom rzeczownika w liczbie mnogiej.
Stuart Marks,
1

Czy strumienie były zaprojektowane tak, aby zawsze były „kończone” wewnątrz tego samego wyrażenia, w którym zostały utworzone?

Tak są używane w większości przykładów.

Uwaga: zwrócenie strumienia nie różni się zbytnio od zwrotu Iteratora (przyznane z dużo większą mocą ekspresji)

IMHO najlepszym rozwiązaniem jest podsumowanie, dlaczego to robisz, i nie zwracanie kolekcji.

na przykład

public int playerCount();
public Player player(int n);

lub jeśli zamierzasz je policzyć

public int countPlayersWho(Predicate<? super Player> test);
Peter Lawrey
źródło
2
Problem z tą odpowiedzią polega na tym, że wymagałoby to od autora przewidywania każdej akcji, którą klient chce wykonać, co znacznie zwiększyłoby liczbę metod w klasie.
dkatzel
@dkatzel Zależy to od tego, czy użytkownik końcowy jest autorem, czy kimś, z kim pracuje. Jeśli użytkownicy końcowi są niepoznawalni, potrzebujesz bardziej ogólnego rozwiązania. Nadal możesz chcieć ograniczyć dostęp do podstawowej kolekcji.
Peter Lawrey
1

Jeśli strumień jest skończony, a na zwracanych obiektach jest oczekiwana / normalna operacja, która wyrzuci sprawdzony wyjątek, zawsze zwracam Collection. Ponieważ jeśli zamierzasz robić coś na każdym z obiektów, które mogą rzucić wyjątek check, znienawidzisz strumień. Jeden prawdziwy brak w przypadku strumieni to brak możliwości eleganckiego radzenia sobie ze sprawdzonymi wyjątkami.

Może to znak, że nie potrzebujesz sprawdzonych wyjątków, co jest sprawiedliwe, ale czasami są nieuniknione.

designbygravity
źródło
1

W przeciwieństwie do kolekcji strumienie mają dodatkowe cechy . Strumień zwracany dowolną metodą może wyglądać następująco:

  • skończone lub nieskończone
  • równoległe lub sekwencyjne (z domyślną globalnie współdzieloną pulą wątków, która może mieć wpływ na dowolną inną część aplikacji)
  • zamówione lub niezamówione

Te różnice istnieją również w kolekcjach, ale są częścią oczywistej umowy:

  • Wszystkie kolekcje mają rozmiar, Iterator / Iterable mogą być nieskończone.
  • Kolekcje są jawnie lub nieuporządkowane
  • Równoległość na szczęście nie jest czymś, na czym zależy kolekcji poza bezpieczeństwem wątków.

Jako konsument strumienia (ze zwrotu metody lub jako parametr metody) jest to niebezpieczna i zagmatwana sytuacja. Aby upewnić się, że ich algorytm działa poprawnie, odbiorcy strumieni muszą upewnić się, że algorytm nie przyjmuje błędnych założeń dotyczących charakterystyki strumienia. A to jest bardzo trudne do zrobienia. W testach jednostkowych oznaczałoby to, że musisz pomnożyć wszystkie testy, aby zostały powtórzone z tą samą zawartością strumienia, ale ze strumieniami, które są

  • (skończone, uporządkowane, sekwencyjne)
  • (skończone, uporządkowane, równoległe)
  • (skończone, nieuporządkowane, sekwencyjne) ...

Pisanie zabezpieczeń metod dla strumieni, które generują wyjątek IllegalArgumentException, jeśli strumień wejściowy ma cechy, które naruszają algorytm, jest trudne, ponieważ właściwości są ukryte.

To pozostawia Stream tylko jako ważny wybór w sygnaturze metody, gdy żaden z powyższych problemów nie ma znaczenia, co rzadko się zdarza.

Znacznie bezpieczniej jest używać innych typów danych w sygnaturach metod z jawną umową (i bez niejawnego przetwarzania puli wątków), która uniemożliwia przypadkowe przetwarzanie danych z błędnymi założeniami dotyczącymi uporządkowania, wielkości lub równoległości (i użycia puli wątków).

tkruse
źródło
2
Twoje obawy dotyczące nieskończonych strumieni są nieuzasadnione; pytanie brzmi „czy powinienem zwrócić kolekcję czy strumień”. Jeśli kolekcja jest możliwa, wynik jest z definicji skończony. Tak więc obawy, że dzwoniący zaryzykują nieskończoną iterację, biorąc pod uwagę, że mogłeś zwrócić kolekcję , są nieuzasadnione. Reszta porad w tej odpowiedzi jest po prostu zła. Wydaje mi się, że wpadłeś na kogoś, kto nadużywał Stream i nadmiernie obracasz się w innym kierunku. Zrozumiałe, ale zła rada.
Brian Goetz
0

Myślę, że to zależy od twojego scenariusza. Być może, jeśli zrobisz swoje Teamnarzędzie Iterable<Player>, wystarczy.

for (Player player : team) {
    System.out.println(player);
}

lub w stylu funkcjonalnym:

team.forEach(System.out::println);

Ale jeśli chcesz mieć bardziej kompletny i płynny interfejs API, dobrym rozwiązaniem może być strumień.

Gontard
źródło
Zwróć uwagę, że w kodzie opublikowanym przez OP liczba graczy jest prawie bezużyteczna, poza szacunkiem („1034 graczy grających teraz, kliknij tutaj, aby rozpocząć!”) Dzieje się tak, ponieważ zwracasz niezmienny widok zmiennej kolekcji , więc liczba, którą otrzymasz teraz, może nie być równa liczbie za trzy mikrosekundy od teraz. Tak więc, podczas gdy zwracanie kolekcji daje „łatwy” sposób na uzyskanie wyniku (i naprawdę stream.count()jest też całkiem łatwy), liczba ta nie ma większego znaczenia dla niczego innego niż debugowanie lub szacowanie.
Brian Goetz
0

Chociaż niektórzy bardziej znani respondenci udzielili świetnych ogólnych porad, jestem zaskoczony, że nikt nie powiedział:

Jeśli masz już „zmaterializowane” Collectionw ręku (tj. Zostało już utworzone przed wywołaniem - jak to ma miejsce w podanym przykładzie, gdzie jest to pole składowe), nie ma sensu przekształcać go w Stream. Dzwoniący może z łatwością to zrobić samodzielnie. Natomiast jeśli wywołujący chce korzystać z danych w ich oryginalnej postaci, konwertowanie ich do formatu a Streamzmusza ich do wykonania zbędnej pracy w celu ponownego zmaterializowania kopii oryginalnej struktury.

Daniel Avery
źródło
-1

Może fabryka Stream byłaby lepszym wyborem. Największą zaletą ujawniania tylko kolekcji za pośrednictwem usługi Stream jest to, że lepiej hermetyzuje strukturę danych modelu domeny. Niemożliwe jest, aby jakiekolwiek użycie klas domeny wpłynęło na wewnętrzne działanie Twojej listy lub zestawu, po prostu ujawniając strumień.

Zachęca również użytkowników Twojej klasy domeny do pisania kodu w bardziej nowoczesnym stylu Java 8. Możliwe jest stopniowe refaktoryzowanie do tego stylu, zachowując istniejące metody pobierające i dodając nowe metody pobierające zwracające strumień. Z biegiem czasu możesz przepisać swój starszy kod, aż w końcu usuniesz wszystkie pobierające, które zwracają Listę lub Zestaw. Ten rodzaj refaktoryzacji jest naprawdę dobry po wyczyszczeniu całego starego kodu!

Vazgen Torosyan
źródło
7
czy istnieje powód, dla którego jest to w pełni cytowane? czy jest źródło?
Xerus
-5

Prawdopodobnie miałbym 2 metody, jedną do zwrócenia a, Collectiona drugą do zwrócenia kolekcji jako Stream.

class Team
{
    private List<Player> players = new ArrayList<>();

// ...

    public List<Player> getPlayers()
    {
        return Collections.unmodifiableList(players);
    }

    public Stream<Player> getPlayerStream()
    {
        return players.stream();
    }

}

To najlepsze z obu światów. Klient może wybrać, czy chce Listę, czy Strumień, i nie musi tworzyć dodatkowego obiektu polegającego na tworzeniu niezmiennej kopii listy tylko po to, aby uzyskać strumień.

To również dodaje tylko 1 dodatkową metodę do twojego API, więc nie masz zbyt wielu metod

dkatzel
źródło
1
Ponieważ chciał wybierać między tymi dwiema opcjami i zapytał o zalety i wady każdej z nich. Ponadto zapewnia każdemu lepsze zrozumienie tych pojęć.
Libert Piou Piou,
Proszę, nie rób tego. Wyobraź sobie interfejsy API!
François Gautier