Java 8 Retrospektive

Dezember 2019, Michael Höreth

Einleitung

Seit Java 8 gibt es nun die Lambda Expressions. Diese funktionalen Konstrukte werden immer zusammen mit Streams genannt - ein weiteres neues Feature von Java 8. Da ist noch etwas, das uns seitdem immer wieder über den Weg läuft: der Doppelpunktoperator. Oder sollte man besser sagen Doppelter Doppelpunktoperator?

Diese drei Features wurden irgendwie nicht ohne Grund in einem gemeinsamen Release eingeführt. Ich nutze sie natürlich auch schon länger, aber der generelle Zusammenhang hat sich mir erst nach einer Weile erschlossen. Dieser Artikel beschreibt daher nicht das "Wie und was?" - dazu gibt es viele andere "Getting Started Tutorials" - hier geht es eher um das "Warum und weshalb?".

Lambda Expressions

Das sogenannte Lambda Kalkül ist eine mathematische Abhandlung zur Schreibweise und Mächtigkeit von Funktionen. Jetzt ist jede Java-Methode mit Rückgabewert letztlich auch eine Funktion. Was ist nun eigentlich der Vorteil der "Lambda Expressions" gegenüber einer normalen Methode?

Beispiel: Der java.util.Comparator ist ein Interface mit einer relevanten Methode compare. Diese Methode ist mathematisch gesehen eine Funktion mit zwei Parametern und einem Integer-Rückgabewert. Bereits vor Java 8 gab es die Möglichkeit, einen anonymen Comparator wie folgt mitten im Code zu definieren:

List<Person> personen = new ArrayList<>();
...
Collections.sort(personen, new Comparator<Person>() {
  @Override
  public int compare(Person x, Person y) {
    return x.getNachname().compareTo(y.getNachname());
  }
});

Was gibt es an dieser Stelle also noch zu verbessern?

Seiteneffekte

Eine gute funktionale Programmiersprache schließt Seiteneffekte aus. Das ist in diesem Beispiel noch nicht der Fall. Wir sind in Java gewohnt, auf alle anderen Objektattribute und andere Klassenattribute gemäß ihrer Sichtbarkeit zugreifen zu können. Mit den Lambda-Expressions wurde die Restriktion eingeführt, dass man innerhalb eines Lambdas nur solche äußeren Attribute sehen kann, die final sind. Man gewährt damit zwar immer noch lesenden Zugriff auf einen äußeren Zustand, aber der schreibende Zugriff wird dadurch stark eingeschränkt.

Kompakte Schreibweise

In diesem Beispiel vergleichen wir zwei Personen mit einer Zeile Code. Die anonyme Instanziierung der Comparator Klasse bringt leider sehr viel "Boilerplate-Code" mit sich. Warum eigentlich? Man kann eben nicht einfach eine Methode instanziieren, sondern man schafft immer eine Instanz einer Klasse. Der Compiler muss also wissen, welches Interface implementiert werden soll und welche Methode. Eine Java-Klasse kann nunmal beliebig viele Methoden besitzen, daher müssen wir dem Compiler auch sagen, um welche es sich handelt.

Seit Java 8 weiß der Compiler, dass ein Comparator quasi immer eine Funktion ist, die zwei Parameter vom selben Typ übergeben bekommt und ein Integer zurückliefert. Das führt zu folgender kompakter Schreibweise:

personen.sort(
  (x,y) -> x.getNachname().compareTo(y.getNachname())
);

Mit einer Lambda-Expression instanziiert man also konzeptionell keine Klasse mehr, sondern nur eine einzige Funktion. Es bleibt die Frage - woher weiß der Compiler welche? Dazu werfen wir einen Blick in die Implementierung von Comparator:

// Die Annotation ist eigentlich nur informativ.
@FunctionalInterface
public interface Comparator<T> {
  ...
  int compare(T o1, T o2);
  ...
  boolean equals(Object obj);

Die compare-Methode ist die einzige abstrakte Methode von Comparator, die keine Methode von java.lang.Object überschreibt. Das Comparator-Interface ist daher nicht nur als solches annotiert, sondern es erfüllt auch dieses hinreichende Kriterium eines funktionalen Interface. Der Compiler weiß also, dass es bei einer Lambda-Expression um die compare-Methode geht und kann daher auf unnötigen Code verzichten.

Streams

MapReduce ist ein Programmierparadigma von Google, das sich mit der Verarbeitung von großen Datenmengen befasst. Man nimmt an, dass Daten häufig in einer Art und Weise verarbeitet werden, indem sie gefiltert, konvertiert oder aggregiert werden. In einer prozeduralen Programmierung würde man dann viele foreach-Loops sehen und die Datenmenge iterativ abarbeiten. Diese Vorgehensweise lässt sich leider auf der JVM nicht parallel abarbeiten. Die Datenverarbeitung muss in kleinere Operationen aufgebrochen werden, die ihrerseits parallelisierbar sind. Lambda-Expressions bieten sich aufgrund ihrer bereits angesprochenen fehlenden Seiteneffekten dafür an. In Anlehnung an Google's Programmierparadigma führt Java 8 zur Verarbeitung von großen Datenmengen folgende Operationen ein, die auf sogenannten Streams ausgeführt werden können:

  • filter(Predicate)
  • map(Function)
  • reduce(identity, BinaryOperator)

Predicate, Function und BinaryOperator sind funktionale Interfaces aus dem java.util.function Package. Mit diesem Package wollte man eigentlich nur die häufigsten Signaturen funktionaler Interfaces abdecken. Fast alle diese Interfaces besitzen generische Typ-Parameter. Ein Predicate ist z.B. einfach nur ein Lambda, das ein Objekt eines Typs übergeben bekommt und boolean zurück liefert. Es erscheint unnötig zu erklären, wie man mit einem Predicate filtern kann. Eine Function bekommt ein Objekt übergeben und liefert ein Objekt zurück. Damit lassen also Daten mappen. Das Function-Interface klingt jetzt zwar irgendwie besonders, ist letztlich aber auch nur eines von vielen. Mit seiner Signatur eines beliebigen Eingabeparameters und eines beliebigen Rückgabewertes erinnert es an die gängigsten mathematischen Funktionen - daher der Name.

„ java.util.Function ist auch nur ein @FunctionalInterface. ”

Der BinaryOperator erwartet zwei Objekte vom selben Typ T und liefert wieder ein Objekt vom Typ T zurück. Er ermöglicht eine Reduktion der Datenmenge. Diese funktionalen Interfaces des java.util.function Package sind meistens generisch und lassen sich für viele Anwendungsfälle typisieren. Leider gilt das in Java nur für die Objekttypen. Die primitiven Datentypen mussten separat betrachtet werden. Beispiel: ein Lambda, das einen primitiven double erwartet und ein double zurückliefert, kann nicht über das Function-Interface realisiert werden. Dafür gibt es dann nochmal eigene Interfaces, wie z.B. DoubleFunction. Die recht lange Liste der funktionalen Interfaces (> 40) ist also letztlich nur eine Benamung der häufigsten funktionalen Aritäten unter Berücksichtigung aller primitiven Datentypen. Und damit nicht genug, denn die sogenannten "single abstract methods" dieser funktionalen Interfaces bringen ja auch nochmal ihre eigenen Namen mit. Die nachfolgende Tabelle demonstriert anhand einiger Beispiele, wie stark das Vokabular von Java in dem Bereich der funktionalen Typen aufgebläht wurde. In Swift gibt es z.B. einen eigenständigen Funktionstyp als Sprachfeature. Dieser ist unabhängig von Interface-Klassen und benötigt durch seine kompakte und mathematische Pfeilnotation keine zusätzlichen Bezeichner.

Funktionale Interfaces

java.util.functionMathematische Pfeilnotation
boolean Predicate<T>.test(T t) (T) → boolean
R Function<T,R>.apply(T t) (T) → R
T BinaryOperator<T>.apply(T t1, T t2) (T, T) → T
Consumer<T>.accept(T t) (T) → void
T Supplier<T>.get() () → T
R BiFunction<T,U,R>.apply(T t, U u) (T, U) → R
BiConsumer<T,U>.accept(T t, U u) (T, U) → void
Sonstige
int Comparator<T>.compare(T t1, T t2) (T, T) → int
void Runnable.run() () → void
void ActionListener.actionPerformed(ActionEvent e) (ActionEvent) → void
... ...

Das ist genau ein Knackpunkt, über den ich persönlich nicht von Anfang an nachgedacht hatte. In der mathematischen Notation stecken bereits alle Infos, die man braucht. Hingegen benötigt jede Lambda-Expression ein Gegenstück in Form eines funktionalen Interface - und das braucht eben genau wie dessen "single abstract method", einen Namen. Da hilft nur Auswendiglernen. Worauf man sich hingegen scheinbar verlassen darf, ist die Reihenfolge der Typ-Parameter. Beispiel: Eine BiFunction<T,U,R> ist mathematisch ein Abbildung der Form: (T, U) → R. Innerhalb der spitzen Klammer der Typ-Parameter hat die Reihenfolge eigentlich keine Semantik. Dennoch entspricht die Reihenfolge der Parameter meiner Erfahrung nach immer genau der Reihenfolge der Pfeilnotation. Der Rückgabetyp steht also, falls es einen gibt und falls es ein Objekttyp ist, immer rechts. Das ist in der täglichen Arbeit nicht ganz unwichtig, weil die IDE gerne mal dazu auffordert, z.B. eine Function<T,R> zu implementieren, man sich aber nicht jedes Mal die zugehörige Methode ansehen möchte. Gut, in diesem Beispiel ist der Buchstabe R wie Return ein noch besserer Hinweis. Ich hoffe dennoch, dass ich den Zusammenhang zwischen funktionalem Interface, "single abstract method" und mathematischer Bedeutung mit diesem Exkurs deutlich machen konnte.

Die Java-Streams verarbeiten also große Datenmengen nach einem neuen Paradigma. Einzelne Datenelemente werden von Lambdas verarbeitet. Aufgrund ihrer funktionalen Natur ist die Verarbeitungsreihenfolge egal und es bleibt dem Stream überlassen, wie er daraus Nutzen schlägt. Ein Stream, der auf mehreren CPUs operiert, könnte beim Filtern z.B. die Datenmenge vorab in gleich große Teilmengen unterteilen, diese dann parallel verarbeiten und die gefilterten Ergebnisse am Ende wieder zu einem Gesamtergebnis zusammenführen.

Fazit: Streams wären zwar auch ohne Lambda-Expressions möglich, aber diese machen die typischen Verarbeitungsschritte durch die kompakte Schreibweise erst leserlich. Das folgende Beispiel soll das nochmal deutlich machen. Wir suchen das Gesamtalter aller Personen, wobei "Mustermanns" dafür nicht berücksichtigt werden sollen, also herausgefiltert werden. Je nach Ausführungsumgebung werden die Personen automatisch auf mehreren CPUs gleichzeitig verarbeitet. Anstelle einer iterativen Addition wird hier immer nur das Alter zweier Entitäten addiert, bis nur noch eine Entität übrig bleibt. Ich schreibe Entität deswegen, weil man natürlich nur Integers und keine Personen addieren kann. Daher werden die Personen auch vor der reduce-Phase auf Integer-Entitäten gemappt. Der Reduktionsoperator benötigt immer zwingend zwei Argumente, die er summiert. Sollte mal ein Argument fehlen, z.B. bei einem ein-elementigen Stream, wird der Identitätsparameter dort ersatzweise eingesetzt. Dieser soll sich bezgl. der Reduktionsoperation neutral auswirken und wird daher in diesem Beispiel auf "0" gesetzt.

int gesamtAlter = personen.stream()
  .filter((p) -> !Objects.equals(p.nachname, "Mustermann"))
  .map((p) -> p.alter)
  // Der Getter ist bereits definiert,
  // es ginge also auch so:
  // .map(Person::getAlter)
  .reduce(0, (x, y) -> x + y);

Das sieht zwar alles schon recht gut aus, aber ich denke, den Streams steht die große Zukunft erst noch bevor. Angenommen, die Liste der Personen wurde sequentiell aus einer Datenbank geladen. Erst danach steigen wir mit der Konvertierung der Liste in einen Stream in die Parallelisierung ein. Und das nur für eine simple Addition? Das wird leider die Gesamtperformance nicht sonderlich steigern. Seit Hibernate 5.2 gibt es z.B. die Möglichkeit, mit einer Query unmittelbar einen Stream zu erzeugen. Hibernate könnte also mehrere Datenbankverbindungen gleichzeitig nutzen, um die Daten bereits beim Lesen parallel zu verarbeiten. Aufgrund der IO-Latenzen, die an dieser Stelle auftreten, könnte sich die Performance nun signifikant erhöhen. Hoffen wir also, dass der Trend zum "Streamen" auch in den Drittanbieter-Bibliotheken weiter anhält und dadurch die Streams immer länger und effektiver werden können.

Doppelpunktoperator ::

Wollte man vor Java 8 eine Referenz auf eine Methode haben, konnte man so vorgehen:

Method m = Person.class.getMethod("setNachname", String.class);
m.invoke(person, "Mustermann")

Man sieht dem Code bereits seine Nachteile an: der Methodenname wird als String übergeben und kann daher nicht vom Compiler überprüft werden. Die invoke-Methode kann mit einer variablen Anzahl von Parametern aufgerufen werden. Ob das zur Signatur der Methode passt, merkt man auch erst zur Laufzeit. Dazu kommt noch, dass von derartigem Code aufgrund der schlechten Performance der Java-Introspection generell abgeraten wird. Letztlich war Java bis zur Version 8 nicht ausdrucksstark genug, um Methoden sinnvoll referenzieren zu können.

Methoden haben zwar schon immer eine mathematische Funktion abgebildet, aber erst die funktionalen Interfaces lassen diese Funktionen formal beschreiben.

Getter

Sehen wir uns mal die Methode Person.getNachname() an. Mathematisch gesprochen bekommt diese Funktion als Eingangsparameter das Personenobjekt übergeben und liefert einen String zurück. Eigentlich lässt sich daher jeder Getter mit einer java.util.Function beschreiben.

// Function<T, R> steht für T = Type, R = Return type
Function<Person, String> getter = Person::getNachname;
String s = getter.apply(person);

Setter

Wie sieht es nun mit Person.setNachname(String) aus? Mathematisch gesehen kommen zwei Objekte rein: die Person und ein String. Es gibt keinen Rückgabewert. Ein kurzer Blick in die o.a. Tabelle zeigt: hier handelt es sich um einen BiConsumer. Auch das lässt sich für alle Setter verallgemeinern.

Property

Langsam wird klar, dass Getter und Setter IMMER auch eine Function und einen BiConsumer des selben Typs implementieren.

Person::getVorname Function<Person,String> (Person) → String
Person::setVorname BiConsumer<Person,String> (Person, String) → void

Die Kombination aus Getter und Setter ist allgemein als Bean-Property bekannt, jedoch gibt es dafür in Java bislang keine Implementierung, die diese Konvention abbildet. Das folgende Beispiel implementiert eine solche Property:

/**
 * There is no "built-in property" concept in Java.
 * This class ties together a pair of getter
 * and setter. C = class type, A = attribute type.
 */
public class Property <C, A> {
  final Function<C, A> getter;
  final BiConsumer<C, A> setter;

  public Property(Function<C, A> getter, BiConsumer<C, A> setter) {
    this.getter = getter;
    this.setter = setter;
  }

  public void set(C object, A attribute) {
    this.setter.accept(object, attribute);
  }

  public A get(C object) {
    return this.getter.apply(object);
  }
}
	 	

Die Property "Vorname" der Personenklasse ließe sich damit dann mit Hilfe des Doppelpunktoperators so definieren:

new Property<Person, String>(Person::getVorname, Person::setVorname);

Take away

Ich hoffe, dass mit diesem Artikel die Zusammenhänge der funktionalen Features seit Java 8 klarer geworden sind und sich dadurch die Lambda-Expression noch intuitiver verwenden lassen. Es ist auf jeden Fall begrüßenswert, was sich in dieser Richtung in Java getan hat. Denn wer möchte schon eine rein funktionale Programmiersprache? Ganz ohne Seiteneffekte geht es schießlich auch nicht. Am Ende des Tages werden wir für die Seiteneffekte bezahlt (Zitat aus dem Vortrag Functional Programming von Russ Olsen). Es wird immer einen "globalen Zustand" z.B. in Form einer Datenbank geben - und eben diese Änderung des globalen Zustands ist per Definition ein Seiteneffekt. Eine Programmiersprache bietet also am besten nicht nur ein einziges Paradigma, sondern möglichst viele, die sich miteinander kombinieren lassen.