dark

Die Magie der Lambdas

Avatar

Mit der JDK-Version 8 wurde der Versuch gestartet, die objektorientierte Welt mit den Vorteilen der funktionalen Programmierung zu verbinden. Dazu wurden die Lambda-Ausdrücke eingeführt, ein mächtiges aber leider oft unterschätztes Werkzeug. Da Lambda-Ausdrücke jedoch ein integraler Bestandteil des JDK und vieler Frameworks sind, kommt man in der Java-Welt nur noch schwer um sie herum.

Code = Daten

Die mit Java 8 eingeführten Lambda-Ausdrücke sollen die objektorientierte Programmiersprache Java mit den Vorteilen bekannter Konzepte aus der funktionalen Programmierung bereichern. Dazu muss erst einmal geklärt werden, dass Daten nicht notwendigerweise nur aus Zahlen, Bytes oder Zeichenketten bestehen, die in einem Programm herumgereicht werden, sondern dass auch der Code unseres Programms nichts anderes ist als Daten. Daten können an Methoden übergeben werden. Es ist also bekanntermaßen kein Problem Daten zum Beispiel in Form eines Integers oder eines komplexen Objekts an eine Methode weiterzugeben.

Doch auch Code kann an eine Methode übergeben werden. Vor Java 8 verwendete man dazu eine Referenz auf ein Objekt. Innerhalb der Methode können dann die Methoden des Objekts aufgerufen werden und somit ist ein Zugriff auf dessen Code möglich. Oftmals macht man es sich zunutze, dass die Klasse des Objekts eine bestimmte Methode implementiert, die beispielsweise durch ein Interface oder eine abstrakte Klasse vorgegeben ist. Bekannt sind das Interface Runnable, in deren Methode run der Code für einen Thread verpackt wird, und das Interface ActionListener zur Definition einer Aktion, die z.B. beim Klick auf eine Schaltfläche ausgeführt werden soll. Diese Aktion wird in der vom Interface vorgegebenen Methode actionPerformed definiert.

Übergabe von Code mittels anonymer Klassen

In diesem Abschnitt möchten wir uns kurz mit einer Variante der Übergabe von Code beschäftigen, die schon recht nahe an das funktionale Konzept herankommt: den anonymen Klassen. (Listing 1) zeigt ein einfaches Interface, das eine einzige Methode zur Verfügung stellt. Das folgende Konzept funktioniert auch mit Interfaces die mehr Methoden enthalten sowie mit abstrakten Klassen. Aber wir möchten uns auf den Fall eines Interfacess mit nur einer einzigen Methode beschränken, denn diese werden noch eine wichtige Rolle spielen.

(Listing 1)

public interface Printer { 
  void print(int pagesCount); 
}

Bekanntermaßen benötigt man eine implementierende Klasse, wenn man im Programm ein Interface oder eine abstrakte Klasse verwenden möchte, denn von beiden Konstrukten können keine Objekte erzeugt werden. Eine Klasse implementiert ein Interface durch Verwendung des Schlüsselwortes implements wie es in (Listing 2)  zu sehen ist.

(Listing 2)

public class LaserPrinter implements Printer {
  @Override
  public void print(int pagesCount) {
    System.out.println("Ich drucke wie ein "
       + "Laserdrucker");
  }
}

Von dieser Klasse kann nun ein gewöhnliches Objekt mithilfe des Schlüsselworts new erzeugt werden. Sowohl Interface als auch die implementierende Klasse im Beispiel ist sehr klein. Wenn die Implementierung nur an einer einzigen Stelle benötigt wird, lohnt sich meistens der Aufwand einer zusätzlichen benannten Klasse nicht. Als Alternative können sogenannte anonyme Klassen verwendet werden, die im Gegensatz zu benannten Klassen keinen Namen haben (Listing 3).

(Listing 3)

public static void main(String [] args) {
    print100Pages(new Printer() {
		@Override
		public void print(int pagesCount) {
        System.out.println("Ich drucke wie ein "
            + "Laserdrucker");
    }
	});
}

public static void print100Pages(Printer printer) {
    printer.print(100);
}

Die Methode print erwartet als Übergabe ein Objekt, dessen Klasse das Interface Printer implementiert. Dieser Methode können wir ein normales Objekt einer benannten implementierenden Klasse übergeben. Im Beispiel wird jedoch eine unbenannte Klasse übergeben: Die Klassendefinition und die Objekterzeugung finden an derselben Stelle statt, indem new mit einem Interface aufgerufen wird. In diesem Moment implementieren wir das Interface und erzeugen gleichzeitig ein Objekt dieser Klasse. Dieses Objekt wird an die Methode print übergeben. Da die implementierende Klasse keinen Namen hat, können wir sie nicht noch einmal verwenden. Um auf den Anfang dieses Artikels zurückzukommen: Wir übergeben hier Code an eine Methode, was natürlich bei der Übergabe eines gewöhnlichen Objekts wie bereits beschrieben auch geschieht, aber an dieser Stelle ist diese Tatsache auf den ersten Blick ersichtlich. Lambda-Ausdrücke funktionieren prinzipiell ähnliche wie anonyme Klassen, sie gehen nur noch einen Schritt weiter.

Einfache Lambda-Ausdrücke

Die Syntax von Lambda-Ausdrücken sieht folgendermaßen aus:

Parameter -> Anweisungen

Die Syntax ist ein wenig gewöhnungsbedürftig, aber wenn man sich einmal eingelesen hat, sind Lambda-Ausdrücke in vielen Fällen erheblich besser lesbar als die oben beschriebenen anonymen Klassen. Sehen wir uns einmal die Syntax genauer an:

Ein Lambda-Ausdruck kann Parameter erhalten wie eine Methode. Dann wird die Anweisungen ausgeführt, welche die Parameter verarbeiten. Im Prinzip kann man sich einen Lambda-Ausdruck als eine anonyme Methode vorstellen, also als eine Methode ohne Namen. (Listing 4) zeigt einen einfachen Lambda-Ausdruck.

(Listing 4)

Printer laserprinter = (int pagesCount) -> {
	System.out.println("Ich drucke wie ein "
			+ "Laserdrucker: " + pagesCount + " Seiten!");
};
laserprinter.print(100);

Der Lambda-Ausdruck erzeugt ein Printer-Objekt und erhält als Übergabe die Anzahl der Seiten. Was dann geschieht, ist auf den ersten Blick allerdings nicht ersichtlich. Und genau das macht Lambda-Ausdrücke im ersten Moment etwas schwer verständlich: Welche Methode wird implementiert?

Funktionale Interfaces

An dieser Stelle kommt eine besondere Art von Interface ins Spiel: das funktionale Interface. Es definiert genau eine einzige Methode. Das Printer-Interface aus (Listing 1) ist ein solches funktionales Interface. Und genau dieses ist für Lambda-Ausdrücke enorm wichtig. Lambda-Ausdrücke sind so kompakt, weil der Compiler sich viele Informationen selbst erschließen kann. Dazu gehört auch die Information, welche Methode eines Interface eigentlich implementiert werden soll. Das ist für den Compiler sehr einfach, denn ein Lambda-Ausdruck funktioniert nur mit einem funktionalen Interface und ein funktionales Interface kann nur eine einzige Methode enthalten. Unser Lambda-Ausdruck aus (Listing 4) erzeugt ein Printer-Objekt. Printer definiert als funktionales Interface nur die Methode print. Und somit weiß der Compiler, dass sich der im Lambda-Ausdruck enthaltene Code auf diese Methode bezieht. Daher wird in der letzten Zeile in (Listing 4) der Lambda-Ausdruck aufgerufen.

Lambda-Ausdrücke vereinfachen

Lambda-Ausdrücke können noch weiter vereinfacht werden. In (Listing 5) wird das funktionale Interface MathFunction mit der Methode calculate als Beispiel aufgeführt. Die Lambda-Ausdrücke werden in (Listing 6) Schritt für Schritt vereinfacht.

(Listing 5)

public interface MathFunction {
	int calculate(int a, int b);
}

(Listing 6)

MathFunction sum1 = (int a, int b) -> {
	return a + b;
};

MathFunction sum2 = (a, b) -> {
	return a + b;
};

MathFunction sum3 = (a,b) -> a + b;

Der erste Ausdruck in (Listing 6) ist der lange Ausdruck mit allen Informationen. Bei dem zweiten wurden die Datentypen weggelassen. Die kann der Compiler sich erschließen, da das implementierte Interface schließlich nur eine einzige Methode hat. Besteht der Lambda-Ausdruck nur aus einer einzigen Zeile, können auch die geschweiften Klammern weggelassen werden, und in diesem Fall ist es auch nicht notwendig, die Rückgabe mit return einzuleiten. return wird vom Compiler implizit angenommen. So kommen wir zu dem kleinen und charmanten Ausdruck in der letzten Zeile von (Listing 6).

(Listing 7)

MathFunction sum4 = new MathFunction() {
	
	@Override
	public int calculate(int a, int b) {
		return  a + b;
	}
};

Zum Vergleich zeigt (Listing 7) dieselbe Implementierung noch einmal als anonyme Klasse. Hier fällt sofort auf, dass der Lambda-Ausdruck erheblich kürzer und – nach einer Eingewöhnungsphase – besser lesbar ist.

Sammlung funktionaler Interfaces in java.util.functions

In dem Paket java.util.functions wurden bereits einige funktionale Interfaces vordefiniert, die an zahlreichen Stellen in Java eingesetzt werden können. Dazu ist es wichtig zu wissen, welche Interfaces es gibt, und ihre Einsatzmöglichkeiten zu verstehen. Die folgenden funktionalen Interfaces mit den entsprechenden Merkmalen sind bereits definiert:

  • Consumer: keine Rückgabe
  • Function: erzeugt eine Rückgabe
  • Operator: gibt einen Wert vom Argumenttyp zurück
  • Supplier: hat eine Rückgabe, aber kein Argument
  • Predicate: hat eine Rückgabe vom Typ Boolean

Das funktionale Interface Consumer

Consumer geben keinen Wert zurück, erhalten allerdings einen Übergabeparameter. Dazu enthält das funktionale Interface eine Methode accept, die in einem Lambda-Ausdruck implementiert werden muss. (Listing 8) zeigt ein Beispiel für die Verwendung von Consumer. Von dem Interface Consumer gibt es mehrere Spezialisierungen, wie zum Beispiel IntConsumer, der im Code zu sehen ist. Der Lambda-Ausdruck berechnet das Quadrat der übergebenen Ganzzahl und gibt dieses auf dem Bildschirm aus. Das Interface Consumer bietet noch zahlreiche weitere Methoden, die unter anderem eine Verkettung von Ausdrücken ermöglichen. Dies ist in den letzten Zeilen von (Listing 8) zu sehen. Hier wird ein zweiter Consumer sumConsumer definiert. Mithilfe der Methode andThen() werden die beiden Consumer nacheinander ausgeführt.

(Listing 8)

IntConsumer squareConsumer =
    a -> System.out.println(a * a);
squareConsumer.accept(5);

IntConsumer sumConsumer = a -> System.out.println(a + a);
sumConsumer = sumConsumer.andThen(squareConsumer)
                         .andThen(sumConsumer);
sumConsumer.accept(6);

Im Moment addieren und multiplizieren wir immer nur eine Zahl. Was aber tun wir, wenn wir zwei Zahlen in die Berechnung miteinbeziehen möchten? Im Gegensatz zum einfachen IntConsumer erhält die Implementierung BiConsumer zwei Übergabeparameter. In (Listing 9) werden die beiden Übergaben an den BiConsumer multipliziert.

(Listing 9)

BiConsumer <Integer, Double> biConsumer = 
    (a, b) -> System.out.println(a * b);
biConsumer.accept(4, 5.5);

Das funktionale Interface Function

Die Methode applyAsX des funktionalen Interface Function erhält einen Übergabeparameter und gibt einen Wert zurück. Das X in dem Namen der Methode steht für den Rückgabetyp.(Listing 10) führt zwei Beispiele für das Interface Function auf. Als erstes wird ein Objekt des Typs IntToLongFunction deklariert. Dessen Methode applyAsLong erhält als Übergabe einen int Parameter und gibt einen long zurück. Das zweite Beispiel zeigt die Verwendung eines Objekts vom Typ ToIntFunction. Dieses kann mit einem Typ für die Übergabe typisiert werden, hier String. Zurückgegeben wird ein Wert vom Typ int.

(Listing 10)

IntToLongFunction squareFunction = 
     (x) -> {
          return x * x;
};
long square = squareFunction.applyAsLong(6);
System.out.println("Ergebnis x^2: " + square); 

ToIntFunction<String> convertToIntFunction =
     (myString) -> Integer.parseInt(myString);
int value = convertToIntFunction

Das funktionale Interface Operator

Wenn Übergabeparameter und Rückgabe den gleichen Datentyp haben, kann anstelle von Function das Interface Operator als nützliche Abkürzung verwendet werden. Beispiele in (Listing 11). Zuerst wird ein IntUnaryOperator definiert. Dieser erhält eine Übergabe vom Typ Int, verdoppelt dieses und gibt es wieder als Int zurück. Im zweiten Beispiel wird ein DoubleBinaryOperator eingesetzt, der zwei Double-Parameter erwartet und einen Double-Wert zurückgibt.

(Listing 11)

IntUnaryOperator doubleOperator = x -> {return x * 2;};
System.out.println(doubleOperator.applyAsInt(4));

DoubleBinaryOperator multiplyOperator = (x,y) -> {
     return x * y;
};
System.out.println(multiplyOperator.applyAsDouble(4.5, 6.6));

Das funktionale Interface Supplier

Das Interface Supplier erzeugt nur eine Rückgabe, erwartet aber keine Parameter. (Listing 12) zeigt mehrere Beispiele. Im ersten wird ein IntSupplier erzeugt, der eine Zufallszahl erzeugt und als Int zurückgibt, dabei aber keinen Parameter erhält. Das zweite berechnet mithilfe eines BooleanSupplier, ob ein fiktives Geschäft gerade geöffnet hat. Auch in diesem Fall kommen wir ohne eine Übergabe aus.

(Listing 12)

IntSupplier randomSupplier = () -> {
     return new Random().nextInt(100);
};
System.out.println("Zufallszahl: " + randomSupplier.getAsInt());

BooleanSupplier shopOpenSupplier = () -> {
    LocalDateTime now = LocalDateTime.now();
    return now.getHour() < 20 && now.getHour() > 8;
};
boolean isShopOpen = shopOpenSupplier.getAsBoolean();

Das funktionale Interface Predicate

Das Interface Predicate schließlich erlaubt die Definition von Bedingungen und deren Verkettung, indem es einen Wert vom Typ boolean zurückgibt. (Listing 13) zeigt zwei Beispiele für die Verwendung von Predicate. Im ersten Beispiel wird ein IntPredicate erzeugt. Dieses erhält eine Bedingung als Lambda-Ausdruck und wendet diese Bedingung auf den int-Wert an, welcher der Methode test() übergeben wird.

Das zweite Beispiel zeigt die Verkettung mehrerer IntPredicate-Objekte. Das erste IntPredicate prüft, ob eine Zahl größer als 0 ist, das zweite, ob eine Zahl kleiner ist als 100. Beide Bedingungen werden mithilfe der Methode and mittels logischem Und miteinander verknüpft. Predicate stellt auch Methoden zur Verneinung oder Verknüpfung mit einem logischen Oder zur Verfügung.

(Listing 13)

IntPredicate negativeNumberPredicate = x -> x < 0;
System.out.println("Ist 42 negativ? " + 
negativeNumberPredicate.test(42));
System.out.println("Ist -42 negativ? " + negativeNumberPredicate.test(-42));

IntPredicate predicateGreaterThan0 = x -> x > 0;
IntPredicate prediateLessThan100 = x -> x < 100;
IntPredicate combinedPredicate = 
                                predicateGreaterThan0.and(prediateLessThan100);
System.out.println("Liegt 55 zwischen 0 und 100? " +      combinedPredicate.test(55));
System.out.println("Liegt 550 zwischen 0 und 100? " + combinedPredicate.test(550));

Optionals

Die beschriebenen funktionalen Interfaces und somit auch die Lambda-Ausdrücke werden in Java 8 sehr häufig eingesetzt. So enthält das JDK ab der Version 8 zahlreiche Methoden, die mit diesen fünf funktionalen Interfaces arbeiten. Ein Beispiel ist die Klasse Optional. Diese bieten eine Alternative zu der unseligen Rückgabe von Null-Werten, was immer die Gefahr von NullPointerExceptions nach sich zieht. Ein Optional ist eine Art Datencontainer, der entweder einen Wert enthalten oder leer sein kann. Ein leerer Optional ist eine Entsprechung zu Null mit dem Unterschied, dass keine NullPointerException ausgelöst werden kann. Zudem kann man mithilfe eines Optionals eine interessante Semantik in eine Methodendefinition hineinbringen. Wenn die Methode eine Optional-Rückgabe aufweist, bedeutet dies, dass die Rückgabe leer sein könnte und der Aufrufer entsprechende Maßnahmen ergreifen muss, um sich abzusichern. Handelt es sich nicht um eine Optional-Rückgabe, wird die Semantik transportiert, dass die Rückgabe niemals Null sein kann und dass somit auch keine besonderen Maßnahmen getroffen werden können. Eine konsequente Benutzung von Optional kann ein Programm somit erheblich sicherer und besser lesbar machen, weil viele ineinander verschachtelte If-Anweisungen verschwinden können, die einfach nur Rückgaben von Methoden auf Null prüfen. Optional ist eine sehr komplexe Klasse und deswegen werden hier nur einige grundlegende Funktionen vorgestellt.

(Listing 14) zeigt einige Unit-Tests, mit denen die Funktionsweise von Optional überprüft werden kann. In der ersten Test-Methode wird ein leeres Optional-Objekt erzeugt, in der zweiten ein Optional, der einen String enthält. Die Methode isPresent prüft, ob der Optional einen Wert enthält. Gibt die Methode false zurück, handelt es sich bei dem Optional um eine Entsprechung von Null mit dem Unterschied, dass keine NullPointerException geworfen werden kann. Die Methode get schließlich entnimmt dem Optional den enthaltenen Wert, wenn der Optional einen Wert enthält.

(Listing 14)

@Test
public void whenCreatesEmptyOptional_thenCorrect() {
	final Optional<String> empty = Optional.empty();
	assertFalse(empty.isPresent());
}
	
@Test
public void givenNonNull_whenCreatesOptional_thenCorrect() {
	final String expectedString = "SystemTechnikLabor";
	final Optional<String> optional = Optional.of(expectedString);
	assertTrue(optional.isPresent());	 
	final String actualString = optional.get();	    	assertEquals(expectedString, actualString);
}

Interessant ist die Methode ifPresent, die in (Listing 15) präsentiert wird. ifPresent erwartet als Übergabe einen Consumer, der als Lambda-Ausdruck dargestellt werden kann. Im Beispiel enthält der Consumer einen String, an er die Endung -if anhängt und dies in der Variablen actualName speichert.

(Listing 15)

@Test
public void givenOptional_whenIfPresentWorks_thenCorrect() {
	final String name = "SystemTechnikLabor";
	String actualName = null;
	final String expectedName = "SystemTechnikLabor-if";
	final Optional<String> underTest = Optional.of(name);
	underTest.ifPresent(myString -> actualName = myString + "-if";));
	assertThat(actualName, is(equalTo(expectedName)));
}

Ein letztes Beispiel ist in (Listung 16) zu sehen. Hier wird noch zusätzlich die Methode filter eingesetzt. Sie erhält als Übergabe ein Predicate mit einer Bedingung. Nach dieser Bedingung wird der Inhalt des Optionals gefiltert. Im Beispiel wird zweimal gefiltert: nach dem Jahr 2016 und dem Jahr 2017. filter prüft, ob der im Optional enthaltene Wert jeweils durch 2016 oder 2017 ohne Rest teilbar ist. Und isPresent gibt zurück, ob ein Wert enthalten ist, der diese Bedingung erfüllt. Es geht also bei isPresent nicht mehr alleine darum, ob ein Wert enthalten ist.

(Listing 16)

@Test
public void whenOptionalFilterWorks_thenCorrect() {
	final Integer year = 2016;
	final Optional<Integer> yearOptional = Optional.of(year);
	final boolean is2016 = yearOptional.filter(y -> y == 2016).
	   isPresent();
	assertTrue(is2016);
	final boolean is2017 = yearOptional.filter(y -> y == 2017).
		isPresent();
	assertFalse(is2017);
}

Optionals sind ein gutes Beispiel wie mächtig die Kombination aus Lambda-Ausdrücken und den in Java vordefinierten funktionalen Interfaces ist. Ein weiteres Beispiel ist die Stream-API, JAVAPRO 3-2019, ab Seite 22.

Fazit:

Lambda-Ausdrücke sind zugegebenermaßen im ersten Moment etwas gewöhnungsbedürftig. Hat man sich jedoch einmal mit ihnen angefreundet, eröffnet sich ein breites Feld an Möglichkeiten, Code kompakter und besser lesbar zu gestalten. Sie können beliebig eigene funktionale Interfaces verwenden, aber Sie können auch auf die bereits in Java definierten zurückgreifen. Diese decken bereits ein breites Spektrum an Anwendungsmöglichkeiten ab und sind sehr allgemein formuliert. Sie werden bereits in vielen Methoden des JDK angewandt und deswegen sollten Java-Programmierer sie auch unbedingt kennen und verstehen. Dennoch gilt auch bei Lambda-Ausdrücken, dass die Lesbarkeit das wichtigste Gut eines Quelltextes ist. Lambda-Ausdrücke können den Quellcode leichter lesbar machen, wenn sie kompakt sind. Sind sie jedoch zu lang, kann die Lesbarkeit schnell darunter leiden. Daher empfiehlt es sich, auch in Lambda-Ausdrücken darauf zu achten, dass sie nicht zu lang werden, und gegebenenfalls einen Teil des enthaltenen Codes in private Methoden auszulagern.

Buchquellen:

Michael Inden: Java 8 – Die Neuerungen: Lambdas, Streams, Date And Time API und JavaFX 8 im Überblick

Christopher Olbertz

Christopher Olbertz arbeitet als Lehrkraft an der Hochschule für Technik und Wirtschafts des Saarlandes. Dort betreut er Studenten vor allem in den Programmiersprachen Java und C/C++ und der UML. Sein besonderes Interesse gilt modernen Frameworks wie Spring, Hibernate, JavaServer Faces und Apache Tapestry.

Webseite | Blog | E-Mail

Total
0
Shares
Previous Post

MicroStream 4 – High-Performance Java-Native Persistence

Next Post

Vernetzte Systeme mit FEPCOS-J

Related Posts