Traditionell, auch aufgrund der Zeit zwischen Abgabe und dem Erscheinen, hänge ich bei aktuellen Themen mit dieser Kolumne immer etwas hinterher und versuche deswegen, über Themen mit hoher Dynamik erst dann zu schreiben, wenn sich nicht mehr allzu viel ändern wird.
Doch dieses Mal soll alles anders sein und wir schauen uns Features an, die erst in der Zukunft ins JDK kommen werden. Deswegen gilt für alles, was ich hier beschreibe, der Hinweis, dass zum Lesezeitpunkt auch schon vieles anders sein kann oder die Features dann doch nicht kommen werden. Da die meisten der Features jedoch, zum Zeitpunkt des Schreibens, bereits als Preview JEPs für JDK 22 eingeplant sind und dieses zum Lesezeitpunkt sogar erschienen sein sollte, gehe ich doch stark davon aus, dass keines der Features mehr komplett gestrichen wird.
Statements vor Aufruf von super
Erben wir über extends
von einer anderen Klasse, haben wir die Möglichkeit, mittels super
explizit auf Variablen oder Methoden dieser Klasse zuzugreifen. Dies gibt uns, vor allem beim Überschreiben von Methoden, die Möglichkeit, vor oder nach dem Aufruf zusätzliche Dinge zu erledigen. Wir können jedoch auch darauf verzichten, die überschriebene Methode jemals aufzurufen.
Für Konstruktoren gilt dies jedoch nicht. Wäre es beim Überschreiben möglich, nicht den Konstruktor der Elternklasse aufzurufen, könnten wir invalide und unvollständige Objekte erzeugen. Deswegen muss jeder Konstruktor entweder einen anderen Konstruktor der eigenen Klasse, mittels this
, oder einen der Elternklasse, mittels super
, aufrufen.
Zusätzlich musste dieser Aufruf bisher stets der erste Ausdruck in einem Konstruktor sein. Hierdurch wurde, mit wenig Aufwand, sichergestellt, dass nicht vor diesem Aufruf, mittels this
, auf die noch nicht vollständig initialisierte Instanz zugegriffen werden konnte (s. Listing 1).
Dieser Mechanismus ist sehr effektiv, verursacht aber leider hier und da auch Probleme. Diese müssen dann, in der Regel, über „synthetische“ Hilfsmethoden umgangen werden. Dabei gibt es drei klassische Anwendungsfälle, bei denen dieses Muster genutzt wird. Beim ersten Fall geschieht dies bei der zusätzlichen Validierung von Parametern des Konstruktors. Möchten wir diese in unserer Kindklasse validieren, müssen wir das entweder nach dem Aufruf von super
machen (s. Listing 2) oder die Validierung in einer statischen Methode durchführen, die bei erfolgreicher Validierung den Wert wieder zurückgibt (s. Listing 3).
Der zweite Anwendungsfall entsteht, wenn die Elternklasse mehrere Instanzen einer Klasse erwartet, wir in unserer Kindklasse jedoch mehrmals dieselbe Instanz übergeben möchten und diese auch noch erzeugen müssen. Dadurch, dass wir keine – einfache – Möglichkeit haben, uns die Instanz zu merken, wird hier üblicherweise mit einem privaten Hilfskonstruktor gearbeitet (s. Listing 4).
Auch beim dritten Fall ist das eigentliche Problem, dass wir uns, innerhalb des Konstruktors, keine temporären Variablen merken können. Dieser Fall entsteht nämlich dann, wenn wir für die Erzeugung von Parametern der Elternklasse mehrere Dinge machen müssen. Hier besteht die Lösung, analog zum ersten Fall, wieder aus einer statischen Hilfsmethode (s. Listing 5).
Um genau diese Fälle in Zukunft zu vermeiden, wird in JEP 447 daran gearbeitet, die Einschränkung zu entfernen, dass der erste Aufruf eines Kontruktors this
oder super
sein muss. Die drei beschriebenen Fälle würden sich dann wie in Listing 6 lösen lassen. Zwar wird dadurch erlaubt, Code auszuführen, bevor die Elternklasse komplett initialisiert ist, die Einschränkung, dass wir nicht auf Methoden oder Variablen dieser Klasse zugreifen dürfen, bevor wir deren Konstruktor aufgerufen haben, bleibt jedoch bestehen und wird weiterhin vom Compiler geprüft. Die Erkennung ist nun zwar deutlich komplizierter, der JEP zeigt eine ganze Menge an Fällen, die nun erkannt werden müssen, aber in diesem Fall haben die JDK-Maintainer entschieden, dass es die verbesserte Nutzung rechtfertig, diese Komplexität zu haben.
String-Templates
Möchten wir in Java in einem String
auf Variablen zugreifen, haben wir vier Möglichkeiten (s. Listing 7). Alle funktionieren, haben aber auch Nachteile, unter denen jeweils die Lesbarkeit leidet. Deswegen wünschen sich viele schon länger die Möglichkeit der Interpolation für String
s. Groß war dementsprechend der Frust, als 2020 die Arbeit an JEP 326 eingestellt und nur der Teil der mehrzeiligen String
s in JEP 355 weitergeführt wurde. Diese Text Blocks stehen uns nun seit JDK 15 mit JEP 378 bereits vollständig zur Verfügung.
Doch nun nähert sich auch die Arbeit an String Interpolation dem Ende entgegen. Bereits JDK 21 enthielt mit JEP 430 eine erste Preview und in JDK 22 wird es mit JEP 459 eine zweite geben. Das JDK hat dabei jedoch beschlossen, nicht einfach nur Interpolation zu implementieren, da hier die Angst zu groß war, dass dies Fälle böswilliger Code Injection begünstigen könnte. Deswegen wurde bewusst ein anderer Weg gewählt, nämlich diese Funktionalität über sogenannte Template Expressions anzubieten. Diese bestehen aus einem Template-Prozessor und einem Template, welches eine oder mehrere Embedded Expressions enthält. Listing 8 zeigt die Nutzung des vom JDK mitgebrachten STR-Prozessors.
Wie zu sehen, erzeugt der STR-Prozessor einen String
und ersetzt, wie erwartet, die eingebetteten Expressions durch deren konkreten Wert. STR selbst ist dabei ein Template-Prozessor, der als statische Variable im Interface java.lang.StringTemplate
existiert und automatisch in jeder Klasse importiert wird, ohne dass wir selber ein import-Statement schreiben müssen.
Neben dem STR-Prozessor wird vom JDK noch der FMT- und RAW-Prozessor mitgeliefert. Der FMT-Prozessor ist dabei in java.util.Formatter
definiert und funktioniert wie der STR-Prozessor, unterstützt aber auch die aus String::format
bekannten Formatierungsanweisungen. Der RAW-Prozessor wiederum kann immer dann verwendet werden, wenn die Auswertung des konkreten StringTemplate
erst später, beispielsweise mittels STR::process
, durchgeführt werden soll.
Durch das gewählte Design werden zudem Fehlermeldungen beim Kompilieren geworfen, wenn wir eine Embedded Expression innerhalb eines normalen String
s verwenden. Dies macht es unmöglich, die Angabe des zu nutzenden Prozessors zu vergessen. Außerdem ermöglicht der gewählte Ansatz auch die Implementierung von eigenen Prozessoren. Im JEP selbst werden dazu exemplarisch ein JSON- (s. Listing 9) und SQL-Prozessor gezeigt.
Dabei ist jedoch zu beachten, dass Template-Prozessoren eigentlich nicht für langlaufende Aktionen oder Aktionen mit Seiteneffekten gedacht sind. Diese sollen, geht es nach dem Willen der Autoren, primär Validierungen durchführen und ein Ergebnis zurückgeben, das dem Aufrufenden maximale Flexibilität gibt.
Stream-Gatherers
Bei der Einführung von Streams haben sich die JDK-Maintainer damals bewusst für das API entschieden, das wir heute kennen, nämlich das einer Pipeline. Diese Pipeline besteht aus einer Quelle, welche die Elemente „produziert“, beliebig vielen, oder auch ohne, Zwischenoperationen und zum Schluss einer terminierenden Operation.
Dieses Design erlaubt es uns, eine solche Pipeline lesbar aufzubauen, und ist gleichzeitig effizient, denn die eigentliche Ausführung findet erst mit der terminierenden Operation statt. Außerdem kann eine solche Pipeline sowohl mit unendlichen Streams umgehen, als auch parallel ausgeführt werden. Über die collect
-Methode eines Streams können dabei beliebige, auch nicht vom JDK mitgelieferte, Operationen, sogenannte Collector
s, genutzt werden. Somit ist diese API auch noch gut für eigene Anwendungsfälle erweiterbar.
Im Gegensatz dazu sind allerdings die Zwischenoperationen nicht erweiterbar und wir müssen, zumindest bisher, mit denen auskommen, die uns die StreamsAPI zur Verfügung stellt. Da es hier eine große Auswahl gibt, kommen wir, in der Regel, sehr weit, aber an der einen oder anderen Stelle fehlt dann doch die passende Zwischenoperation.
Aus diesem Grund hat es sich der JEP 461 zur Aufgabe gemacht, für Zwischenoperationen auf einem Stream eine an Collector
angelehnte API zu definieren, damit wir eigene Zwischenoperationen schreiben können, genannt Gatherer
. Ein solcher Gatherer
besteht dabei aus vier Funktionen und wird über die auf Stream
definierte Methode gather
übergeben. Über die Methode initializer
lässt sich ein initialer Zustand für den Gatherer
erzeugen. Dieser State kann anschließend in den weiteren Methoden lesend oder schreibend genutzt werden, um sich Dinge zu merken.
Der in integrator
erzeugte Integrator
ist das Herzstück der API. Dieser implementiert die Methode boolean integrate(A state, T element, Downstream<? Super R> downstream)
. Diese wird jeweils mit dem aktuellen State, dem Element, welches gerade prozessiert wird, und einem Downstream aufgerufen. Der Integrator
kann nun den State und das Element nutzen, um zu entscheiden, ob und wenn ja wie viele Elemente an die nächste Operation, über den Downstream, weitergegeben werden sollen. Natürlich kann dabei auch der State aktualisiert werden. Gibt die Methode false
zurück, wird außerdem der vorherigen Operation signalisiert, dass keine weiteren Elemente mehr prozessiert werden.
Der combiner
ist dazu da, in einem parallelen Stream entstandenen State zu mergen. Erst hierdurch ist es möglich, Operationen im Stream parallel auszuführen, indem die Arbeit erst aufgeteilt und anschließend die Ergebnisse zusammengeführt werden.
Zuletzt wird der finisher
am Ende des Streams aufgerufen. Dieser erlaubt es uns somit, eine finale Aktion auf Basis des finalen Zustands auszuführen.
Durch das Zusammenspiel dieser Methoden lässt sich nun eine Menge neuer Operationen erstellen. Listing 10 zeigt beispielsweise einen Gatherer
max, welcher nur das größte Integer
-Element eines Streams durchlässt. Hierzu wird, anstatt Gatherer
selbst zu implementieren, die Hilfsmethode Gatherer::of
genutzt, um eine Instanz zu erzeugen. Im State merken wir uns dabei das bisher größte Element und ob wir überhaupt schon ein Element gesehen haben. Der Integrator
wird als Greedy markiert, das heißt, es müssen alle Elemente des Streams konsumiert werden. Dieser manipuliert lediglich den State und gibt selbst kein Element an den Downstream weiter. Beim combiner
suchen wir uns aus beiden States das größte Element heraus und verwerfen das andere. Und zu guter Letzt wird im finisher
genau das größte Element an den Downstream weitergegeben, sollte es denn überhaupt ein Element gegeben haben.
Benötigen wir keinen State und ist unser Gatherer
parallelisierbar, können wir auch die einfachere Variante von Gatherer::of
nutzen. Listing 11 nutzt diese, um die bereits auf Stream vorhandenen Methoden filter
, map
und takeWhile
zu implementieren. Dabei ist auch zu sehen, dass anstelle von mehreren Aufrufen von gather
auch die Gatherer
selbst mit andThen
kombiniert werden können.
Innerhalb des JEP 461 werden neben dem API auch bereits die fertigen Gatherer
-Implementierungen fold
, mapConcurrent
, scan
, windowFixed
und windowSliding
mitgebracht.
Pattern Matching für primitive Typen
Durch die drei JEPs 394, 440 und 441 hat das JDK, Schritt für Schritt, Support für, das sogenannte, Pattern Matching eingebaut. Hierdurch können wir an vielen Stellen auf Casts verzichten, und im Falle von record
s besteht sogar die Möglichkeit, über Dekonstruktion direkt an dessen Werte zu gelangen. Listing 12 zeigt beispielhaft die aktuell vorhandenen Möglichkeiten in Verbindung mit switch
, dasselbe funktioniert allerdings auch in Verbindung mit instanceof
.
Allerdings funktioniert das alles bisher nicht für die primitiven Datentypen des JDK. Da diese Unterstützung jedoch für ein vollständiges Pattern Matching benötigt wird, hat sich der JEP 455 diesem Problem angenommen. Mit diesem lässt sich dann auch der in Listing 13 gezeigte Code nutzen.
Obwohl das auf den ersten Blick einfach erscheint, entstehen beim drüber Nachdenken dann doch einige Fragen. Diese entstehen vor allem dadurch, dass in Java primitive Werte an bestimmten Stellen auch in andere Typen automatisch konvertiert werden können. Zudem stellt sich noch die Frage, ob und wenn ja wie bei primitiven Typen und switch
mit einer vollständigen Abdeckung und dem damit einhergehenden Verzicht auf einen default
-Zweig umgegangen werden soll. Listing 14 zeigt die aktuelle Idee des JEP.
Für eine detailliertere Beschreibung, wie mit impliziten Konvertierungen umgegangen wird, empfehle ich einen direkten Blick in den JEP. Dieses Feature wird im Gegensatz zu den vorherigen allerdings frühestens in JDK 23 als Preview-Feature erscheinen, und es wird somit noch bis mindestens nächstes Jahr dauern, bevor es wirklich final erscheinen kann.
With für records
Das letzte Feature, das es eventuell irgendwann ins JDK schaffen wird, ist gleichzeitig auch das spekulativste dieses Artikels. Mit der Einführung und Nutzung von record
s hat auch die Verbreitung von unveränderlichen Instanzen zugenommen. Schließlich sind record
s erzwungenermaßen unveränderlich. Möchten wir einen Wert ändern, müssen wir, über einen Konstruktor, eine neue Instanz erzeugen, bei der alle außer dem zu verändernden Wert identisch sind. Aktuell erfordert das entweder händischen Code oder den Einsatz von Bibliotheken, die mittels Bytecode-Transformation diesen Code generieren.
Eine schon seit 2022 existierende Idee von Brian Götz, dem Java Language Architect von Oracle, ist es deswegen, eine solche Funktionalität mit in die Sprache einzubauen. Das würde uns Code wie in Listing 15 ermöglichen.
Neben der Diskussion auf der Mailingliste gibt es dabei ein noch älteres Dokument aus 2020. Dieses beschreibt dieselbe Idee, enthält jedoch auch noch eine Möglichkeit, wie neben Konstruktoren auch Factory-Methoden genutzt werden könnten, um die neue Instanz zu erzeugen.
Das alles ist jedoch bisher primär eine Idee und es gibt lediglich ein Ticket, aber noch keinen JEP dafür. Die Idee ist allerdings sehr interessant und ich hoffe wirklich darauf, dass sie es, in dieser oder ähnlicher Form, in naher Zukunft ins JDK schafft.