Java-Lambdas Einführung

Aus MimiPedia
Version vom 9. April 2021, 18:03 Uhr von Ullrich (Diskussion | Beiträge) (Die Seite wurde neu angelegt: „Category:Java Der Begriff "Lambda-Ausdruck" entstammt dem Lambda-Kalkül mit dem sich Alonzo Church in den 1930er Jahren anschickte eine formale Spezifikat…“)
(Unterschied) ← Nächstältere Version | Aktuelle Version (Unterschied) | Nächstjüngere Version → (Unterschied)

Der Begriff "Lambda-Ausdruck" entstammt dem Lambda-Kalkül mit dem sich Alonzo Church in den 1930er Jahren anschickte eine formale Spezifikation der Mathematik zu schaffen. Dabei hat er ein Konzept entwickelt, auf dem die funktionalen Programmiersprachen wie LISP, Haskell, Clojure oder Scala aufsetzen und das sich fundamental vom Modell der Turing-Maschine unterscheidet, das den imperativen Sprachen wie Java oder C zugrunde liegt. Der Name leitet sich vom griechischen Buchstaben λ ab den Church für seine Notation verwendete. Weil das kürzer ist -- und cooler aussieht -- steht im Folgenden λ für "Lambda".

Im nachfolgenden Artikel wird gezeigt, was λ-Ausdrücke in Java sind und wie man sie anwenden kann. Es wird hier nur ein Überblick gegeben, die Verwendungsmöglichkeiten von λ-Ausdrücken sind immens vielseitig. Was also ist ein λ oder besser gesagt ein λ-Ausdruck?

Ein Lambda-Ausdruck definiert eine einzelne Funktion deren Typ bestimmt ist durch die Typen ihrer Parameter-Liste und ihres Ergebnis-Typs.

Das klingt abstrakt und sehr akademisch. Aber behalten wir diese Aussage im Hinterkopf, wenn wir im Folgenden an einem Beispiel die Verwendung von λ-Ausdrücken in Java erkunden.

Es sei nicht verschwiegen, daß in obiger Definition ein Detail weggelassen wurde. Für den Typ des λ-Ausdruck ist in Java auch die throws-Klausel der Methode von Bedeutung. Was das bedeutet wird später erläutert.

Ein Beispiel mit konventionellen Java-Mitteln

Wir haben hier eine Methode, die die Zahlen einer Liste durchgeht und zu jedem Wert der Liste den Wert 1 hinzuzählt. Die bearbeiteten Zahlen werden in einer neuen Liste als Ergebnis zurückgegeben:

public List<Integer> verarbeite(List<Integer> liste) {
    List<Integer> result = new ArrayList<>();
    for (Integer x : liste) {
        result.add(x + 1);
    }
    return result;
}

Jedesmal, wenn eine andere Funktion (als "plus eins") zur Berechnung verwendet werden soll, muß die Methode kopiert und angepaßt werden – nicht schön. Man kann die Methode aber auch mit konventionellen Mitteln so erweitern, daß auf die Zahlen eine beliebige Funktion angewandt werden kann. Dafür definiert man zunächst ein Interface

interface Funktion {
    Integer calc(Integer x);
}

Nun erweitert man die Methode so, daß sie eine Instanz dieses Interfaces als Argument übernimmt und anstelle des fest verdrahteten Ausdrucks x + 1 für die Verechnung verwndet:

public List<Integer> verarbeite(List<Integer> liste, Funktion fkt) {
    List<Integer> result = new ArrayList<>();
    for (Integer x : liste) {
        result.add(fkt.calc(x));
    }
    return result;
}

Um die Methode verarbeite() verwenden zu können benötigen wir ein Objekt, das das Interface Funktion implementiert. Nur mit einem solchen Objekt kann die Methode verwendet werden. Möchten wir zum Beispiel folgende Klasse verwenden:

class Calc {
    public Integer doppel(Integer x) {
        return 2 * x;
    }
}

benötigen wir zusätzlich einen Wrapper oder Adapter der die Methode doppel auf die Methode calc des Interface Funktion abbildet. Anstelle einer eigenen Klasse kann man beim Aufruf von bearbeite ein Objekt einer anonymen Klasse instantiieren:

verarbeite(meineListe, new Funktion() {
    public Integer calc(Integer x) {
        return new Calc().doppel(x);
    }
});

Und das ist so unübersichtlich wie aufwendig.

Und jetzt mit Lambdas

Um uns dem Problem zu nähern, beginnen wir diesmal bei der Funktion die auf die Zahlen angewandt werden soll. Das ist zunächst die Inkrementierungs-Funktion.

Wir definieren dafür eine Variable und weisen ihr einen λ-Ausdruck zu, der die Inkrementierungs-Funktion implementiert. im Anschluß wird der Ausdruck ausführlich beschrieben. Vollständig ausgeschrieben sieht das so aus:

Function<Integer, Integer> foo = (Integer x) -> {return x+1;};

Wir wenden uns zunächst der rechten Seite der Zuweisung zu, das ist der eigentliche Lamnda-Ausdruck. Den Typ der Variable foo nehmen wir im nächsten Abschnitt unter die Lupe.

Ein λ-Ausdruck entspricht der Definition einer mathematischen Funktion. Links des ->-Operators steht die Parameter-Liste (die Parameter werden durch Komma getrennt, im Beispiel haben wir nur einen Parameter). Rechts des Operators steht der auszuwertende Ausdruck, ein – nahezu – beliebiger Ausdruck, der einen Wert des Ergebnistyps (hier: Integer) liefert.

Um den Ausdruck kompakter und dadurch übersichtlicher zu machen, lassen sich einige Teile weglassen:

Besteht der Ausdruck lediglich aus einem return-Statement, kann man die Klammern samt return weglassen:

Function<Integer, Integer> foo = (Integer x) -> x + 1;

Wenn aus dem Kontext klar ist, welche Typen die Parameter der Parameter-Liste haben, kann man auch die Typen weglassen:

Function<Integer, Integer> foo = (x) -> x + 1;

Wenn die Parameter-Liste nur einen einzigen Parameter enthält, können wir die Klammern um den Parameter weglassen:

Function<Integer, Integer> foo = x -> x + 1;

Wir können die Methode nun so umschreiben, daß sie unseren λ-Ausdruck akzeptiert:

public List<Integer> verarbeite(List<Integer> liste, Function<Integer, Integer> fkt) {
    List<Integer> result = new ArrayList<>();
    for (Integer x : liste) {
        result.add(fkt.apply(x));
    }
    return result;
}

Auf den Ausdruck fkt.apply(x) werden wir später zurückkommen, einstweilen genügt uns zu wissen, daß damit der λ-Ausdruck fkt mit dem Argument x ausgewertet wird. Wir können die Methode verarbeite() nun so aufrufen:

verarbeite(liste, x -> x + 1);

und der Aufruf mit dem Wrapper um die Klasse Calc sieht nun so aus:

verarbeite(liste, x -> new Calc().doppel(x));

Wenn das mal kein Fortschritt ist –- und es wird noch besser!

Funktionale Interfaces

Betrachten wir nun den Typ des λ-Ausdrucks. Function ist ein generisches Interface des JDK, das eine einzige Methode definiert:

public interface Function<T, R> {
    R apply(T t);
}

Man könnte sagen, daß der λ-Ausdruck einer gedachten Klasse MyFunction entspricht die so aussieht:

class MyFunction {
    Integer apply(Integer t){
        return t + 1;
    }
}

Der Unterschied des λ-Ausdrucks zur Methode besteht darin, daß der λ-Ausdruck gleichwertig ist mit jeder Methode jeder Klasse deren Parameter-Liste die gleichen Typen hat (also hier einen Integer) und den gleichen Ergebnis-Typ (hier ebenfalls Integer). Konkret heißt das, daß das λ die Methode calc aus unserem Ausgangs-Interface implementiert:

interface Funktion {
    Integer calc(Integer x);
}

Diese Äquivalenz geht sogar noch einen Schritt weiter: Das λ foo implementiert jedes Java-Interface das genau eine nicht-statische Methode hat die einen Integer als Parameter übernimmt und einen Integer als Ergebnis liefert. Das λ foo implementiert die einzige Methode des Interface Funktion und kann überall dort verwendet werden, wo ein Objekt verlangt wird das das Interface Funktion implementiert – wir hätten unsere Methode also gar nicht auf das JDK-Interface Function umzuschreiben brauchen (naja, hätten wir gleich mit λ-Ausdrücken angefangen, hätten wir von Anfang an das JDK-Interface verwendet).

Interfaces, die der genannten Regel entsprechen und genau eine nicht-statische Methode haben heißen in Java "functional Interfaces" und werden meist mit der Annotation @FunctionalInterface versehen. Die Annotation ist nicht erforderlich, aber hilfreich. Der JDK bietet im Package java.util.function eine ganze Reihe nützlicher Interfaces an, aber es steht dem Entwickler frei, beliebige Interfaces zu definieren. Bisweilen ist es auch hilfreich nicht die JDK-Interfaces zu verwenden wenn das Wording nicht zu hundert Prozent paßt.

Methoden als Lambda-Ausdruck

Kommen wir nun zu einigen alternativen Varianten, λ-Ausdrücke zu schreiben die genau eine existierende Methode aufrufen.

statische Methoden

Beginnen wir mit statischen Methode. Die JDK-Klasse Math besitzt eine Methode abs() die den Absolutwert einer Integerzahl berechnet:

public static int abs(int a) {
    return (a < 0) ? -a : a;
}

An dieser Stelle sei darauf hingewiesen, daß wir hier durch das Autoboxing von Java die Typen Integer und int jederzeit gegen einander austauschen können. Wir können diese Methode nun so verwenden wie oben und in einen λ-Ausdruck verpacken:

verarbeite(liste, x -> Math.abs(x));

Wir können die Methode aber auch mit dem ::-Operator referenzieren. Dabei wird die Klasse links des Operators angegeben und Name der Methode rechts:

verarbeite(liste, Math::abs);

nicht-statische Methoden

Das geht auch mit nicht-statischen Methoden. Allerdings benötigen wir dafür ein Objekt auf das sich die Methode bezieht. Befindet sich die Methode in der gleichen Klasse, können wir this verwenden:

verarbeite(liste, this::doppel);

Natürlich kann man auch eine Variable verwenden:

Calc c = new Calc();
verarbeite(liste, c::doppel);

oder ein Objekt in place erzeugen:

verarbeite(liste, new Calc()::doppel);

Tatsächlich darf auf der linken Seite des ::-Operators ein beliebiger Ausdruck stehen der ein Objekt liefert, also zum Beispiel eine Methode – statisch oder auch nicht – die ein Objekt liefert.

Statische Referenz nicht-statischer Methoden

Die dritte Variante ist auf den ersten Blick ausgesprochen verwirrend, weil sie die beiden vorangegangenen Varianten zu vermischen scheint.

Tatsächlich ist es aber vermutlich die am häufigsten eingesetzte Variante und ist beim Arbeiten mit Streams und Optionals unverzichtbar.

Nehmen wir uns also etwas mehr Zeit dafür:

Wie bei der ersten Variante schreibt man den Namen der Klasse auf der linken Seite des ::-Operators und den Namen der (nicht-statischen) Methode auf der rechten Seite:

String::trim

Während die ersten beiden Varianten noch intuitiv verständlich sein dürften, muß man sich fragen, wie dieser Ausdruck zu interpretieren ist. Die Methode trim() der Klasse String ist nicht statisch. Sie wird auf ein String-Objekt angewandt und liefert als Ergebnis ebenfalls einen String. Da stellt sich zunächst die Frage, welches funktionale Interface die Methode implementiert? Probiert man das aus erhält man folgendes verblüffende Ergebnis:

Function<String, String> mapper = String::trim;

Dann ist trim() also eine Methode, die einen String als Eingabe hat und einen String als Ergebnis liefert? Ganz falsch ist diese Interpretation nicht, wenn man sich vorstellt, daß der String der getrimmt werden soll nicht in der Klammer steht, sondern vor dem Punkt:

String foo = " mit Leerzeichen ";
foo = foo.trim();

Und genauso wird der Ausdruck String::trim verwendet. Man benutzt ihn, wenn man eine Methode angeben möchte, die auf ein Objekt angewendet werden soll (in diesem Falle vom Typ String). Die Notation String::trim könnte man als "normalen" λ-Ausdruck auch so schreiben:

(String s) -> {return s.trim();}

Dieser λ-Ausdruck definiert eine Funktion, die einen String als Parameter übernimmt und dann die Methode trim() auf diesen Parameter anwendet. Gerade diese letzte Variante wirkt ohne praktische Anwendung ziemlich akademisch, In der Einführungen zu Optionals gibt es einige praktische Anwendungsbeispiele für solche λ-Ausdrücke.

Lambdas mit Kontext

Die oben vorgestellte, zweite Variante läßt sich – anders als die Variante mit der statischen Methode – nicht ohne weiteres als "normaler" λ-Ausdruck schreiben, da zur Ausführung ein Objekt benötigt wird. Hier sehen wir eine weitere Eigenschaft von λ-Ausdrücken: Man kann ihnen nämlich aus dem sie umgebenden Kontext Daten mitgeben:

Calc rechner = new Calc();
Function<Integer, Integer> fkt = (Integer x) -> rechner.doppel(x);
this.verarbeite(liste, fkt);

Was geschieht hier? Wir erzeugen zunächst ein Objekt rechner von Typ Calc und bauen dann einen λ-Ausdruck der das Objekt rechner verwendet um die Methode doppel() mit einem Integer-Wert auszuführen der als Parameter mitgegeben wird.

Das Objekt kommt aus dem Kontext des λ-Ausdrucks (der Methode in der der Ausdruck erzeugt wird) und wird dem λ-Ausdruck mitgegeben. Man kann dieses Verhalten zum Beispiel dazu nutzen, λ-Ausdrücke mit einer Methode zu erzeugen um sie dann an anderer Stelle zu verwenden:

public Function<Integer, Integer> getRechner() {
    Calc rechner = new Calc();
    return (Integer x) -> rechner.doppel(x);
}
 
public void rechne() {
    this.verarbeite(liste, getRechner());
}

Die Methode getRechner erzeugt das Calc-Objekt und baut es in den λ-Ausdruck ein. Wenn die Methode rechne() das λ von getRechner als Ergebnis erhält, ist die Methode getRechner() zu Ende gelaufen, aber das Objekt das die Variable rechner referenziert lebt im λ-Ausdruck weiter und wird nun bei der Verarbeitung verwendet.

Dabei muß man zwei Dinge beachten: Die Variable rechner, die das Objekt enthält das hier in den λ-Ausdruck eingearbeitet wird, muß "effektiv final" sein. Das heißt, ihr Inhalt darf nach der Zuweisung nicht mehr verändert werden. Folgender Code wird daher nicht kompilieren, da rechner nachträglich verändert wird:

public Function<Integer, Integer> getRechner() {
    Calc rechner = new Calc();
    Function<Integer, Integer> fkt = (Integer x) -> rechner.doppel(x);
    rechner = new Calc();
    return fkt;
}

Das Objekt das durch die Variable rechner referenziert wird kann aber sehr wohl verändert werden. Dadurch kann sich das Verhalten des λ-Ausdrucks ändern. Das ist ein generelles Problem in Java, das keine unveränderbare (immutable) Objekte kennt. Es ist dringend davon abzuraten, solches Verhalten in λ-Ausdrücke einzuarbeiten, auch wenn es auf den ersten Blick sehr elegant wirkt. Solches Seiteneffekte führen sehr gerne zu obskuren Fehlern, die nur schwer zu debuggen sind.