Java 8 - Kalter Kaffee?von Michael Höreth, erschienen im Java Magazin, Ausgabe 6.2020 Mittlerweile gibt es kaum noch ein Java-Projekt oder eine Bibliothek, das oder die nicht mindestens Java 8 als Baseline hat. Lambdas werden dadurch nicht nur immer häufiger eingesetzt, sondern auch gerne immer komplexer. Es war vermutlich nie wichtiger als heute, die Funktionsweise dahinter auch im Detail zu verstehen. Und weil das im Alltag vielleicht schnell mal aus dem Blick gerät, bietet dieser Artikel je nach Erfahrung des Lesers entweder einen sehr kompakten Einstieg in die Zusammenhänge der funktionalen Sprachfeatures von Java oder er hält hoffentlich dennoch ein paar Aha-Momente für diejenigen bereit, die schon länger damit arbeiten. 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 von 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, das wie in Listing 1 zu implementieren. Listing 1: Anonyme Instanz von Comparator
Es handelt sich hierbei um eine anonyme Implementierung von Comparator, die inline notiert werden konnte. Die Ausführung findet außerhalb des lokalen Scopes bzw. Kontexts statt, in dem z. B. die lokale Variable personen definiert ist. Dennoch könnte man auch innerhalb der compare-Methode auf personen zugreifen. Der Compiler realisiert das durch eine Closure, die sich den lokalen Kontext merkt. Dabei werden alle freien Variablen kopiert und für den lesenden Zugriff zu einem späteren Zeitpunkt zur Verfügung gestellt. Der Begriff „freie Variablen“ vereinigt alle lokalen Variablen plus alle Methodenparameter des aufrufenden Kontexts. Lambda Expressions ändern an dieser Vorgehensweise erst einmal nichts. Sie werden ebenso als Closures umgesetzt. Auch hier müssen „freie Variablen“ final sein, weil ein schreibender Zugriff auf eine Kopie des Originals keinen Sinn ergeben würde. Praktischerweise muss man Deklarationen nicht mehr unbedingt mit final kennzeichnen. Seit Java 8 ist der Compiler in der Lage, zu erkennen, wenn nur lesend auf eine Variable zugegriffen wird. Er betrachtet solche Variablen als effectively final. Lambda ExpressionsDas Beispiel aus Listing 1 schrumpft auf einen Einzeiler zusammen, wenn man es als Lambda Expression schreibt:
Das sollte einen Entwickler im Jahre 2020 nicht mehr überraschen. Dennoch lade ich den Leser auf einen kurzen Tauchgang ein, um zu erkunden, wie das eigentlich ermöglicht wurde. Warum brachte die anonyme Instanziierung der Comparator-Klasse eigentlich immer noch so viel unnötigen Code mit sich? Man kann in Java eben nicht einfach so eine Methode instanziieren, sondern man erstellt auch immer eine Instanz einer Klasse. Der Compiler musste also mindestens wissen, welches Interface implementiert werden soll. Java 8 führte erstmalig das Feature der Typinferenz ein. Der Compiler ermittelt aus dem Kontext, um welchen Typ bzw. um welches Interface es sich bei einer Lambda Expression handeln muss. Dadurch entfällt im Beispiel die Angabe von Comparator. Woher weiß er aber, dass wir mit der Lambda Expression die compare-Methode implementieren? Dazu werfen wir in Listing 2 einen Blick in die Implementierung von Comparator. Listing 2: Comparator
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 in diesem Fall um die compare-Methode geht und kann daher auf jeglichen unnötigen Code verzichten. Man könnte Lambda Expressions als anonyme Implementierungen von funktionalen Interfaces bezeichnen. Die Typinferenz sorgt dafür, dass man das funktionale Interface nicht benennen muss. Das Single-Abstract-Method-Prinzip der funktionalen Interfaces sorgt dafür, dass man die zu implementierende Methode nicht benennen muss. Das führt in der Summe zur denkbar kürzesten Notation für diesen Anwendungszweck. SeiteneffekteEine gute funktionale Programmiersprache schließt Seiteneffekte aus. Wir sind in Java gewohnt, auf alle anderen Variablen gemäß ihrer Sichtbarkeit zugreifen zu können. Anfangs fand ich die Einschränkung der freien Variablen auf final relativ nervig. Man gewährt damit zwar immer noch lesenden Zugriff auf einen äußeren Zustand, aber der schreibende Zugriff wird stark eingeschränkt. Der Zugriff auf Attribute der äußeren Klasse sowie auf alle sichtbaren statischen Attribute und Methoden ist weiterhin möglich, aber innerhalb des Scopes der Lambda Expression werden Seiteneffekte durch diese Einschränkung insgesamt vermindert. Gerade beim Thema der funktionalen Programmierung sollte man für derartige Einschränkungen dankbar sein. Je weniger Seiteneffekte, umso hochwertiger der funktionale Aspekt der Implementierung. Ich bin daher mittlerweile dankbar für die Beschwerden des Compilers über Verwendung der freien Variablen innerhalb eines Lambdas, weil man darauf aufmerksam gemacht wird, dass man ein gutes Stück vom Weg der funktionalen Programmierung abgekommen ist. StreamsMapReduce ist ein Programmierparadigma von Google, das sich mit der Verarbeitung großer 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 verschachtelte for-Schleifen schreiben und die Datenmenge iterativ abarbeiten. Diese Vorgehensweise lässt sich leider auf der JVM durch weitere CPUs nicht beschleunigen. Die Datenverarbeitung muss in kleinere Operationen aufgebrochen werden, die ihrerseits parallelisierbar sind, wenn man die Verarbeitungsgeschwindigkeit erhöhen möchte. Lambda Expressions bieten sich dafür an. In Anlehnung an Googles Programmierparadigma führte Java 8 zur Verarbeitung großer Datenmengen folgende Operationen ein, die auf den sogenannten Streams ausgeführt werden können:
Derartige Methoden nennt man auch Funktionen höherer Ordnung, weil sie ihrerseits Funktionen übergeben bekommen. Predicate, Function und BinaryOperator sind funktionale Interfaces aus dem Package java.util.function. Mit diesem Package wollte man eigentlich nur die häufigsten Signaturen funktionaler Interfaces abdecken. Fast alle diese Interfaces besitzen generische Typparameter. Ein Predicate ist z.B. einfach ein Lambda, das ein Objekt eines Typs übergeben bekommt und boolean zurückliefert. 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 sich 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ückgabewerts erinnert es an die gängigsten mathematischen Funktionen – daher vielleicht 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 wie gesagt meist 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-Wert erwartet und einen double-Wert zurückliefert, kann nicht über das Function-Interface realisiert werden. Dafür gibt es dann nochmal eigene Interfaces, wie z. B. die DoubleFunction. Die recht lange Liste der funktionalen Interfaces (mit mehr als 40) ist also letztlich nur eine Benamung der häufigsten funktionalen Aritäten unter Berücksichtigung aller primitiven Datentypen. Und damit nicht genug, bringen die sogenannten Single Abstract Methods dieser funktionalen Interfaces auch nochmal ihre eigenen Namen mit. Tabelle 1 demonstriert anhand einiger Beispiele, wie stark das Vokabular von Java im Bereich der funktionalen Typen aufgebläht wurde. In Swift gibt es z.B. einen eigenständigen Funktionstyp als Sprachfeature. Dieser ist unabhängig von Interfaceklassen und benötigt durch seine kompakte und mathematische Pfeilnotation keine zusätzlichen Bezeichner.
Funktionale InterfacesDiese historisch bedingte Verkomplizierung der funktionalen Interfaces stellte für mich lange Zeit die Bäume dar, derentwegen ich den Wald nicht sehen konnte. In der mathematischen Notation stecken eigentlich 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 das Auswendiglernen der wichtigsten Beispiele. Worauf man sich hingegen scheinbar verlassen darf, ist die Reihenfolge der Typparameter. Beispiel: Eine BiFunction<T, U, R> ist mathematisch eine Abbildung der Form: (T, U) → R. Innerhalb der spitzen Klammer der Typparameter hat die Reihenfolge eigentlich keine Semantik. Dennoch entspricht die Reihenfolge der Parameter meiner Erfahrung nach immer genau der Reihenfolge der Pfeilnotation. Der Rückgabetyp, falls es einen gibt und falls es ein Objekttyp ist, steht also 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 deutlich machen konnte. Die Java-Streams verarbeiten also große Datenmengen nach einem neuen Paradigma. Einzelne Datenelemente werden von Lambda Expressions 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. Wir nutzen hier den parallelStream() statt eines einfachen stream(). Je nach Ausführungsumgebung werden die Personen dann 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 übrigbleibt. 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 zwei Argumente, die er summieren kann. Der Identitätsparameter verhält sich zwar auf den ersten Blick wie ein initialer Wert der Reduktion, er muss aber den „Contract“ der Identität erfüllen, weil es von der Implementierung des Streams abhängig ist, wann und wie oft dieser zur Reduktion herangezogen wird. In unserem Beispiel muss es die 0 sein, weil das die einzige Zahl ist, die sich neutral auf den Reduktionsoperator bzw. die Addition auswirkt. Listing 3:
Das sieht zwar alles schon recht gut aus, aber ich denke, den Streams steht die große Zukunft erst noch bevor. Angenommen, die Personen wurden sequenziell 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 Verarbeitungsgeschwindigkeit nun signifikant erhöhen. Hoffen wir also, dass der Trend zum Streamen auch in den Drittanbieterbibliotheken weiter anhält und dadurch die Streams immer länger und effektiver werden können. Doppelpunktoperator ::In Listing 3 ist er bereits im Kommentar zu sehen – der doppelte Doppelpunkt. Mit Person::getAlter können wir die Mappingoperation des Streams sogar ganz ohne Lambda Expression schreiben, weil die Abbildung von Person → Alter bereits durch den Getter implementiert wurde. Der Doppelpunktoperator liefert genau darauf eine Referenz. Das ging vor Java 8 so:
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. Das änderte sich mit der Einführung des Doppelpunktoperators. Dieser liefert keine Instanz von Method mehr zurück, sondern das jeweils passende funktionale Interface aus dem Package java.util.function. Das ist auch sinnvoll, denn in gewisser Art und Weise haben Methoden schon immer eine mathematische Funktion abgebildet. An dieser Stelle sei noch erwähnt, dass man z.B. mittels Person::new auch eine Referenz auf einen Konstruktor erhalten kann. Und da wäre noch eine Besonderheit des Doppelpunktoperators: Ich konnte nie so recht erkennen, ob eine statische oder eine nicht-statische Methode referenziert wurde. Person::getAlter referenziert in unserem Beispiel einen Getter, der Function<Person, Integer> implementiert. Was wäre aber, wenn es in der Klasse Person zusätzlich folgende Methode geben würde: static Integer getAlter(Person p)? Die Antwort ist relativ ernüchternd: Der Compiler hätte einen Fehler erzeugt, weil er eine zweite Methode mit dem Namen getAlter gefunden hätte, die ebenfalls eine Function<Person, Integer> implementiert. Man sieht es dem Doppelpunktoperator daher nicht direkt an, ob es sich um eine statische oder nicht-statische Methodenreferenz handelt. Es empfiehlt sich deshalb, darauf achten, dass es in dieser Hinsicht keine Mehrdeutigkeiten geben kann, damit der Compiler noch zurechtkommt. Ansonsten entscheidet natürlich auch der aufrufende Kontext darüber, wie spezifisch der Compiler nach einer passenden Methode suchen kann. Und der hat auch ohne derartige Überladungen schon genug Arbeit. Schließlich muss er für jede Lambda Expression durch Typinferenz ermitteln, welches funktionale Interface bzw. welchen Typ sie eigentlich implementiert. Wir erinnern uns dazu an das Beispiel mit dem Comparator. In der Lambda Expression sind sämtliche Typinfos weggefallen. Wir instanziieren ein funktionales Interface, ohne dem Compiler zu sagen, welches. Diese Typinferenz steht seit Java 10 mittlerweile auch für lokale Variablen zur Verfügung. Zum Schluss aber nochmal zurück zu einem kleinen Programmierbeispiel. Welche Besonderheiten haben z. B. Getter und Setter (die vermutlich häufigsten Methoden der Java-Welt) und was lässt sich in dieser Hinsicht verallgemeinern? GetterNehmen wir mal die Methode Person.getVorname() unter die Lupe. Mathematisch gesprochen bekommt sie als Eingangsparameter das Personenobjekt übergeben und liefert einen String zurück. Somit implementiert eigentlich jede Getter-Methode eine java.util.Function (Listing 4). Listing 4
SetterWie sieht es mit Person.setVorname(String) aus? Mathematisch gesehen kommen zwei Objekte rein: die Person und ein String. Es gibt keinen Rückgabewert. Ein kurzer Blick in Tabelle 1 zeigt: Hier handelt es sich um einen BiConsumer. Auch das lässt sich für alle Setter verallgemeinern. PropertyLangsam wird klar, dass Getter und Setter immer auch eine Function und einen BiConsumer desselben Typs implementieren. In dem konkreten Beispiel wären diese wie in Tabelle 2 typisiert.
Die Kombination aus Getter und Setter ist allgemein als Bean Property bekannt, jedoch gibt es in Java bislang keine Implementierung, die diese Konvention abbildet. Das Beispiel in Listing 5 demonstriert eine solche Property in Form einer eigenen Klasse mit Hilfe des Doppelpunktoperators. Listing 5
Take awayDas war jetzt hoffentlich nicht alles aufgewärmter Coffee to go. Mir persönlich hat es sehr geholfen, mich mit diesem Thema nochmal eingehend zu beschäftigen, und ich hoffe, dass ihr die Lambdas damit auch etwas intuitiver nutzen könnt. Es ist auf jeden Fall begrüßenswert, was sich in dieser Richtung in Java getan hat. „Funktional“ ist zwar sehr im Trend, aber wer möchte schon eine rein funktionale Programmiersprache? Ganz ohne Seiteneffekte geht es schließlich auch nicht. Am Ende des Tages werden wir für die Seiteneffekte bezahlt – so Russ Olsen in „Functional Programming“. 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. |
|||||||||||||||||||||||||||||||||||||||||