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).

class Parent {

    final int i;

    Parent(int i) {
        this.i = i;
    }
}

class Child extends Parent {

    // Cannot reference 'this' before
    // supertype constructor has been called
    Child() {
        super(this.i);
    }
}
Listing 1: Kompilierungsfehler beim Zugriff auf this

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).

class Child extends Parent {

    Child(int i) {
        super(i);
        if (i != 42) {
            throw new IllegalArgumentException("...");
        }
    }
}
Listing 2: Validierung nach super-Aufruf
class Child extends Parent {

    Child(int i) {
        super(validate(i));
    }

    private static int validate(int i) {
        if (i != 42) {
            throw new IllegalArgumentException("...");
        }
        return i;
    }
}
Listing 3: Hilfsfunktion zur Validierung

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).

record Value() {}

class Parent {

    Parent(Value v1, Value v2) {
        // ...
    }
}

class Child extends Parent {

    Child() {
        this(new Value());
    }

    // wird nur gebraucht, um identischen Wert an super zu übergeben
    private Child(Value v) {
        super(v, v);
    }
}
Listing 4: Hilfskonstruktor für identische Objekte in super

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).

class Key {

    Key(byte[] bytes) {
        // ...
    }
}

class SubKey extends Key {

    SubKey(Certificate certificate) {
        super(bytesOf(certificate));
    }

    private static byte[] bytesOf(Certificate certificate) {
        var publicKey = certificate.getPublicKey();
        if (publicKey == null) {
            throw new IllegalArgumentException("...");
        }
        return switch (publicKey) {
            case RSAKey rsa -> ...
            ...
            default -> ...
        };
    }
}
Listing 5: Hilfsfunktion für komplexe Umwandlung

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 Kon­truktors 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.

class Validation extends Parent {

    Validation(int i) {
        if (i != 42) {
            throw new IllegalArgumentException("...");
        }
        super(i);
    }
}

class IdenticalValue extends Parent {

    IdenticalValue() {
        var v = new Value();
        super(v, v);
    }
}

class SubKey extends Key {

    SubKey(Certificate certificate) {
        var publicKey = certificate.getPublicKey();
        if (publicKey == null) {
            throw new IllegalArgumentException("...");
        }
        var bytes = switch (publicKey) {
            case RSAKey rsa -> ...
            ...
            default -> ...
        };
        super(bytes);
    }
}
Listing 6: Aufrufe vor super

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 Strings. Groß war dementsprechend der Frust, als 2020 die Arbeit an JEP 326 eingestellt und nur der Teil der mehrzeiligen Strings in JEP 355 weitergeführt wurde. Diese Text Blocks stehen uns nun seit JDK 15 mit JEP 378 bereits vollständig zur Verfügung.

var name = "Michael";

// Konkatenierung
var greeting = "Hello, " + name + "!";

// Format String
greeting = "Hello, %s!".formatted(name);

// StringBuilder
greeting = new StringBuilder("Hello, ")
    .append(name)
    .append("!")
    .toString();

// MessageFormat
greeting = MessageFormat.format("Hello, {0}!", name);
Listing 7: Strings und Variablen

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.

var name = "Michael";
String text = STR."""
        Hello \{name}!
        It's \{LocalDateTime.now()}.""";
System.out.println(text);
Listing 8: Nutzung des STR-Prozessors mit Text Block

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 Strings 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.

// Simple JSON Template Prozessor Implementierung
StringTemplate.Processor<JSONObject, RuntimeException> JSON =
        stringTemplate -> new JSONObject(stringTemplate.interpolate());

JSONObject json = JSON."""
    {
        "name": \{name}
    }""";

System.out.println(json.getString("name"));
Listing 9: Prototypischer JSON-Prozessor

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 Collectors, 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 Streams­API 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.

static Gatherer<Integer, ?, Integer> max() {

    class State {
        Integer value;
        boolean hasValue;
    }

    return Gatherer.of(
        State::new,
        Gatherer.Integrator.ofGreedy((state, element, _) -> {
            if (!state.hasValue) {
                state.value = element;
                state.hasValue = true;
            } else {
                state.value = Math.max(state.value, element);
            }
            return true;
        }),
        (left, right) -> {
            if (!left.hasValue) {
                return right;
            } else if (!right.hasValue) {
                return left;
            } else {
                left.value = Math.max(left.value, right.value);
                return left;
            }
        },
        (state, downstream) -> {
            if (state.hasValue) {
                downstream.push(state.value);
            }
        });
}

Stream.of(2, 1, 5, 3)
    .gather(max())
    .findFirst()
    .ifPresent(System.out::println); // -> 5
Listing 10: Eigene Gatherer-Implementierung für max

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.

static <T> Gatherer<T, Void, T> filter(Predicate<T> predicate) {
    return Gatherer.of((_, element, downstream) -> {
        if (!predicate.test(element)) {
            return downstream.isRejecting();
        }
        return downstream.push(element);
    });
}

static <T, R> Gatherer<T, Void, R> map(Function<T, R> function) {
    return Gatherer.of((_, element, downstream) ->
        downstream.push(function.apply(element)));
}

static <T> Gatherer<T, Void, T> takeWhile(Predicate<T> predicate) {
    return Gatherer.ofSequential((_, element, downstream) -> {
        if (!predicate.test(element)) {
            return false;
        }
        return downstream.push(element);
    });
}

Stream.of(2, 1, 5, 3)
    .gather(takeWhile(element -> element < 4))
    .gather(
        map((Integer element) -> element * 2)
        .andThen(filter(element -> element > 2)))
    .forEach(System.out::println); // -> 4
Listing 11: Eigene Gatherer mit Gatherer::of implementieren

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 records 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.

record Name(Object value) {}

Object o = new Name("Michael");

var result = switch (o) {
    case String s -> STR."Found a string: \{s}";
    case Integer i when i > 5 -> STR."Found int > 5: \{i}";
    case Name(Integer i) -> STR."Found a name with number: \{i}";
    case Name(String v) -> STR."Found a name of: \{v}";
    default -> "No match found";
};

System.out.println(result);
Listing 12: Pattern Matching in Java

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.

record Name(Object value) {}

Object o = new Name("Michael");

var result = switch (o) {
    case byte b -> STR."Found a byte of: \{b}";
    case Name(int i) -> STR."Found a name with numer: \{i}";
    default -> "No match found";
};

System.out.println(result);
Listing 13: Pattern Matching mit primitiven Typen

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.

record JsonNumber(double value) {}

var o = new JsonNumber(42);
if (o instanceof JsonNumber(int v)) {
    // matched, weil 42 ein int ist
}

var i = 129;
if (i instance of byte b) {
    // matched nicht, weil 129 zu groß für byte ist
}

byte b = 42; switch(b) {
    case int integer -> // ...
    // kein default notwendig, weil jedes byte in int passt
}
Listing 14: Konvertierungen bei Pattern Matching für primitive Typen

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 records hat auch die Verbreitung von unveränderlichen Instanzen zugenommen. Schließlich sind records 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.

record Point(int x, int y) {}

var p = new Point(42, 42);
var pp = p with {
    // Gedanklich passiert hier:
    // var x = this.x;
    // var y = this.y;
    y = 0;
    // return new Point(x, y);
} // -> Point(42, 0)
Listing 15: Beispielhafte Nutzung von with

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.

Conclusion

In diesem Artikel haben wir fünf Features kennengelernt, die es in zukünftige JDK-Releases schaffen können. Für Statements vor super, String-Templates und Gatherers ist das sogar in naher Zukunft möglich, da diese bereits als Preview Features in JDK 22 erscheinen werden und somit schon in den Hauptzweig des JDK integriert sind. Alle sind meiner Meinung nach eine konsequente und gute Fortführung des begonnenen Wegs.

Pattern Matching für primitive Typen ist, Stand jetzt, auch bereits für JDK 23 als Preview-Feature geplant und hat somit eine hohe Chance, bis zum nächsten Long-term-Release, vermutlich JDK 25, auch komplett fertig zu werden. Auch dieses Feature ist ein konsequenter nächster Schritt, um den ganzen Bereich rund um Pattern Matching zu vervollständigen.

Die Idee, mittels eines neuen Keywords with das Ändern von einzelnen Werten eines records zu unterstützen, steht im Gegensatz dazu noch vollkommen in den Sternen. Es wäre sicherlich wünschenswert, aber hier müssen wir warten, was die Zukunft bringt.