Czy w strumieniach Java podglądanie jest naprawdę tylko do debugowania?

137

Czytam o strumieniach Java i odkrywam nowe rzeczy w trakcie. Jedną z nowych rzeczy, które znalazłem, była peek()funkcja. Prawie wszystko, co przeczytałem w Peek, mówi, że powinno się go używać do debugowania strumieni.

Co by było, gdybym miał Stream, w którym każde konto ma nazwę użytkownika, pole hasła oraz metodę login () i loggedIn ().

też mam

Consumer<Account> login = account -> account.login();

i

Predicate<Account> loggedIn = account -> account.loggedIn();

Dlaczego to miałoby być takie złe?

List<Account> accounts; //assume it's been setup
List<Account> loggedInAccount = 
accounts.stream()
    .peek(login)
    .filter(loggedIn)
    .collect(Collectors.toList());

O ile wiem, robi dokładnie to, co jest przeznaczone. To;

  • Pobiera listę kont
  • Próbuje zalogować się na każde konto
  • Filtruje konta, które nie są zalogowane
  • Zbiera zalogowane konta na nową listę

Jakie są wady zrobienia czegoś takiego? Jest jakiś powód, dla którego nie powinienem kontynuować? Wreszcie, jeśli nie to rozwiązanie, to co?

Oryginalna wersja tego używała metody .filter () w następujący sposób;

.filter(account -> {
        account.login();
        return account.loggedIn();
    })
Adam.J
źródło
38
Za każdym razem, gdy potrzebuję wieloliniowej lambdy, przenoszę wiersze do metody prywatnej i przekazuję odwołanie do metody zamiast lambda.
VGR
1
Jaki jest cel - czy próbujesz zalogować wszystkie konta i filtrować je na podstawie tego, czy są zalogowane (co może być trywialne)? A może chcesz ich zalogować, a następnie przefiltrować je na podstawie tego, czy się zalogowali, czy nie? Proszę o to w tej kolejności, ponieważ forEachmoże to być operacja, której chcesz, a nie inną peek. To, że jest w API, nie oznacza, że ​​nie jest otwarte na nadużycia (np Optional.of.).
Makoto
8
Zauważ również, że twój kod może być po prostu .peek(Account::login)i .filter(Account::loggedIn); nie ma powodu, aby pisać konsumenta i predykatu, który po prostu wywołuje inną taką metodę.
Joshua Taylor
2
Należy również zauważyć, że interfejs API strumienia wyraźnie odradza skutki uboczne w parametrach behawioralnych .
Didier L
6
Pożyteczni konsumenci zawsze mają skutki uboczne, których oczywiście się nie zniechęca. W rzeczywistości jest to wspomniane w tej samej sekcji: „ Niewielka liczba operacji strumieniowych, takich jak forEach()i peek(), może działać jedynie poprzez efekty uboczne; należy ich używać ostrożnie. ”. Moja uwaga była raczej przypomnieniem, że peekoperacja (która jest przeznaczona do debugowania) nie powinna być zastępowana przez robienie tego samego w ramach innej operacji, takiej jak map()lub filter().
Didier L

Odpowiedzi:

77

Kluczowe wnioski z tego:

Nie używaj interfejsu API w niezamierzony sposób, nawet jeśli osiąga to twój bezpośredni cel. Takie podejście może się załamać w przyszłości i nie jest jasne dla przyszłych opiekunów.


Nie ma nic złego w rozbiciu tego na wiele operacji, ponieważ są to odrębne operacje. Nie ma nic złego w użyciu API w sposób niejasny i niezamierzony sposób, który może mieć konsekwencje, jeśli ten konkretny zachowanie jest modyfikowany w wersji przyszłości Java.

Użycie forEachtej operacji wyjaśniłoby opiekunowi, że każdy element ma zamierzony efekt uboczny accountsi że wykonujesz jakąś operację, która może go zmienić.

Jest to również bardziej konwencjonalne w tym sensie, że peekjest operacją pośrednią, która nie działa na całej kolekcji, dopóki nie zostanie uruchomiona operacja terminalowa, ale w forEachrzeczywistości jest operacją terminalową. W ten sposób możesz tworzyć mocne argumenty dotyczące zachowania i przepływu kodu, zamiast zadawać pytania o to, peekczy zachowywałby się tak samo, jak forEachw tym kontekście.

accounts.forEach(a -> a.login());
List<Account> loggedInAccounts = accounts.stream()
                                         .filter(Account::loggedIn)
                                         .collect(Collectors.toList());
Makoto
źródło
3
Jeśli logujesz się na etapie przetwarzania wstępnego, w ogóle nie potrzebujesz strumienia. Możesz wykonać forEachbezpośrednio w kolekcji źródłowej:accounts.forEach(a -> a.login());
Holger
1
@Holger: Doskonała uwaga. Włączyłem to do odpowiedzi.
Makoto
2
@ Adam.J: Tak, moja odpowiedź skupiła się bardziej na ogólnym pytaniu zawartym w twoim tytule, tj. Czy ta metoda jest naprawdę tylko do debugowania, wyjaśniając aspekty tej metody. Ta odpowiedź jest bardziej związana z Twoim rzeczywistym przypadkiem użycia i sposobem, w jaki to zrobić. Można więc powiedzieć, że razem dają pełny obraz. Po pierwsze, powód, dla którego nie jest to zamierzone użycie, po drugie wniosek, aby nie trzymać się niezamierzonego użycia i co zamiast tego zrobić. Ten ostatni będzie miał dla ciebie bardziej praktyczne zastosowanie.
Holger
2
Oczywiście byłoby znacznie łatwiej, gdyby login()metoda zwróciła booleanwartość wskazującą na stan sukcesu…
Holger
3
Właśnie do tego dążyłem. Jeśli login()zwraca a boolean, możesz użyć go jako predykatu, który jest najczystszym rozwiązaniem. Nadal ma efekt uboczny, ale to jest w porządku, o ile nie przeszkadza, tj. loginProces „jednego Accountnie ma wpływu na proces logowania” drugiego Account.
Holger
111

Ważną rzeczą, którą musisz zrozumieć, jest to, że strumienie są sterowane przez działanie terminala . Operacja terminala określa, czy wszystkie elementy mają zostać przetworzone, czy w ogóle. Podobnie collectjest z operacją, która przetwarza każdy element, podczas gdy findAnymoże przestać przetwarzać elementy, gdy napotka pasujący element.

I count()może w ogóle nie przetwarzać żadnych elementów, jeśli może określić rozmiar strumienia bez przetwarzania elementów. Ponieważ jest to optymalizacja, która nie została przeprowadzona w Javie 8, ale będzie w Javie 9, mogą wystąpić niespodzianki, gdy przełączysz się na Javę 9 i kod będzie zależał od count()przetwarzania wszystkich elementów. Jest to również związane z innymi szczegółami zależnymi od implementacji, np. Nawet w Javie 9 implementacja referencyjna nie będzie w stanie przewidzieć rozmiaru nieskończonego strumienia źródła w połączeniu z, limitpodczas gdy nie ma fundamentalnego ograniczenia uniemożliwiającego takie przewidywanie.

Ponieważ peekumożliwia „wykonywanie podanej akcji na każdym elemencie, gdy elementy są zużywane z wynikowego strumienia ”, nie nakazuje przetwarzania elementów, ale wykona akcję w zależności od potrzeb operacji terminala. Oznacza to, że musisz używać go z wielką ostrożnością, jeśli potrzebujesz określonego przetwarzania, np. Chcesz zastosować akcję na wszystkich elementach. Działa, jeśli operacja terminala gwarantuje przetworzenie wszystkich elementów, ale nawet wtedy musisz mieć pewność, że następny programista nie zmieni operacji terminalu (lub zapomnisz o tym subtelnym aspekcie).

Ponadto, chociaż strumienie gwarantują utrzymanie kolejności napotkania dla określonej kombinacji operacji nawet dla strumieni równoległych, te gwarancje nie mają zastosowania peek. Podczas zbierania do listy wynikowa lista będzie miała właściwą kolejność dla uporządkowanych równoległych strumieni, ale peekakcja może zostać wywołana w dowolnej kolejności i jednocześnie.

Więc najbardziej użyteczną rzeczą, jaką możesz zrobić, peekjest sprawdzenie, czy element strumienia został przetworzony, co jest dokładnie tym, co mówi dokumentacja API:

Ta metoda istnieje głównie w celu obsługi debugowania, w którym chcesz zobaczyć elementy przepływające przez określony punkt w potoku

Holger
źródło
czy będzie jakiś problem, przyszły lub obecny, w przypadku użycia OP? Czy jego kod zawsze robi to, czego chce?
ZhongYu
9
@ bayou.io: o ile widzę, nie ma problemu w tej dokładnej formie . Ale jak próbowałem wyjaśnić, używanie go w ten sposób oznacza, że ​​musisz pamiętać o tym aspekcie, nawet jeśli wrócisz do kodu rok lub dwa lata później, aby włączyć do kodu «prośbę o funkcję 9876»…
Holger
1
„akcja wglądu może zostać wywołana w dowolnej kolejności i jednocześnie”. Czy to stwierdzenie nie jest sprzeczne z ich regułą dotyczącą działania wglądu, np. „Gdy elementy są zużywane”?
Jose Martinez
5
@Jose Martinez: Mówi się, że „elementy są zużywane z wynikowego strumienia ”, co nie jest akcją terminala, ale przetwarzaniem, chociaż nawet akcja terminala może zużywać elementy poza kolejnością, o ile wynik końcowy jest spójny. Ale myślę również, że fraza zawarta w notatce API, „ zobacz elementy przepływające przez określony punkt w potoku ”, lepiej go opisuje.
Holger,
23

Być może ogólną zasadą powinno być to, że jeśli używasz wglądu poza scenariusz „debugowania”, powinieneś to robić tylko wtedy, gdy jesteś pewien, jakie są warunki kończenia i pośredniego filtrowania. Na przykład:

return list.stream().map(foo->foo.getBar())
                    .peek(bar->bar.publish("HELLO"))
                    .collect(Collectors.toList());

wydaje się być dobrym przypadkiem, w którym chcesz, w jednej operacji przekształcić wszystkie Foos w Bars i powiedzieć im wszystkim cześć.

Wydaje się bardziej wydajne i eleganckie niż coś takiego:

List<Bar> bars = list.stream().map(foo->foo.getBar()).collect(Collectors.toList());
bars.forEach(bar->bar.publish("HELLO"));
return bars;

i nie kończy się dwukrotnym iterowaniem kolekcji.

chimera8
źródło
4

Powiedziałbym, że peekzapewnia to możliwość zdecentralizowania kodu, który może mutować obiekty strumieniowe lub modyfikować stan globalny (na ich podstawie), zamiast upychać wszystko w prostą lub złożoną funkcję przekazaną do metody terminala.

Teraz pytanie może brzmieć: czy powinniśmy mutować obiekty strumieniowe, czy zmieniać stan globalny z poziomu funkcji w programowaniu w języku funkcjonalnym w języku Java ?

Jeśli odpowiedź na którekolwiek z powyższych 2 pytań brzmi tak (lub: w niektórych przypadkach tak), peek()to zdecydowanie nie jest to tylko dla celów debugowania , z tego samego powodu, który forEach()nie jest tylko do celów debugowania .

Dla mnie, wybierając między forEach()i peek(), wybieram następujące: Czy chcę, aby fragmenty kodu, które mutują obiekty strumienia, były dołączane do komponentu, czy też chcę, aby były dołączane bezpośrednio do strumienia?

Myślę, że peek()będzie lepiej sparować z metodami java9. np. takeWhile()może być konieczne podjęcie decyzji, kiedy zatrzymać iterację na podstawie już zmutowanego obiektu, więc parowanie go z forEach()nie miałoby takiego samego efektu.

PSmap() Nigdzie się nie odwoływałem, ponieważ w przypadku, gdy chcemy mutować obiekty (lub stan globalny), zamiast generować nowe obiekty, działa to dokładnie tak, jak peek().

Marinos An
źródło
3

Chociaż zgadzam się z większością powyższych odpowiedzi, mam jeden przypadek, w którym użycie wglądu wydaje się najczystszym sposobem.

Podobnie jak w przypadku użycia, załóżmy, że chcesz filtrować tylko na aktywnych kontach, a następnie zalogować się na te konta.

accounts.stream()
    .filter(Account::isActive)
    .peek(login)
    .collect(Collectors.toList());

Wgląd jest pomocny, aby uniknąć zbędnego wywołania bez konieczności dwukrotnego iterowania kolekcji:

accounts.stream()
    .filter(Account::isActive)
    .map(account -> {
        account.login();
        return account;
    })
    .collect(Collectors.toList());
UltimaWeapon
źródło
3
Wszystko, co musisz zrobić, to uzyskać właściwą metodę logowania. Naprawdę nie rozumiem, w jaki sposób podglądanie jest najczystszą drogą. Skąd ktoś, kto czyta Twój kod, powinien wiedzieć, że faktycznie nadużywasz API. Dobry i czysty kod nie zmusza czytelnika do robienia założeń dotyczących kodu.
kaba713
1

Funkcjonalnym rozwiązaniem jest uczynienie obiektu konta niezmiennym. Zatem account.login () musi zwrócić nowy obiekt konta. Oznacza to, że operacja mapy może służyć do logowania zamiast wglądu.

Solubris
źródło