Blog: Java, Software-Entwicklung & mehr

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.