Java Streams vs Javaslang - funktional arbeiten mit Java8
01.12.2016 Michael Mosmann
Wer unter Java7 schon funktional mit Daten arbeiten wollte, hat nicht selten auf Guava zurückgegriffen. Als mit Java8 Lamdas/Closures einzug hielten und man mit Streams Funktionalitäten bereitgestellt hat, mit denen man ebenfalls funktional mit Daten arbeiten kann, hat der eine oder andere sicher ganz auf Java-Streams gesetzt. Guava war nicht mehr notwendig.
Allerdings war Guava ja nicht nur eine Funktionssammlung zum Datenfiltern, sondern stellte für die wichtigsten Datenstrukturen unveränderliche Implementierungen bereit. Da diese Implementierungen auf dem Java-Collection-API aufsetzten, konnte man recht schmerzfrei z.B. eine ArrayList durch eine ImmutableList austauschen. Da aber das API in keiner Weise dafür ausgelegt war, dass es auch unveränderliche Implementierungen geben könnte, gab es immer an den Grenzen Überraschungen, wenn zur Laufzeit Fehler auftraten, weil jemand ein Element in eine Liste einfügen wollte, die aber als unveränderlich implementiert wurde.
Das Javaslang-Projekt http://www.javaslang.io/ geht dabei einen eigenen Weg. Es verabschiedet sich vom Java-Collection-API und bringt ein eigenes API mit. Auch wenn die Interaktion mit dem Java-Collection-API gegeben ist (meist wird Iterable
implementiert), zielt es wohl eher darauf ab, dass man alles aus dem Java-Collection-API ersetzt. Dabei sind alle Datenstrukturen unveränderlich und sogar noch besser aufeinander abgestimmt, als das beim Collection-API der Fall ist.
Um ein Gefühl für die Unterschiede zu bekommen, zeige ich im Folgenden beispielhafte Implementierungen für klassische Anwendungsfälle: jeweils mit den Möglichkeiten von Javaslang oder den mit Java8 hinzugefügten Streams
. Anhand dieser Beispiele möchte ich die Vor- und Nachteile der jeweiligen Lösung beleuchten.
Daten aus einer Liste in eine Map transformieren
1:1 Map
Ein klassischer Anwendungsfall: man bekommt eine Liste von Elementen und greift wiederholt und oft über ein Schlüssel, der sich aus den Daten ermitteln lässt, auf die Elemente zu.
Javaslang
List<Integer> source = List.of(1,2,3,4);
Map<String, Integer> result = source.groupBy(e -> e.toString())
.mapValues(v -> Iterables.getOnlyElement(v));
Wenn man mit Javaslang solche Transformationen durchführt, bekommt man immer als Rückgabewert eine Immutable-Implementierung. Im Gegensatz zu Java8 und Guava wird aber bei Kollisionen keine Exception geworfen. Um also zuverlässig eine eineindeutige Map zu erhalten, kann man die Transformation in diese beiden Schritte zerlegen. Die Iterables.getOnlyElement()
-Methode ist eine Guava-Funktion.
Java8
Collection<Integer> source = ImmutableList.of(1, 2, 3, 4);
Map<String, Integer> result = source.stream()
.collect(Collectors.toMap(e -> e.toString(), x -> x));
Da es bei Java8 keine wirklichen Immutable-Implementierungen gibt, ist der Rückgabewert immer veränderlich. Ich habe mich für die Beispiele bewusst an typischen Anwendungsfällen orientiert. Dabei fällt hier vielleicht das erste Mal auf, dass das Java8-API generischer als das Javaslang-API ist, aber man vielleicht gerade für dieses Beispiel fragt, ob man a) auch schnell auf diese Lösung gekommen wäre und b) ob es die richtige Lösung für dieses Problem ist.
Transformation von Schlüssel und Wert
Für dieses Beispiel stellen wir uns eine Liste mit Personen vor, wo ein Zugriff auf das Alter anhand des Names ermöglicht werden soll.
Javaslang
List<Person> source = List.of(Person.of("Klaus",42),Person.of("Bert",33));
Map<String, Integer> result = source.toMap(e -> Tuple.of(e.name, e.age));
Java8
Collection<Person> source = ImmutableList.of(Person.of("Klaus",42),Person.of("Bert",33));
Map<String, Integer> result = source.stream()
.collect(Collectors.toMap(e -> e.name, e -> e.age));
Die Java8-Version unterscheidet sich hier kaum von dem vorhergehenden Beispiel, die Javaslang-Version wirkt durch den fehlenden Zwischenschritt über Streams noch einfacher. Außerdem ist anzumerken, dass die Javaslang-Versionen immer unveränderlich sind.
Werte anhand eines Schlüssels gruppieren
Dieser Anwendungsfall ist seltener und wer sich vor Java8/Javaslang an diese Problemstellung herangewagt hat, der wird sehr schnell auf Lösungen gestoßen sein, die alles andere als einfach zu implementieren waren. Das Grundproblem ist in diesem Fall, dass man nicht einfach Elemente in eine Map
legen kann, sondern man muss vorher prüfen, ob es für einen Schlüssel schon einen Eintrag gibt und muss dann die Daten aus der Map
und die Daten die noch in der Map abgelegt werden sollen zusammenführen.
Javaslang
List<String> source = List.of("Klaus","Klaas","Susi","Jane","Jan","Jochen");
Map<String, List<String>> result = source.groupBy(e -> e.substring(0,1));
Knapper geht es kaum.
Java8
Collection<String> source = ImmutableList.of("Klaus","Klaas","Susi","Jane","Jan","Jochen");
Map<String, ? extends Collection<String>> result = source.stream()
.collect(Collectors.groupingBy(e -> e.substring(0,1)));
Die Java8-Version wirkt durch den generischen Ansatz sehr geschwätzig.
Listen aus Listen
Für diesen Artikel beschränken wir uns auf eine Teilmenge der Möglichkeiten, welche Funktionen man auf Datenmengen anwenden kann. Jetzt interessieren uns die Anwendungsfälle, wo wir aus einem Element einer Liste ein neues Element in einer neuen Liste erstellen.
Listen filtern und transformieren
Nicht immer bekommt man die Daten die man z.B. zur Anzeige bringen möchte schon korrekt gefiltert und im richtigen Format. Das bedeutet, dass man vielleicht noch unerwünschte Elemente entfernen oder Daten neu zusammenstellen muss. Die Anwendungsfälle sind vielfälltig, in unserem Fall möchten wir aus einer Liste von Zahlen alle gerade Zahlen in String
umwandeln.
Javaslang
List<Integer> src = List.of(1,2,3,4,5,6,7);
List<String> result = src
.filter(e -> e % 2 == 0)
.map(e -> e.toString());
Es ist auffällig, das man nach keiner Operation eine abschließende Methode aufrufen muss, die das Endergebnis einsammelt. Es ist auf diese Weise auch gefahrlos möglich, einen Teil zu extrahieren ohne dass es dadurch zu einem anderen Verhalten kommt, was bei Java-Streams nicht garantiert ist.
Java8
List<Integer> src = Lists.newArrayList(1,2,3,4,5,6,7);
Collection<String> result = src.stream()
.filter(e -> e % 2 == 0)
.map(e -> e.toString())
.collect(Collectors.toList());
Schaut man sich die beiden Beispiele an, dann sieht man, dass es wenige Unterschiede zwischen diesen Versionen zu geben scheint. Doch der Schein trügt. Der große Unterschied hierbei ist, das bei Streams das Ergebnis am Ende 'einsammeln' muss. Bei Javaslang ist jedes Zwischenergebnis unveränderlich vorhanden.
Flatmap - ein Universalwerkzeug
Der Begriff Flatmap verrät dem einen sehr viel, dem anderen sehr wenig. Im Wesentlichen ist es eine Kombination aus zwei Verarbeitungsschritten: einer Transformation von einem Element einer Liste in eine Liste von anderen Elementen und dem nachträglichen Aneinanderhängen der neuen Elemente in eine zusammenhängende Liste. Auf den ersten Blick wird nicht sofort klar, wozu man so eine Funktion überhaupt benötigt. Klarer wird es vielleicht, wenn man bedenkt, dass man die möglichen Ergebnisse der Transformation wie folgt zusammenfassen kann:
- Die Ergebnisliste ist leer.
- Die Ergebnisliste enthält genau ein Element.
- Die Ergebnisliste enthält mehr als ein Element.
Man könnte z.B. eine Liste von Elementen filtern, in dem man immer, wenn man das Element nicht im Ergebnis haben möchte, als Ergebnis eine leere Liste zurück gibt.
Listen aneinander hängen
Für das folgende Beispiel stellen wir uns vor, dass wir eine Liste von Wortgruppen haben aber alle Wörter in den verschiedenen Wortgruppen in einer neuen Liste benötigen.
Javaslang
List<List<String>> src = List.of(List.of("A","B","C"), List.of("D","E"), List.of("F","G","H","I","J"), List.of("Z"));
List<String> result = src.flatMap(e -> e);
Java8
List<ImmutableList<String>> src = ImmutableList.of(ImmutableList.of("A","B","C"),
ImmutableList.of("D","E"),
ImmutableList.of("F","G","H","I","J"),
ImmutableList.of("Z"));
List<String> result = src.stream()
.flatMap(e -> e.stream())
.collect(Collectors.toList());
Javaslang in Action
Für die klassischen Anwendungsfälle ist man mit beiden Lösungen gut aufgestellt. Allerdings bekommt man mit Javaslang unveränderliche Datenstrukturen und ein sehr rundes API über alle Collections hinweg.
String result = List.of("Susi","Sara","Jane")
.zipWithIndex()
.toMap(t -> Tuple.of(t._2(), t._1()))
.map(t -> t._1+":"+t._2)
.foldLeft(null, (l,r) -> l==null ? r : l+" - "+r);
In dem Beispiel machen wir aus einer Liste, eine Liste von Tuples mit dem Index des Elements als zweiten Wert, dann machen wir daraus eine Map anhand des Index, transformieren wir die Map in eine Sequenz in dem wir aus Key und Value einen String machen und erzeugen daraus mit foldLeft()
einen String.
Zusammenfassung
Wärend die Verarbeitung von sequenziellen Daten mit Java-Streams gut abgebildet wird, fällt doch sehr schnell auf, dass es für andere Konstrukte wenig Unterstützung gibt. Mit Javaslang hat man einen sehr mächtigen und umfangreichen Werkzeugkasten, der auch gerade wegen der standardmäßigen Unveränderlichkeit einen Blick wert ist.