In Java-Streams ist Peek wirklich nur zum Debuggen?

136

Ich lese über Java-Streams und entdecke dabei neue Dinge. Eines der neuen Dinge, die ich gefunden habe, war die peek()Funktion. Fast alles, was ich auf Peek gelesen habe, besagt, dass es zum Debuggen Ihrer Streams verwendet werden sollte.

Was wäre, wenn ich einen Stream hätte, in dem jedes Konto einen Benutzernamen, ein Kennwortfeld sowie eine Methode zum Anmelden () und Anmelden () hat?

ich habe auch

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

und

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

Warum sollte das so schlimm sein?

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

Soweit ich das beurteilen kann, macht dies genau das, was es tun soll. Es;

  • Nimmt eine Liste von Konten
  • Versucht, sich bei jedem Konto anzumelden
  • Filtert alle Konten heraus, die nicht angemeldet sind
  • Sammelt die angemeldeten Konten in einer neuen Liste

Was ist der Nachteil von so etwas? Gibt es einen Grund, warum ich nicht fortfahren sollte? Wenn nicht diese Lösung, was dann?

In der Originalversion wurde die .filter () -Methode wie folgt verwendet:

.filter(account -> {
        account.login();
        return account.loggedIn();
    })
Adam.J
quelle
38
Jedes Mal, wenn ich ein mehrzeiliges Lambda benötige, verschiebe ich die Zeilen in eine private Methode und übergebe die Methodenreferenz anstelle des Lambda.
VGR
1
Was ist die Absicht - versuchen Sie, alle Konten anzumelden und danach zu filtern, ob sie angemeldet sind (was möglicherweise trivial wahr ist)? Oder möchten Sie sie anmelden und dann danach filtern, ob sie angemeldet sind oder nicht? Ich frage dies in dieser Reihenfolge, da dies forEachmöglicherweise die gewünschte Operation ist peek. Nur weil es in der API enthalten ist, heißt das nicht, dass es nicht für Missbrauch offen ist (wie Optional.of).
Makoto
8
Beachten Sie auch, dass Ihr Code nur .peek(Account::login)und sein kann .filter(Account::loggedIn); Es gibt keinen Grund, ein Consumer and Predicate zu schreiben, das nur eine andere Methode wie diese aufruft.
Joshua Taylor
2
Beachten Sie auch, dass die Stream-API Nebenwirkungen von Verhaltensparametern ausdrücklich abschreckt .
Didier L
6
Nützliche Verbraucher haben immer Nebenwirkungen, von denen natürlich nicht abgeraten wird. Dies wird tatsächlich im selben Abschnitt erwähnt: „ Eine kleine Anzahl von Stream-Vorgängen wie forEach()und peek()kann nur über Nebenwirkungen ausgeführt werden. Diese sollten mit Vorsicht verwendet werden. ”. Meine Bemerkung war eher, um daran zu erinnern, dass die peekOperation (die für Debugging-Zwecke entwickelt wurde) nicht durch dasselbe in einer anderen Operation wie map()oder ersetzt werden sollte filter().
Didier L

Antworten:

77

Der Schlüssel zum Erfolg:

Verwenden Sie die API nicht unbeabsichtigt, auch wenn sie Ihr unmittelbares Ziel erreicht. Dieser Ansatz könnte in Zukunft brechen, und es ist auch für zukünftige Betreuer unklar.


Es schadet nicht, dies auf mehrere Operationen aufzuteilen, da es sich um unterschiedliche Operationen handelt. Es ist schädlich, die API auf unklare und unbeabsichtigte Weise zu verwenden. Dies kann Auswirkungen haben, wenn dieses bestimmte Verhalten in zukünftigen Versionen von Java geändert wird.

Die Verwendung forEachdieser Operation würde dem Betreuer klar machen, dass für jedes Element von eine beabsichtigte Nebenwirkung vorliegt accountsund dass Sie eine Operation ausführen, die es mutieren kann.

Es ist auch konventioneller in dem Sinne, dass peekes sich um eine Zwischenoperation handelt, die nicht die gesamte Sammlung bearbeitet, bis die Terminaloperation ausgeführt wird, sondern forEachtatsächlich eine Terminaloperation ist. Auf diese Weise können Sie starke Argumente für das Verhalten und den Fluss Ihres Codes vorbringen, anstatt Fragen zu stellen, ob Sie peeksich genauso verhalten würden wie forEachin diesem Kontext.

accounts.forEach(a -> a.login());
List<Account> loggedInAccounts = accounts.stream()
                                         .filter(Account::loggedIn)
                                         .collect(Collectors.toList());
Makoto
quelle
3
Wenn Sie die Anmeldung in einem Vorverarbeitungsschritt durchführen, benötigen Sie überhaupt keinen Stream. Sie können forEachdirekt an der Quellensammlung durchführen:accounts.forEach(a -> a.login());
Holger
1
@ Holger: Hervorragender Punkt. Ich habe das in die Antwort aufgenommen.
Makoto
2
@ Adam.J: Richtig, meine Antwort konzentrierte sich mehr auf die allgemeine Frage in Ihrem Titel, dh diese Methode dient wirklich nur zum Debuggen, indem die Aspekte dieser Methode erläutert werden. Diese Antwort ist mehr auf Ihren tatsächlichen Anwendungsfall und dessen Vorgehensweise ausgerichtet. Man könnte also sagen, zusammen liefern sie das Gesamtbild. Erstens der Grund, warum dies nicht die beabsichtigte Verwendung ist, zweitens die Schlussfolgerung, sich nicht an eine unbeabsichtigte Verwendung zu halten und stattdessen zu tun. Letzteres wird für Sie praktischer sein.
Holger
2
Natürlich war es viel einfacher, wenn die login()Methode einen booleanWert zurückgab , der den Erfolgsstatus angibt…
Holger
3
Darauf habe ich abgezielt. Wenn login()a zurückgegeben wird boolean, können Sie es als Prädikat verwenden, das die sauberste Lösung darstellt. Es hat immer noch einen Nebeneffekt, aber das ist in loginOrdnung, solange es nicht stört, dh der Prozess eines Accounthat keinen Einfluss auf den Anmeldeprozess eines anderen Account.
Holger
111

Das Wichtige, was Sie verstehen müssen, ist, dass Streams vom Terminalbetrieb gesteuert werden . Die Terminaloperation bestimmt, ob alle Elemente verarbeitet werden müssen oder überhaupt. Dies collectist eine Operation, die jedes Element verarbeitet, während findAnydie Verarbeitung von Elementen möglicherweise beendet wird, sobald ein übereinstimmendes Element gefunden wird.

Und count()möglicherweise werden überhaupt keine Elemente verarbeitet, wenn die Größe des Streams bestimmt werden kann, ohne die Elemente zu verarbeiten. Da dies eine Optimierung ist, die nicht in Java 8, sondern in Java 9 vorgenommen wurde, kann es zu Überraschungen kommen, wenn Sie zu Java 9 wechseln und Code haben, der auf der count()Verarbeitung aller Elemente beruht . Dies hängt auch mit anderen implementierungsabhängigen Details zusammen, z. B. kann die Referenzimplementierung selbst in Java 9 die Größe einer unendlichen Stream-Quelle in Kombination nicht vorhersagen, limitwährend es keine grundlegende Einschränkung gibt, die eine solche Vorhersage verhindert.

Da peekdie Ausführung der bereitgestellten Aktion für jedes Element möglich ist, wenn Elemente aus dem resultierenden Stream verbraucht werden , ist keine Verarbeitung von Elementen erforderlich, sondern die Aktion wird abhängig von den Anforderungen der Terminaloperation ausgeführt. Dies bedeutet, dass Sie es mit großer Sorgfalt verwenden müssen, wenn Sie eine bestimmte Verarbeitung benötigen, z. B. eine Aktion auf alle Elemente anwenden möchten. Es funktioniert, wenn die Terminaloperation garantiert alle Elemente verarbeitet, aber selbst dann müssen Sie sicher sein, dass nicht der nächste Entwickler die Terminaloperation ändert (oder Sie diesen subtilen Aspekt vergessen).

Während Streams garantieren, dass die Begegnungsreihenfolge für eine bestimmte Kombination von Operationen auch für parallele Streams beibehalten wird, gelten diese Garantien nicht für peek. Beim Sammeln in einer Liste hat die resultierende Liste die richtige Reihenfolge für geordnete parallele Streams, die peekAktion kann jedoch in beliebiger Reihenfolge und gleichzeitig aufgerufen werden.

Das Nützlichste, was Sie tun können, peekist herauszufinden, ob ein Stream-Element verarbeitet wurde. Genau das steht in der API-Dokumentation:

Diese Methode dient hauptsächlich zur Unterstützung des Debuggens, bei dem Sie die Elemente sehen möchten, wenn sie an einem bestimmten Punkt in einer Pipeline vorbeifließen

Holger
quelle
Wird es im Anwendungsfall von OP ein zukünftiges oder gegenwärtiges Problem geben? Macht sein Code immer das, was er will?
ZhongYu
9
@ bayou.io: Soweit ich sehen kann, gibt es in genau dieser Form kein Problem . Aber wie ich zu erklären versuchte, bedeutet die Verwendung auf diese Weise, dass Sie sich an diesen Aspekt erinnern müssen, auch wenn Sie ein oder zwei Jahre später zum Code zurückkehren, um «Feature Request 9876» in den Code aufzunehmen…
Holger
1
"Die Peek-Aktion kann in beliebiger Reihenfolge und gleichzeitig aufgerufen werden." Verstößt diese Aussage nicht gegen ihre Regel, wie Peek funktioniert, z. B. "wenn Elemente verbraucht werden"?
Jose Martinez
5
@Jose Martinez: Es heißt "wie Elemente aus dem resultierenden Stream verbraucht werden ", was nicht die Terminalaktion, sondern die Verarbeitung ist, obwohl selbst die Terminalaktion Elemente außer Betrieb verbrauchen könnte, solange das Endergebnis konsistent ist. Ich denke aber auch, dass der Satz des API-Hinweises „ Sehen Sie die Elemente, wenn sie an einem bestimmten Punkt in einer Pipeline vorbeifließen “ es besser beschreibt.
Holger
23

Vielleicht sollte eine Faustregel lauten: Wenn Sie Peek außerhalb des "Debug" -Szenarios verwenden, sollten Sie dies nur tun, wenn Sie sich über die Bedingungen für die Beendigung und Zwischenfilterung sicher sind. Beispielsweise:

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

scheint ein gültiger Fall zu sein, in dem Sie in einem Arbeitsgang alle Foos in Bars umwandeln und ihnen alle Hallo sagen möchten.

Scheint effizienter und eleganter als so etwas wie:

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

und am Ende iterieren Sie eine Sammlung nicht zweimal.

chimäre8
quelle
4

Ich würde sagen, dass dies peekdie Möglichkeit bietet, Code zu dezentralisieren, der Stream-Objekte mutieren oder den globalen Status (basierend auf ihnen) ändern kann, anstatt alles in eine einfache oder zusammengesetzte Funktion zu packen, die an eine Terminalmethode übergeben wird.

Nun könnte die Frage lauten: Sollten wir Stream-Objekte mutieren oder den globalen Status innerhalb von Funktionen in der Java-Programmierung im funktionalen Stil ändern ?

Wenn die Antwort auf eine der beiden oben genannten Fragen Ja lautet (oder: in einigen Fällen Ja), dann peek()ist dies definitiv nicht nur für Debugging-Zwecke , aus demselben Grund, der forEach()nicht nur für Debugging-Zwecke gilt .

Wenn ich zwischen forEach()und wähle peek(), wähle ich Folgendes: Soll Code-Teile, die Stream-Objekte mutieren, an ein Composable angehängt werden, oder sollen sie direkt an Stream angehängt werden?

Ich denke, peek()wird besser mit Java9-Methoden koppeln. z. B. muss takeWhile()möglicherweise entschieden werden, wann die Iteration basierend auf einem bereits mutierten Objekt gestoppt werden soll, sodass das Parieren mit forEach()nicht den gleichen Effekt hätte.

PS Ich habe map()nirgendwo darauf verwiesen , weil es genau so funktioniert, wenn wir Objekte (oder den globalen Status) mutieren möchten, anstatt neue Objekte zu generieren peek().

Marinos An
quelle
3

Obwohl ich den meisten Antworten oben zustimme, habe ich einen Fall, in dem die Verwendung von Peek tatsächlich der sauberste Weg zu sein scheint.

Angenommen, Sie möchten ähnlich wie in Ihrem Anwendungsfall nur nach aktiven Konten filtern und sich dann für diese Konten anmelden.

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

Peek ist hilfreich, um den redundanten Aufruf zu vermeiden, ohne die Sammlung zweimal durchlaufen zu müssen:

accounts.stream()
    .filter(Account::isActive)
    .map(account -> {
        account.login();
        return account;
    })
    .collect(Collectors.toList());
UltimaWeapon
quelle
3
Alles was Sie tun müssen, ist diese Anmeldemethode richtig zu machen. Ich sehe wirklich nicht, wie der Blick der sauberste Weg ist. Woher sollte jemand, der Ihren Code liest, wissen, dass Sie die API tatsächlich missbrauchen? Guter und sauberer Code zwingt einen Leser nicht dazu, Annahmen über den Code zu treffen.
kaba713
1

Die funktionale Lösung besteht darin, das Kontoobjekt unveränderlich zu machen. Daher muss account.login () ein neues Kontoobjekt zurückgeben. Dies bedeutet, dass die Kartenoperation für die Anmeldung anstelle von Peek verwendet werden kann.

Solubris
quelle