Java 8 Lambdas unter der Lupe

Mit Java 8 kam das wohl größte Update für Java SE. Dabei gab es sowohl Neuerungen in der Sprache (Lambdas, Default-Methoden, …), in der Klassen-Bibliothek (Streams), als auch in der JVM. Die Lambda-Ausdrücke sind eine Art Grundlage für viele der neuen Funktionalitäten. Zudem ermöglichen sie uns, auf eine funktionale Art und Weise zu programmieren. Genau genommen ist ein Lambda-Ausdruck ein Code-Block, der im Gegensatz zu Methoden/Funktionen aber keinen Namen hat. Es handelt sich also um eine anonyme Funktion, die man über Variablen referenzieren und Methoden als Parameter mit- bzw. von dort zurückgeben kann. Bei der Definition eines Lambda-Ausdrucks wird der Inhalt noch nicht ausgeführt, kann aber anschließend beliebig oft aufgerufen werden.

Mit der Einführung der Lambda-Ausdrücke wurde Java zwar nicht gleich eine funktionale Programmiersprache. Aber allein die Möglichkeit, auf funktionale Art und Weise zu programmieren, ermöglicht es uns, besser lesbaren und verständlicheren Code zu schreiben. Zudem bietet es auch Vorteile in der nebenläufigen Programmierung. Das wird im Zeitalter von Multicore- und Multiprozessor-Hardware immer wichtiger.

In der klassischen imperativen Entwicklung arbeiten wir viel mit Bedingungen und Schleifen. Dabei wird ständig das Was mit dem Wie vermischt, wie das folgende Beispiel zeigt:

for (Point p : pointList) {
    if (p.isValid()) {
        p.translate(1, 1);
    }
}

Die Wiederverwendung des Prädikats (p.isValid()) und der Transformation (p.translate(1, 1)) ist nicht ohne weiteres möglich. Stattdessen sind diese beiden Aufrufe mit dem Durchlauf der Schleife fest verknüpft. Mit der Stream-API kann man das obige Beispiel aber nun auch folgendermaßen schreiben:

pointList.stream()
    .filter(p::isValid())
    .each(p -> p.translate(1, 1));

Bei den Methodenparametern von filter() und each() handelt es sich um Lambda-Ausdrücke, die das Was symbolisieren. Das Wie passiert in den verschiedenen Methoden der Stream-API, die man beliebig kombinieren und denen man wiederverwendbare Lambda-Ausdrücke übergeben kann. Zudem kann einfach durch das Austauschen des Aufrufs von stream() zu parallelStream() eine parallele Verarbeitung der Liste angefordert werden.

Lambda-Ausdrücke bestehen aus einer Parameterliste und dem Body, die durch den sogenannten Function Arrow (->) getrennt werden. Den Typ der Parameter und den Rückgabe-Typ kann der Compiler aus dem Kontext ermitteln, man kann sich hier also unnötige Angaben sparen. Das Ergebnis der Anweisung wird einfach zurückgegeben, eine explizite Angabe von return entfällt also.

Point p -> p.translate(1, 1);
p -> p.translate(1, 1);

Längere oder leere Parameterlisten müssen geklammert werden:

() -> System.out.println("Foo");
(x, y) -> x + y;

Sofern der Body mehr als eine Anweisung enthält, muss der Codeblock in geschweifte Klammern gesetzt und mit einer return-Anweisung verlassen werden:

() -> { 
    System.out.print("Foo");
    return true;
}

Ganz genau genommen ist args -> expr; also die unter bestimmten Umständen mögliche Kurzform von (args) -> { return expr; }. Lambda-Ausdrücke können übrigens keine throws-Klausel definieren. Außerdem sind sie typischerweise zustandslos in der Form, dass sie keine Klasse implementieren. Sie dürfen aber auf Variablen der Umgebung zugreifen, in der sie erzeugt wurden. Diese Variablen dürfen aber nicht verändert werden, d. h. sie müssen effectively final sein. Wollte man vor Java 8 aus anonymen inneren Klassen auf äußere Variablen zugreifen, so mussten diese noch explizit als final deklariert werden. Nichtveränderbarkeit bringt tatsächlich viele Vorteile (Vermeidung von Race Conditions und Sichtbarkeitsproblemen, bessere Performance und Verhinderung von schlechten Idiomen/Code) und ist in funktionalen Sprache typischerweise die Regel (Immutability).

Lambda-Ausdrücke kann man Variablen vom Typ eines Functional Interface zuweisen. Diese Art gibt es implizit schon sehr lange in der Java Welt als SAM-Typen (Single Abstract Method), also Klassen/Interfaces mit genau einer abstrakten Methode. Ein Lambda-Ausdruck implementiert dann genau diese eine abstrakte Methode. Durch die Typendeklaration der SAM-Methodenparameter und des Rückgabewertes werden bei der Erzeugung auch gleich die Typen in die Lambda-Ausdrücke inferiert. Für alte Java-Hasen ein ganz neues Gefühl, spart man doch so die sich sonst ständig wiederholende Angabe von Typinformationen. Stattdessen stellt der Compiler aufgrund des Verwendungskontextes (z. B. Übergabe als Methodenparameter, Zuweisung zu einer Variablen mit einem Functional Interface Typ, als Return Statement oder durch Casten) die typsichere Verwendung sicher. Trotzdem kann es leider schnell verwirrend werden und zu kryptischen Compiler-Fehlern kommen, insbesondere, wenn noch generische Datentypen mit im Spiel sind. Abhilfe schafft dann nur, Mehrdeutigkeiten durch zusätzliche Typinformationen zu beseitigen.

Zu den klassischen SAM-Typen wie Runnable und Comparable sind mit der Java 8 Stream API eine Vielzahl neuer SAM-Typen hinzugekommen. Durch ihre Markierung mit der Annotation @FunctionalInterface kann man sie gut erkennen. Die im Package java.util.function befindlichen 40 Interfaces lassen sich dabei in vier Basistypen unterteilen:

  • Consumer: Erwartet ein Argument und gibt nichts zurück
  • Predicate: Erwartet ein Argument und wertet dieses zu einem booleschen Wert aus
  • Supplier: Erwartet kein Argument, erzeugt und liefert aber ein Ergebnis zurück
  • Function: Erwartet ein Argument, transformiert und gibt es als Ergebnis zurück

Weitere Unterteilung erfolgt über Namenskonventionen, z. B.:

  • BiConsumer : Erwartet zwei Argumente und gibt nichts zurück
  • UnaryOperator : Erwartet ein Argument bei Ergebnis vom gleichen Typ
  • IntPredicate : Erwartet int Argument und gibt booleschen Wert zurück

Lambda-Ausdrücke können auch aus Methoden-Referenzen erzeugt werden, was den Function Pointern in C ähnelt. Sinnvoll sind Methodenreferenzen besonders, wenn im Lambda-Ausdruck sowieso nur an einen anderen Methodenaufruf delegiert wird. Statt

intList.forEach(i -> System.out.println(i));

kann man auch

intList.forEach(System.out::println);

schreiben. Insgesamt gibt es vier Arten:

  • Statische Methodenreferenz (Type::staticMethod statt (args) -> Type.staticMethod(args))
  • Bound Instanz Methodenreferenz (expr::instMethod statt (args) -> expr.instMethod(args))
  • Unbound Instanz Methodenreferenz (Type::instMethod statt (arg0, rest) -> arg0.instMethod(rest))
  • Konstruktorreferenz (ClsName::new statt (args) -> new ClsName(args))

Lambda-Ausdrücke sind eine der großen Neuerungen von Java 8. Sie können überall dort eingesetzt werden, wo man früher mit anonymen inneren Klassen arbeiten musste. Das erfolgt aber jetzt mit einer prägnanteren und besser lesbaren Syntax. Nichtsdestotrotz wird es seltene Anwendungsfälle geben, wo mit anonymen inneren Klassen weiterhin echte Objekte mit Zustandsvariablen benötigt werden (z. B. abstrakte Klassen/Interfaces mit mehr als einer abstrakten Methode oder für den Zugriff auf this). Für einen Großteil der Anwendungsfälle stellen Lambda-Ausdrücke aber die einfachste Lösung dar und sind nicht umsonst die Basis für viele der anderen neuen Funktionalitäten von Java 8.

Short URL for this post: http://wp.me/p4nxik-2EG
This entry was posted in Java and Quality, Java Basics and tagged , , , , . Bookmark the permalink.

Leave a Reply