Blog: Java, Software-Entwicklung & mehr

Java Streams - ein Vergleich zwischen Guava und Java 8

27.08.2015 Michael Mosmann

Ein wichtiges Sprachmittel wurde mit Java in der Version 8 eingeführt: Lamdas/Closures. Gerade für Datenverarbeitungen setzt sich der funktionale Ansatz zunehmend durch, Daten werden gefiltert, transformiert, sortiert. Wenn man nur auf die Sprachmittel zurückgreifen konnte, die Java in der Version 7 bereitgestellt hat, sah das oft recht umständlich und geschwätzig aus. Alle Hoffnungen lagen auf den neuen Möglichkeiten, die man mit Java8 eingeführt hat. Wer mit Java7 schon dem funktionalen Ansatz für Datenverarbeitung gefröhnt hat, der wird vermutlich auch den Einsatz von Guava als Unterstützung in Erwägung gezogen haben.

Im Folgenden zeige ich beispielhafte Implementierungen für klassische Anwendungsfälle: jeweils mit den Möglichkeiten von Guava 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.

Guava


Collection<Integer> source = ImmutableList.of(1, 2, 3, 4);
ImmutableMap<String, Integer> result = Maps.uniqueIndex(source,e -> e.toString());

Wenn man mit Guava solchen Transformationen durchführt, bekommt man sehr oft als Rückgabewert eine von den zahlreichen Immutable-Implementierungen der klassischen Java-Collections. Wenn wie der Name schon sagt, für einen Eintrag ein Indexwert erzeugt wird, der bereits in der Map vorhanden ist, dann wirft diese Funktion eine Exception.

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 auch 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 Guava-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.

Guava


Collection<Person> source = ImmutableList.of(Person.of("Klaus",42),Person.of("Bert",33));
Map<String, Integer> result = Maps.transformValues(Maps.uniqueIndex(source, e -> e.name), e -> 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 Guava-Version ist eine Kombination von zwei Teiltransformationen. In beiden Fällen sieht es so aus, als ob der Rückgabewert veränderlich wäre, aber mindestens die Guava-Variante ist es nicht. Das hat damit zu tun, dass Guava in einigen Fällen die Transformation erst durchführt, wenn auf das entsprechende Element zugrgriffen wird (lazy). Das hat zwei wesentliche Effekte: zum einen wird in diesen Fällen eine Veränderung der Collection nicht unterstützt und zum anderen fallen Fehler in der Transformation (z.B. fehlende Null-Prüfungen) erst zum Zeitpunkt der Verwendung und nicht schon zum Zeitpunkt der Transformation auf. Als generelle Empfehlung sollte man sich darauf festlegen, dass man Daten die als Rückgabewert einer Funktion entstanden sind, nicht verändert. Wenn man es explizit machen möchte, dann kopiert man die Daten in eine unveränderliche Variante und erhält als Nebeneffekt auch die RuntimeException an der richtigen Stelle.

Werte anhand eines Schlüssels gruppieren

Dieser Anwendungsfall ist seltener und wer sich vor Java8/Guava 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.

In Guava gibt es mehrere Möglichkeiten, dieses Problem zu lösen (Multimap), in diesem Fall betrachten wir allerdings nur, wie man aus bestehenden Daten eher funktional diese Aggregation erhält.

Guava


Collection<String> source = ImmutableList.of("Klaus","Klaas","Susi","Jane","Jan","Jochen");
ImmutableListMultimap<String, String> multimap = Multimaps.index(source, e -> e.substring(0,1));
ImmutableMap<String, Collection<String>> result = multimap.asMap();

In diesem Fall habe ich den Code länger als notwendig gemacht, damit man erkennen kann, dass Guava für dieses Anwendungsszenario eine eigene Implementierung anbietet, die man mit asMap() in eine klassische Java-Map-Implementierung umwandelt.

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)));

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.

Guava


List<Integer> src = Lists.newArrayList(1,2,3,4,5,6,7);
Collection<String> result = FluentIterable.from(src)
	.filter(e -> e % 2 == 0)
	.transform(e -> e.toString())
	.toList();

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 gibt. Hier ist vor allem die FluentIterable-Implementierung von Guava interessant, weil sie a) noch weiterreichende Möglichkeiten bietet und b) dem Stream-API von Java8 ähnelt. Der große Unterschied hierbei ist, dass Guava mit Iterable arbeitet und Java8 mit Streams. Iterables unterscheiden sich von einer java.util.Collection im wesentlichen dadurch, dass die Größe unbekannt ist und man keinen direkten Zugriff auf die Daten hat. Man kann die Daten im Iterable nur über einen Iterator erreichen. Die Iterable-Klasse ist der schlichteste Datenkontainer, den Java bietet. Leider hat man es verpasst, dem Iterator die remove-Methode zu entfernen. Sonst hätte man schon sehr lange einen sehr einfachen unveränderlichen Datentyp immer dann nutzen können, wenn die Anzahl der enthaltenen Elemente erst nach allen Verarbeitungsschritten ermittelt werden muss. Man hätte dann vielleicht ganz auf ein neues API in Java8 verzichten können und die notwendigen Funktionen in das Iterable-Interface einweben können.

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.

Guava


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 = FluentIterable.from(src)
		.transformAndConcat(e -> e)
		.toList();

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());

In diesem Beispiel sieht man, dass das Java8-API den 'klassischen' Begriff benutzt, Guava aber nicht auf die Erzeugung eines Stream angewiesen ist. Die Transformationsfunktion ist in unserem Beispiel sehr einfach. Wenn es komplizierter wird, stellt sich ohnehin die Frage, ob ein Lambda in dieser Situation das korrekte Sprachmittel ist.

Ansonsten ist der Unterschied gering, die allgemeine collect()-Methode von Java8 ist auch hier wieder flexibler, wobei mir spontan kein Szenario einfällt, wo als Ergebnis etwas anderes als eine Liste gewünscht ist.

Java7 vs Java8

Nicht immer ist es möglich, in den eigenen Projekten auf Java8 zu setzen. Trotzdem kann es von Vorteil sein, wenn man Datenverarbeitung eher funktional löst. Ein Kritikpunkt an Java vor der Version 8 war aber immer der aufgeblähte Code, wenn man anonyme Klassen benutzt. Dabei wird oft ignoriert, dass man auch mit Java7 durchaus Möglichkeiten hat, eine gute Lesbarkeit des Codes zu erreichen.

Im folgenden Beispiel wir aus einer Liste von Personen eine Map erstellen, die als Schlüssel den Namen benutzt und nur Personen enthält, die älter als 42 sind.

Java7+Guava

Die Methode person() ist eine statische Hilfsmethode, um den Code etwas abzukürzen und liefert einfach eine Instanz einer Person-Klasse mit den entsprechenden Attributen.


ImmutableList<Person> source = ImmutableList.of(person("Klaus",43),
		person("Susi",12),person("Jochen",102));
Map<String, Person> result = FluentIterable.from(source)
		.filter(Predicates.compose(bigger(42), Persons.Age))
		.uniqueIndex(Persons.Name);

Auf den ersten Blick sieht das nicht so aufgebläht auf, wie man das vielleicht aus der eigenen Erfahrung kennt. Natürlich muss man dazu etwas ausholen und die notwendige Funktionalität an andere Stelle bereitstellen. Als erstes benötigen wir eine Persons-Hilfsklasse (man beachte das zusätzliche 's').


public abstract class Persons {</p>

static final Function<Person, String> Name = new Function<Person, String>() {
	@Override
	public String apply(Person input) {
		return input.name();
	}
};

static final Function<Person, Integer> Age = new Function<Person, Integer>() {
	@Override
	public Integer apply(Person input) {
		return input.age();
	}
};

static final Predicate<Person> olderThan(int age) {
	return new Predicate<Person>() {

		@Override
		public boolean apply(Person input) {
			return input.age()>age;
		}
	};
}


Hier ist die Geschwätzigkeit von Java gut zu sehen. Der relevante Code besteht eigentlich aus einer Zeile, muss aber in ein Objekt verpackt werden. Da solche Transformationsfunktionen idealerweise zustandslos sind, kann man leicht auf statische Instanzen zurückgreifen. Das sollte mehrere positive Nebeneffekte haben. Es wird keine Instanz pro Aufruf erzeugt und die JVM kann den effektiven Code evtl. dahingehend optimieren, dass es keinen Laufzeitunterschied zu einer Variante gibt, wo man die relevante Zeile Code direkt an die richtige Stelle geschrieben hätte.

Es fehlt noch eine kleine generische Hilfsmethode bigger():


public static <T extends Comparable<T>> Predicate<T> bigger(T value) {
	return new Bigger<T>(value);
}

Java8


ImmutableList<Person> source = ImmutableList.of(person("Klaus",43),
		person("Susi",12),person("Jochen",102));
Map<String, Person> result = source.stream()
		.filter(e -> e.age()>42)
		.collect(Collectors.toMap(e -> e.name(), e -> e));

Die Java8-Version ist ohne diesen Aufwand bereits genauso kompakt. Man könnte also zu dem Schluss kommen, dass man mit dem Einsatz von Java8 sehr viel Schreibarbeit einspart. Man läuft allerdings Gefahr, dass man Kopien der Funktionalität über den Code verstreut, weil es sehr einfach ist, e -> e.age()>42 hinzuschreiben. Mit Java7 hat man automatisch darüber nachgedacht, wie ich eine annonyme Klasse vermeiden kann, weil der Code schnell unleserlich wurde und im diesem Zuge automatisch darüber nachgedacht, an welche Stelle und mit welcher Sichtbarkeit diese Funktion implementiert werden muss.

Zusammenfassung

Man muss nicht bis Java8 warten, um verschiedene Aufgaben mehr funktional zu lösen. Guava biete viele Möglichkeiten und löst manches in meinen Augen wesentlich besser als das Stream-API. Gerade die Immutable-Varianten von Set, Map und List sind in der Anwendung zu empfehlen, wenn man verstanden hat, welche Vorteile unveränderliche Objekte bieten und man sich der Einschränkungen der Standard-Java-Collections bewusst wird.

Man sollte bei Java8 ein Auge darauf haben, ob die neuen Sprachmittel an der richtigen Stelle zum Einsatz kommen. Gerade im Hinblick auf Testbarkeit kann man sonst sehr schnell vor dem Dilema stehen, dass es schwierig wird, gute und scharfe Tests zu formulieren.