Java 8 - Erfahrungen und Good Practices
In der Softwareentwicklung gibt es immer neue Technologien: hier ein neues Framework, dort eine komplett neue moderne Programmiersprache, da ein neues Tool und jetzt bei Java eine neue Version: Java 8 mit vielen neuen Features. Wie gehen wir nun damit um, wie setzen wir diese Neuerungen am besten ein? Im Folgenden beschreibe ich erste Erfahrungen und Good Practices mit Lambdas, built-in functional interfaces und Streams in Java 8.
Lambda-Ausdrücke und die "Lambdafication"
Mit den Lambda-Ausdrücken wurde Java eine wichtige neue Funktionalität hinzugefügt, durch die funktionale Programmierung möglich ist. Als erstes erkläre ich kurz, wie die Lambda-Ausdrücke aufgebaut sind und zeige einige Bespiele dazu:
Argument List Arrow Token Body (int x, int y) -> x+y (String s) -> {System.out.println(s);} () -> 2
Die Liste mit den Argumenten kann leer sein oder eins oder mehrere Argumente enthalten. Der Body enthält einen einzelnen Ausdruck oder einen ganzen Block aus Anweisungen, der dann ausgeführt wird und ein Ergebnis zurückgibt (void ist auch gültiger Rückgabewert).
Beim ersten Ausdruck werden zwei INT-Werte addiert und die Summe zurückgegeben, beim zweiten ein String auf der Konsole ausgegeben und nichts (void) zurückgegeben und beim dritten Beispiel wird immer 2 zurückgegeben. Wir benutzen nun den zweiten Ausdruck um alle Elemente einer Liste auszugeben:
List<Integer> list = Arrays.asList(1,2,3,4,5,6,7,8,9); list.forEach((Integer s) -> { System.out.println(s);});
Ganz einfach konnte eine for-Schleife durch einen Lambda-Ausdruck ersetzt werden. Das Iterable Interface wurde in Java 8 durch die Methode forEach erweitert, die als Parameter einen Consumer erwartet:
public interface Iterable<T> { Iterator<T> iterator(); default void forEach(Consumer<? super T> action) { for (T t : this) { action.accept(t); } } }
Der Consumer ist ein funktionales Interface und kann als Zuweisungsziel für einen Lambda-Ausdruck benutzt werden, der dann auf alle Elemente unserer Liste angewendet wird. Wir wollen aus unserer Liste nun alle durch zwei teilbaren Zahlen einer neuen Liste hinzufügen:
List<Integer> filteredList = new ArrayList<Integer>(); for (int i:list) { if(i%2 == 0) { filteredList.add(i); } }
Mit Lambda Ausdrücken kann es folgendermaßen verkürzt werden:
List<Integer> filteredList = new ArrayList<Integer>(); list.forEach(n -> { if(n%2 == 0) filteredList.add(n); });
Wir wenden einfach auf jedes Element in der Liste eine Lambda-Expression an, die auf modulo 2 prüft und dann das Element einer neuen Liste hinzufügt.
Genauso kann man für das Sortieren einer Liste Lambda-Expressions verwenden und
Collections.sort(list, new Comparator<Integer>() { @Override public int compare(Integer a, Integer b) { return b.compareTo(a); } });
durch Ersetzen der inneren anonymen Klasse folgendermaßen vereinfachen:
Collections.sort(list, (a, b) -> b.compareTo(a));
Nachdem anhand von ein paar Beispielen ein erster Eindruck zu Lambda-Expressions gewonnen wurde, stellen sich jetzt folgende Fragen: Wie und wo verwendet man Lambdas nun am besten? Was sollte man vermeiden? Worauf muss geachtet werden?
Zum einen können – wie schon gezeigt – anonymous inner classes durch Lambdas ersetzt werden und der Code "stylischer" geschrieben werden. Das ist aber bei Weitem noch nicht alles: Eine weitere Möglichkeit ist, den Code, der sonst innerhalb einer for-Schleife sequentiell ausgeführt wird, parallel ausführen zu lassen. Hierzu benutzen wir die mit Java 8 eingeführten Streams:
List<Integer> plusList = new ArrayList<Integer>(); list.parallelStream() .forEach((Integer n) -> { plusList.add(n++); });
Hier muss allerdings beachtet werden, dass die Funktion parallel ausgeführt werden kann, aber dies nicht zwingend geschieht.
In allen Beispielen haben wir jeweils eine Funktion auf den Elementen der Liste ausgeführt und daraus eine neue Liste erzeugt. Ich möchte abschließend zu Lambdas noch zeigen, wie wir hier mit den in Java 8 eingeführten Build In Funtional Interfaces (s. u.) ganz einfach Wiederverwendung erzeugen können und in einer Testklasse Parameter übergeben können. Mit Hilfe folgender Methode
private static List<Integer> getSpecificNumbers ( List<Integer> list, Predicate<Integer> p){ List<Integer> result = new ArrayList<Integer>(); list.forEach(n -> {if(p.test(n)) result.add(n);}); return result; }
kann jeweils ein anderes Predicate übergeben werden:
List<Integer> list1 = getSpecificNumbers(list, z->z%2==0); List<Integer> list2 = getSpecificNumbers(list, z->z>5 && z<9 && z!=7); List<Integer> list3 = getSpecificNumbers(list, z->true);
Ein Predicate wertet einen Ausdruck aus und liefert einen Boolean-Wert zurück.
Nachdem ich nun mehrere Anwendungsbeispiele für Lambdas gezeigt habe, möchte ich noch auf Grenzen und Good Practices eingehen. Bisher habe ich nur Vorteile von Lambdas erwähnt, es gibt aber natürlich auch Grenzen. Zum einen sollte übertriebene Verschachtelung vermieden werden, weil zum Beispiel dieses Code-Beispiel nicht mehr übersichtlich ist:
cars.stream().forEach( car -> { System.out.print("Car " + car.getName() + ":"); car.setValue( IntStream.iterate( car.getValue(), i-> i/2) .limit(car.getValue()) .distinct() .map(i -> { System.out.print(i + ","); return i; }) .sum()); System.out.println(car.getValue( });
Des Weiteren kann in einer Lambda-Expression kein break verwendet werden, um dies auszudrücken muss wie gehabt eine for-Schleife verwendet werden.
Auch müssen die Variablen in einer Lambda-Expression final sein, sonst meckert der Compiler. Nachfolgendes Beispiel ist so nicht gültig:
int x = 1000; IntUnaryOperator up = (int i) -> { return i+x;}; x = 50;
Genauso muss beachtet werden, dass in dem Lambda-Ausdruck keine Exceptions liegen, da diese in den Functional Interfaces nicht deklariert werden. Also muss jeder Code in einen try catch-Block gepackt werden:
private static Integer methodThrowsException(String s) throws IOException{ return 1; } Function<String,Integer> function ; function = (String t) -> { try { return methodThrowsException(t); } catch(IOException e){ throw new RuntimeException(); } };
Man kann sich darüber streiten, ob diese Grenzen nun Nachteile der Lambda-Ausdrücke sind oder ob sie Sinn machen, denn dadurch kann man sofort an einer im Code platzierten Lambda-Expression die eben genannten Eigenschaften erkennen. Es lässt sich hier als Good Practice folgern, dass keine Ausdrücke ohne Seiteneffekte verwendet werden sollen.
Weitere Good Practices folgen aus den oben gezeigten Beispielen:
- Ersetze inner anonymous classes.
- Erziele Wiederverwendung durch geschickten Einsatz von Funktionen.
- Erhalte parallele Ausführung durch Streams.
Das Fazit für Lambdas ist, dass hier gesunder Menschenverstand bei der Verwendung eingesetzt werden muss und sie im Großen und Ganzen dazu verhelfen, mathematische Funktionen realitätsnaher darzustellen. Sobald man sich an die Lambdas gewöhnt hat, kann der Code viel sauberer und schneller erstellt werden und es können viele Eigenschaften sofort herausgelesen werden.
Java 8: Built-in functional interfaces
Functional Interfaces in Java 8 besitzen exakt eine abstrakte Methodendeklaration. Jeder Lambda-Ausdruck von diesem Typ wird in diese abstrakte Methode gematched. Um zu versichern, dass das Interface diese Bedingungen erfüllt, gibt es die neue Annotation @FunctionalInterface.
Es gibt folgende Built-in functional interfaces und noch weitere in Java 8:
- Predicate: Eine Eigenschaft des Objekts wird als Argument übergeben
Predicate<String> predicate = (s) -> s.length() > 0;
- Consumer: Eine Aktion die auf dem Objekt ausgeführt wird, wird als Argument übergeben
Consumer<String> c = (s) -> System.out.println(s);
- Function: transformiere ein T zu einem U
Function<String, Integer> toInteger = Integer n -> Integer.valueOf(n);
- Supplier: Stelle eine Instanz T bereit (vgl. factory)
Supplier<Stream> streamSupplier = Stream.of("a1","b2","c3"); streamSupplier.get();
- UnaryOperator: Ein unärer Operator von T -> T
IntUnaryOperator plusOne = (x) -> x + 1;
- BinaryOperator: Ein binärer Operator von (T, T) -> T
IntBinaryOperator add = (x,y) -> x + y;
Streams mit Java 8
Ich habe Streams schon in Bezug auf Lambdas angesprochen und möchte jetzt weiter darauf eingehen. Was sind Streams überhaupt? Und wie verwende ich diese am besten?
Ein Stream repräsentiert eine Sequenz aus Elementen auf dem je eine oder mehrere Operationen ausgeführt werden können:
List<String> myList = Arrays.asList("bc", "a2", "b1", "c2", "c1"); myList .stream() .filter(s -> s.startsWith("c")) .map(String::toUpperCase) .sorted() .forEach(System.out::println);
liefert den Output:
C1 C2
Stream-Operationen sind entweder intermediär (Zwischenoperationen) oder terminal. Dabei liefern terminale Operationen (vgl. foreach) ein Ergebnis als Objekt von einem bestimmten Typ (auch void) zurück und intermediäre Operationen (vgl. filter, map, sorted) liefern einen Stream zurück, das heißt mehrere Operationen können in einer Reihe aneinander gekettet werden. Die meisten Stream-Operationen akzeptieren als Parameter einen Lambda-Ausdruck, wobei das Verhalten der Operation in einem funtional interface spezifiziert ist. Die meisten dieser Operationen müssen non-interfering und stateless sein. Eine non-interfering-Funktion darf nicht die dem Stream zugrunde liegenden Daten verändern, also zum Beispiel unserer Liste Elemente hinzufügen oder löschen. Eine Funktion ist stateless, wenn keine veränderlichen Variablen oder Zustände aus dem äußeren Scope verwendet werden, die sich während der Laufzeit verändern. Wie bei den Lambdas schon erwähnt, können Stream-Operationen entweder sequentiell oder parallel ausgeführt werden.
Streams können aus verschiedenen Datenquellen erstellt werden, zum Beispiel aus einer Collection wie Listen oder Sets über die Methoden stream und parallelStream. Eine andere Möglichkeit einen Stream zu erzeugen ist folgende:
Stream.of("abc", "cde", "fgh", "xyz", "123");
Es wird ein Stream aus den angegebenen Objekt-Referenzen erzeugt. Es gibt zusätzlich IntStream, LongStream und DoubleStream um mit primitiven Datentypen arbeiten zu können:
int x = IntStream.range(1, 10).map(n -> n*2).sum();
Streams können zum Beispiel zum Filtern oder Sortieren benutzt werden, sowie zum Mappen und Matchen. Die bereits bekannte Liste kann auch folgendermaßen nach allen geraden Zahlen gefiltert und einer anderen Liste hinzugefügt werden:
list.stream() .filter((n)->n%2==0) .forEach(filteredList::add);
Die Liste wird in absteigender Reihenfolge sortiert auf der Konsole ausgeben:
list.stream() .sorted((a,b) -> b.compareTo(a)) .forEach(s -> System.out.print(s + ","));
Aus einem Stream kann durch die collect-Methode eine Liste erzeugt werden.
List<Integer> doubleList = list.map(n -> n*2) .mapToObj(n -> (Integer) n) .collect(Collectors.toList());
Bei Streams kommt die Laufzeit auf die Reihenfolge an, in welcher die Operationen ausgeführt werden und ob es eine terminale Operation gibt. Ohne eine terminale Operation wird keine intermediäre Operation ausgeführt, denn intermediäre Operationen sind "lazy". Betrachten Sie folgendes Beispiel:
Stream.of("abc", "cde", "fgh", "xyz", "123") .map( s -> {System.out.println("map:"+s); return s.toUpperCase();}) .forEach(s -> {System.out.println("dummy print:" + s);});
nur mit der abschließenden foreach-Operation liefert der Ausdruck folgenden Output:
map:abc dummy print:ABC map:cde dummy print:CDE map:fgh dummy print:FGH map:xyz dummy print:XYZ map:123 dummy print:123
Man sieht, dass die Operationen nacheinander auf jedem Element ausgeführt werden und nicht erst eine Operation auf allen Stream-Elementen und dann die nächste Operation. Jedes Element bewegt sich auf der Kette vertikal. Dieses Verhalten hat mehrere Performancevorteile, denn dadurch werden die Elemente nur genau einmal besucht, es müssen keine Zwischenergebnisse gespeichert werden und die Abarbeitung ist effizient parallelisierbar.
Es gibt trotzdem Operatoren, die horizontal abgearbeitet werden, wie beispielsweise die sort-Operation. Diese wird auf allen Elementen des Streams ausgeführt, bevor auf den sortierten Elemente jeweils die nachfolgenden Operationen ausgeführt werden:
Stream.of("c3", "a1","b2") .sorted((a,b)->{System.out.println(a+","+b); return a.compareTo(b);}) .forEach(s->System.out.println(s));
führt demnach zu folgendem Output:
a1,c3 b2,a1 b2,c3 b2,a1 a1 b2 c3
Wenn wir nun noch einen Filter hinzufügen,
Stream.of("abc", "cde", "fgh", "xyz", "123") .map( s -> {System.out.println("MAP:"+s); return s.toUpperCase();}) .filter(s -> {System.out.println("FILTER:"+s); return s.startsWith("C");}) .forEach(s -> {System.out.println("filtered:" + s);});
bekommen wir folgenden Output:
MAP:abc FILTER:ABC MAP:cde FILTER:CDE filtered:CDE MAP:fgh FILTER:FGH MAP:xyz FILTER:XYZ MAP:123 FILTER:123
Auch hier werden die Operationen nacheinander angewandt, wir können aber durch Verändern der Reihenfolge der Stream-Operatoren
Stream.of("abc", "cde", "fgh", "xyz", "123") .filter(s -> {System.out.println("FILTER:"+s); \\1 return s.startsWith("c");}) .map( s -> {System.out.println("MAP:"+s); \\2 return s.toUpperCase();}) .forEach(s -> {System.out.println("filtered:" + s);});
die Anzahl der Operationen die pro Element ausgeführt werden, minimieren:
FILTER:abc FILTER:cde MAP:cde filtered:CDE FILTER:fgh FILTER:xyz FILTER:123
Die map-Operation (2) wird nur noch auf dem einen herausgefilterten (1) Element ausgeführt. Durch sinnvolles Kombinieren der Operatoren kann also die Laufzeit optimiert werden.
Auch der Verwendung von Streams sind Grenzen gesetzt, so kann ein Stream nicht wieder verwendet werden. Das hängt damit zusammen, dass sobald eine terminale Operation auf einem Stream aufgerufen wird, dieser geschlossen wird:
IntStream stream = IntStream.range(1, 5); stream.anyMatch(i -> i == 2); stream.allMatch(i -> i <= 6);
erzeugt eine Exception:
Exception in thread "main" java.lang.IllegalStateException: stream has already been operated upon or closed
Was man auch beachten sollte, ist, dass man aus Versehen oder gewollt unendliche Streams erzeugen kann, zum Beispiel so:
IntStream .iterate(0,i->i+1) .forEach(s -> {System.out.println(s);});
Um dies zu vermeiden, sollten geeignete Grenzen verwendet werden:
IntStream .iterate(0,i->i+1) .limit(9) .forEach(s -> {System.out.println(s);});
Und trotz der Limitierung der Anzahl der Werte ist auf raffinierte Weise noch ein unendlicher Stream möglich:
IntStream.iterate(0, i -> (i+1)%2) .distinct() .limit(9) .forEach(System.out::println);
Denn die iterierte Funktion generiert abwechselnd 0 und 1, es werden nur eindeutige (distinct) Werte dieser Folge behalten und die Anzahl der Werte wird auf 9 begrenzt, dann werden diese ausgegeben. Das Problem hierbei ist, dass die distinct-Operation nicht "weiß", dass die an die iterate Operation übergebene Funktion nur zwei eindeutige Werte produziert. Also werden immer neue Werte vom Stream "konsumiert" und die Begrenzung limit(9) wird nie erreicht. Indem man limit und distinct vertauscht, vermeidet man diesen unendlichen Stream, womit wir wieder bei der Beachtung der Reihenfolge der Operationen angelangt sind.
Fazit
Insgesamt lässt sich zu den in diesem Artikel betrachteten neuen Features von Java 8 sagen, dass mit den Lambdas und Streams sehr mächtige Werkzeuge hinzugekommen sind. Erste Erfahrungen zeigen, dass zwar nicht alles damit möglich ist, man bei unüberlegter Benutzung in Fallen tappen kann, aber dafür ist endlich funktionale Programmierung in Java möglich. Java wird dadurch moderner und stylischer, was in unserem Zeitalter der rasenden Veränderung und neuer Technologien Java meiner Meinung nach sexy macht.
Weitere Informationen:
[1] jug-gr.de: Java 8 – Lambdas und Streams
[2] jaxenter.de: Java SE 8 Neuerungen: Date/Time-API und warum ein Umstieg auf Java 8 lohnt
[3] codecentric.de: Java 8 Erste Schritte mit Lambdas und Streams
[4] javaworld.com: Java programming with lambda expressions
[5] angelikalanger.com: Java 8 – Übersicht über das Stream API in Java 8
[6] winterbe.com: Java 8 Stream Tutorial
[7] winterbe.com: Java 8 Tutorial
[8] jooq.org: Java 8 Friday: 10 Subtle Mistakes When Using the Streams API
[9] stackoverflow.com: Java 8 Iterable.forEach() vs foreach loop