Die in Java 8 LTS eingeführten Streams waren von Anfang an recht mächtig. In den folgenden Java-Versionen wurden verschiedene Erweiterungen im Bereich der Terminal Operations hinzugefügt. Erinnern wir uns: Terminal Operations dienen dazu, die Berechnungen eines Streams abzuschließen und den Stream beispielsweise in eine Collection oder einen Ergebniswert zu überführen.
Stay tuned
Regelmäßig News zur Konferenz und der Java-Community erhalten
Mit JEP 473 wird eine Erweiterung des Stream API zur Unterstützung benutzerdefinierter Intermediate Operations umgesetzt. Darunter versteht man Verarbeitungsschritte wie das Filtern und Transformieren, die sich zu komplexeren Aktionen verbinden lassen. Bisher gab es zwar diverse vordefinierte Intermediate Operations, eine Erweiterungsmöglichkeit war allerdings nicht vorgesehen. Eine solche ist jedoch wünschenswert, um Aufgaben realisieren zu können, die zuvor nicht ohne Weiteres oder nur mit Tricks und eher umständlich umzusetzen waren.
Einführung
Nehmen wir an, wir wollten alle Duplikate aus einem Stream herausfiltern und für ein Kriterium angeben. Um es einfach nachvollziehbar zu halten, betrachten wir einen Stream von Strings und als Kriterium deren Länge.
Hypothetisch wäre das wie folgt mit einer Intermediate Operation in Form einer fiktiven Methode distinctBy() bezüglich der Länge umsetzbar, indem man String::length als Kriterium definiert:
var result = Stream.of("Tim", "Tom", "Jim", "Mike"). distinctBy(String::length). // hypothetisch toList(); // result ==> [Tim, Mike]
Bitte beachten Sie, dass ich mich bei einigen Beispielen von jenen aus dem Original-JEP [1] inspirieren lassen und diese angepasst oder erweitert habe.
Abhilfe mit den bisherigen Möglichkeiten
Schauen wir uns einmal an, wie sich Duplikate bezüglich der Stringlänge mit den bisherigen Möglichkeiten des Stream API vermeiden lassen (Listing 1) – den dazu ebenfalls benötigten Record DistinctByLength stelle ich weiter unter vor.
jshell> var result = Stream.of("Tim", "Tom", "Jim", "Mike"). ...> map(DistinctByLength::new). // #1 ...> distinct(). // #2 ...> map(DistinctByLength::str). // #3 ...> toList(); result ==> [Tim, Mike]
Schauen wir uns einmal an, wie sich Duplikate bezüglich der Stringlänge mit den bisherigen Möglichkeiten des Stream API vermeiden lassen (Listing 1) – den dazu ebenfalls benötigten Record DistinctByLength stelle ich weiter unter vor.
record DistinctByLength(String str) { @Override public boolean equals(Object obj) { return obj instanceof DistinctByLength(String other) && str.length() == other.length(); } @Override public int hashCode() { return str == null ? 0 : Integer.hashCode(str.length()); } }
Dieser Record ist lediglich ein Wrapper um einen String und besitzt dazu ein Attribut str sowie die korrespondierende Zugriffsmethode. Damit wir den Record für unseren Zweck verwenden können, müssen wir die Methoden equals() und hashCode() auf die Stringlänge ausgerichtet überschreiben. In der Implementierung von equals() verwenden wir das Pattern Matching bei instanceof in Kombination mit Record Patterns, wodurch sich der Sourcecode sehr kompakt halten lässt.
SIE LIEBEN JAVA?
Den Core-Java-Track entdecken
Beispiel Gruppierung
Ein weiteres Beispiel für den Bedarf an selbst definierten Intermediate Operations ist die Gruppierung der Daten eines Streams in Abschnitte fixer Größe. Zur Demonstration sollen jeweils vier Zahlen zu einer Einheit zusammengefasst, also gruppiert werden. Für unser Beispiel sollen nur die ersten drei Gruppen ins Ergebnis aufgenommen werden. Auch hier wird wieder der an den JEP angelehnte Sourcecode mit einer fiktiven Methode windowFixed() gezeigt (Listing 3).
record DistinctByLength(String str) { @Override public boolean equals(Object obj) { return obj instanceof DistinctByLength(String other) && str.length() == other.length(); } @Override public int hashCode() { return str == null ? 0 : Integer.hashCode(str.length()); } }
Neu: Interface Gatherer und die Methode gather()
Im Lauf der Jahre ist aus der Java-Community einiges an Vorschlägen und Wünschen für Intermediate Operations als Ergänzung für das Stream API eingebracht worden. Oftmals sind diese in ganz spezifischen Kontexten sinnvoll. Hätte man sie alle ins JDK integriert, hätte dies das API allerdings ziemlich aufgebläht und den Einstieg in das (ohnehin schon umfangreiche) Stream API (weiter) erschwert. Um aber dennoch die Flexibilität benutzerdefinierter Intermediate Operations zu ermöglichen, wird ein ähnlicher Ansatz wie bei den Terminal Operations und dem Extension Point in Form der Methode collect(Collector) und des Interface java.util.stream.Collector verfolgt. Durch diese Kombination lassen sich Terminal Operations bei Bedarf individuell ergänzen.
Um flexibel neue Intermediate Operations bereitstellen zu können, offeriert das Stream API nun eine Methode gather(Gatherer) in Kombination mit dem Interface
java.util.stream.Gatherer. Wollten wir die zuvor besprochene distinctBy()-Funktionalität selbst realisieren, so könnten wir dazu einen eigenen Gatherer implementieren – das würde jedoch den Rahmen dieser Einführung sprengen.
Ausgewählte Gatherer
Praktischerweise sind zur Umsetzung einiger Vorschläge und Wünsche aus der Java-Community nach spezifischen Intermediate Operations bereits ein paar Gatherer in das JDK aufgenommen worden. Sie sind in der Utility-Klasse java.util.stream.Gatherers definiert. Zum Nachvollziehen der Beispiele ist folgender Import nötig:
jshell> import java.util.stream.*
windowFixed
Um einen Stream in kleinere Bestandteile fixer Größe ohne Überlappung zu unterteilen, dient windowFixed() aus dem JDK. Greifen wir das zweite Beispiel aus der Einführung auf und schauen uns an, wie einfach es sich jetzt mit JDK-Basisfunktionalität realisieren lässt.
Nachfolgend wird per iterate() ein unendlicher Stream von Zahlen erzeugt und durch Aufruf von windowFixed(4) jeweils in Teilbereiche der Größe vier untergliedert. Mit limit(3) wird die Anzahl an Teilbereichen auf drei begrenzt und diese werden durch Aufruf von toList() in Form einer Liste als Ergebnis bereitgestellt (Listing 4).
jshell> var result = Stream.iterate(0, i -> i + 1). ...> gather(Gatherers.windowFixed(4)). ...> limit(3). ...> toList() resultNew ==> [[0, 1, 2, 3], [4, 5, 6, 7], [8, 9, 10, 11]]
Beim Unterteilen in Bereiche fester Größe gibt es einen Spezialfall zu beachten: Enthält ein Datenbestand nicht genügend Elemente, um die gewünschte Teilbereichsgröße zu füllen, enthält der letzte Teilbereich weniger Elemente. Als Beispiel dient ein Stream mit einem durch Aufruf von of() erzeugten fixen Datenbestand der Werte 0 bis einschließlich 6. Dieser wird mit windowFixed(3) in Teilbereiche der Größe drei unterteilt, wodurch der letzte Teilbereich nur ein Element enthält, nämlich die Zahl 6 (Listing 5).
jshell> var result = Stream.of(0, 1, 2, 3, 4, 5, 6). ...> gather(Gatherers.windowFixed(3)). ...> toList() result ==> [[0, 1, 2], [3, 4, 5], [6]]
windowSliding
Neben dem Unterteilen in jeweils unabhängige Teilbereiche kann auch eine Untergliederung mit Überlagerungen von Interesse sein. Um einen Stream in kleinere Bestandteile fixer Größe mit Überlappung zu unterteilen, dient die Methode windowSliding() aus dem JDK.
Wieder wird per iterate() ein unendlicher Stream von Zahlen erzeugt und durch Aufruf von windowSliding(4) jeweils in Teilbereiche der Größe vier untergliedert, allerdings mit einer Überlappung bzw. Verschiebung um ein Element. Mit limit(3) wird die Anzahl an Teilbereichen auf drei begrenzt. Wie zuvor werden diese durch Aufruf von toList() in Form einer Liste als Ergebnis bereitgestellt (Listing 6).
jshell> var result = Stream.iterate(0, i -> i + 1). ...> gather(Gatherers.windowSliding(4)). ...> limit(3). ...> toList() result ==> [[0, 1, 2, 3], [1, 2, 3, 4], [2, 3, 4, 5]]
Betrachten wir die Auswirkungen auf das zuvor als Spezialfall aufgeführte Beispiel eines Streams wieder mit den Werten 0 bis inklusive 6. Statt mit windowFixed() wird hier windowSliding() genutzt. Dadurch wird der Datenbestand in sich überlappende Teilbereiche untergliedert. Dementsprechend tritt hier die Situation eines unvollständigen letzten Teilbereichs nicht auf, sondern es werden fünf Teilbereiche mit je drei Elementen erzeugt (Listing 7).
jshell> var result = Stream.of(0, 1, 2, 3, 4, 5, 6). ...> gather(Gatherers.windowSliding(3)). ...> toList() result ==> [[0, 1, 2], [1, 2, 3], [2, 3, 4], [3, 4, 5], [4, 5, 6]]
Bei dieser Art von Operation kann der zuvor behandelte Spezialfall eines Datenbestands mit einer nicht ausreichenden Menge an Elementen normalerweise nicht auftreten. Das ist lediglich dann möglich, wenn die Länge der Eingabe kleiner als die Window-Größe ist – in dem Fall besteht das Ergebnis aus der gesamten Eingabe, wie es nachfolgend für einen Datenbestand von drei Werten und eine Window-Größe von fünf zu sehen ist. Das Ergebnis ist eine Liste, die wiederum eine Liste mit drei Elementen enthält (Listing 8).
jshell> var resultNew = Stream.of(1, 2, 3). ...> gather(Gatherers.windowSliding(5)). ...> toList() resultNew ==> [[1, 2, 3]]
fold
Dazu, nämlich die Werte eines Streams miteinander zu verknüpfen, dient die Methode fold(). Sie arbeitet ähnlich wie die Terminal Operation reduce(), die ein Ergebnis aus einer Folge von Elementen erzeugt, indem wiederholt eine Operation zur Kombination, beispielsweise + oder * für Zahlen, auf die Elemente angewendet wird. Dazu gibt man einen Startwert und eine Berechnungsvorschrift an. Diese Letztere fest, wie das bisherige Ergebnis mit dem aktuellen Element verknüpft wird.
Stay tuned
Regelmäßig News zur Konferenz und der Java-Community erhalten
Nutzen wir dieses Wissen als Ausgangsbasis für ein Beispiel mit fold(). Damit lässt sich die Summe der Werte mit 0 als Startwert und einer Addition als Berechnungsvorschrift wie in Listing 9 berechnen.
jshell> var crossSum = Stream.of(1, 2, 3, 4, 5, 6, 7). ...> gather(Gatherers.fold(() -> 0L, ...> (result, number) -> result + number)). ...> findFirst() crossSum ==> Optional[28]
Bedenken Sie, dass gather() einen Stream als Ergebnis zurückgibt. Hier ist das ein einelementiger Stream. Um daraus einen Wert auszulesen, dient der Aufruf von findFirst(), der ein Optional<T> liefert, weil theoretisch der Stream auch leer sein könnte.
Als Berechnungsvorschrift können wir alternativ etwa eine Multiplikation mit einem Startwert von 1 nutzen und so für die spezielle Wertefolge von 1 bis 7 die Fakultät berechnen (Listing 10). Ganz allgemein handelt es sich um eine Multiplikation der gegebenen Zahlen (Listing 11).
jshell> var result = Stream.of(1, 2, 3, 4, 5, 6, 7). ...> gather(Gatherers.fold(() -> 1L, ...> (result, number) -> result * number)). ...> findFirst() result ==> Optional[5040]
jshell> var result = Stream.of(10, 20, 30, 40, 50). ...> gather(Gatherers.fold(() -> 1L, ...> (result, number) -> result * number)). ...> findFirst() result ==> Optional[12000000]
Aktionen für abweichende Typen
Was passiert, wenn wir zur Kombination der Werte auch solche Aktionen ausführen wollen, die nicht für die Typen der Werte, hier int, definiert sind? Als Beispiel wird ein Zahlenwert in einen String umgewandelt und dieser gemäß dem Zahlenwert durch Aufruf der Methode repeat() der Klasse String wiederholt (Listing 12).
jshell> var repeatedNumbers = Stream.of(1, 2, 3, 4, 5, 6, 7). ...> gather(Gatherers.fold(() -> "", ...> (result, number) -> result + ...> ("" + number).repeat(number))). ...> toList() repeatedNumbers ==> [1223334444555556666667777777]
Varianten mir reduce()
Nur der Vollständigkeit halber seien hier die vorherigen Berechnungen als Varianten mit reduce() gezeigt. Weil reduce() eine Terminal Operation ist, lässt sie keine weitere Verarbeitung im Stream mehr zu – zudem funktionieren die Aktionen nur auf den Typen der Werte, womit sich das zuletzt gezeigte Beispiel nicht umsetzen lässt (Listing 13). Genau wie bei fold() werden bei reduce() ein Startwert und eine Berechnungsvorschrift angegeben. Daraus entsteht dann ein Ergebniswert.
jshell> var sum = Stream.of(1, 2, 3, 4, 5, 6, 7). ...> reduce(0, (result, number) -> result + number) sum ==> 28 jshell> var result = Stream.of(1, 2, 3, 4, 5, 6, 7). ...> reduce(1, (result, number) -> result * number) result ==> 5040 jshell> var result = Stream.of(10, 20, 30, 40, 50). ...> reduce(1, (result, number) -> result * number) result ==> 12000000
scan
Sollen alle Elemente eines Streams zu neuen Kombinationen zusammengeführt werden, sodass jeweils immer ein Element dazukommt, kommt scan() zum Einsatz. Die Methode arbeitet ähnlich wie fold(), das die Werte zu einem Ergebnis kombiniert. Bei scan() wird dagegen für jedes weitere Element ein neues Ergebnis produziert.
Zunächst nutzen wir dies für die Ermittlung von Summen (Listing 14). Danach kombinieren wir Texte statt Ziffern nur durch Abwandlung des Startwerts, für den wir hier einen Leerstring nutzen, wodurch das + zu einer Stringkonkatenation wird (Listing 15).
jshell> var crossSums = Stream.of(1, 2, 3, 4, 5, 6, 7). ...> gather(Gatherers.scan(() -> 0, ...> (result, number) -> result + number)). ...> toList() crossSums ==> [1, 3, 6, 10, 15, 21, 28]
jshell> var crossSums = Stream.of(1, 2, 3, 4, 5, 6, 7). ...> gather(Gatherers.scan(() -> 0, ...> (result, number) -> result + number)). ...> toList() crossSums ==> [1, 3, 6, 10, 15, 21, 28]
Man könnte auch eine n-malige Wiederholung realisieren – dabei wird schön der Unterschied zu fold() deutlich (Listing 16).
jshell> var repeatedNumbers = Stream.of(1, 2, 3, 4, 5, 6, 7). ...> gather(Gatherers.scan(() -> "", ...> (result, number) -> result + ...> ("" + number).repeat(number))). ...> toList() repeatedNumbers ==> [1, 122, 122333, 1223334444, 122333444455555, 122 ... 3334444555556666667777777]
Fazit
In diesem Artikel haben wir uns mit den Stream Gatherers als Preview-Feature beschäftigt. Zunächst habe ich erläutert, warum diese Neuerung für uns Entwickler nützlich und hilfreich ist. Danach wurden diverse bereits im JDK vordefinierte Stream Gatherers anhand von kleinen Anwendungsbeispielen vorgestellt. Insbesondere wurden auch Randfälle und Besonderheiten beleuchtet. Dadurch sollten Sie einen guten ersten Überblick gewonnen haben und fit genug sein, um eigene Experimente zu starten.
Neben den Stream Gatherers enthält Java 23 viele weitere Neuerungen, die die Programmiersprache voranbringen und attraktiver machen. Diese Modernisierung sollte dazu beitragen, dass Java weiterhin konkurrenzfähig bleibt und sich in modernen Anwendungsbereichen behauptet und insbesondere auch zu anderen derzeit populären Sprachen wie Python oder Kotlin aufschließt. Auch für komplette Newbies wird die Einstiegshürde gesenkt: Dank JEP 477 (Implicitly Declared Classes and Instance Main Methods (Third Preview)) lassen sich kleinere Java-Programme viel schneller und mit deutlich weniger Zeilen sowie für Anfänger schwierigen Begrifflichkeiten erstellen.
In diesem Sinne: Happy Coding mit dem brandaktuellen Java 23!
Stay tuned
Regelmäßig News zur Konferenz und der Java-Community erhalten