Schlagwort: Java/Amber

  • Withers in Java: „Derived Record Creation Expressions“

    Withers in Java: „Derived Record Creation Expressions“

    Derived Record Creation Expressions (oder kurz: Withers) sind eine prägnante Schreibweise, um von Java-Records abgeleitete Records zu erzeugen, die sich in einem oder mehreren (oder auch keinen) Feldern von dem ursprünglichen Record unterscheiden.

    In diesem Artikel erfährst du:

    • Warum brauchen wir Derived Record Creation Expressions (Withers)?
    • Was sind explizite Wither-Methoden?
    • Wie funktionieren Derived Record Creation Expressions?
    • Welche Einschränkungen gelten bei der Verwendung von Derived Record Creation Expressions?

    Derived Record Creation Expressions werden in einer der kommenden Java-Versionen als Preview-Feature veröffentlicht. Welche Java-Version das sein wird, ist aktuell noch nicht klar, da sich das entsprechende JDK Enhancement Proposal 468 noch im Candidate-Status befindet.

    Warum brauchen wir Withers?

    Java Records sind unveränderbar – und das ist gut so. Denn Unveränderbarkeit (Immutability) macht Code verständlicher, verlässlicher und sicherer. Es gibt aber immer wieder Use-Cases, in denen wir von einem bestehenden Record einen neuen Record ableiten wollen, der sich von dem bestehenden Record in nur einem oder einigen Feldern unterscheidet.

    Das möchte ich dir an einem Beispiel zeigen – und zwar an folgenden Record:

    public record Point3D(double x, double y, double z) { }Code-Sprache: Java (java)

    Nehmen wir an, wir haben einen bestehenden Point3D point und wollen nun nur die Z-Koordinate um 10,0 erhöhen. Dann müssen wir wie folgt einen neuen Record erzeugen:

    Point3D pointNew = new Point3D(point.x(), point.y(), point.z() + 10.0);Code-Sprache: Java (java)

    Wir müssen also aus dem bestehenden Record alle Felder auslesen und dann für den neuen Record wiederum alle Felder angeben – die geänderten und auch die nicht geänderten. Zum einen ist das aufwändig, und zum anderen kann das – besonders bei komplexeren Records – schnell fehleranfällig werden.

    Wäre es nicht schön, wenn wir nur diejenigen Felder angeben müssten, die sich geändert haben?

    Bisherige Lösung: Explizite Wither-Methoden

    Eine Möglichkeit, um die Arbeit für die „Benutzer:innen“ des Records zu erleichtern, ist die Bereitstellung sogenannter Wither-Methoden. Das sind Methoden innerhalb des Records, die einen abgeleiteten Record mit einem (oder mehreren) geänderten Feldern zurückliefern.

    In unserem Point3D könnten wir beispielsweise folgende Wither-Methoden zur Verfügung stellen:

    public record Point3D(double x, double y, double z) {
      public Point3D withX(double newX) {
        return new Point3D(newX, y, z);
      }
    
      public Point3D withY(double newY) {
        return new Point3D(x, newY, z);
      }
    
      public Point3D withZ(double newZ) {
        return new Point3D(x, y, newZ);
      }
    }Code-Sprache: Java (java)

    Das ermöglicht es uns nun, die Z-Koordinate wie folgt zu ändern:

    Point3D pointNew = point.withZ(point.z() + 10.0);Code-Sprache: Java (java)

    Nachteile von expliziten Wither-Methoden

    Explizite Wither-Methoden haben zwei Nachteile.

    Der erste ist offensichtlich:

    Wir müssen eine Menge Boilerplate-Code implementieren, und das stellt einen erhöhten Implementierungs- und Wartungsaufwand dar.

    Der zweite Nachteil:

    Falls es semantische Einschränkungen über die Kombination mehrerer Felder eines Records gibt, können diese Felder ggf. nicht einzeln geändert werden. Nehmen wir einmal an, die Entfernung unseres Point3D vom Ausgangspunkt des Koordinatensystems darf nicht größer als 100,0 sein.

    Das könnten wir relativ einfach mit folgendem kompakten Konstruktor sicherstellen:

    public record Point3D(double x, double y, double z) {
      public Point3D {
        double distance = Math.sqrt(x * x + y * y + z * z);
        if (distance > 100.0) {
          throw new IllegalArgumentException("Point lies outside the allowed distance " +
                                             "of 100 units from origin (0, 0, 0).");
        }
    
      // . . .
    }Code-Sprache: Java (java)

    Nehmen wir nun an, wir haben einen Punkt mit den Koordinaten (0, 80, 10) und wollen dessen X- und Y-Koordinaten vertauschen:

    Point3D point = new Point3D(0, 80, 10);
    Point3D pointNew = point
        .withX(point.y())
        .withY(point.x());Code-Sprache: Java (java)

    Leider führt das zu einer IllegalArgumentException, da im ersten Schritt – also beim Aufruf von withX(point.y()) – versucht wird, einen Punkt mit den Koordinaten (80, 80, 10) zu erstellen.

    Wir müssten also in Point3D eine weitere Wither-Methode zur Verfügung stellen, die sowohl X- als auch Y-Koordinate setzt (eine sogenannte Compound Wither Method):

    public record Point3D(double x, double y, double z) {
      // . . .
    
      public Point3D withXY(double newX, double newY) {
        return new Point3D(newX, newY, z);
      }
    
      // . . .
    }Code-Sprache: Java (java)

    Folgender Aufruf wäre dann erfolgreich:

    Point3D point = new Point3D(0, 80, 10);
    Point3D pointNew = point.withXY(point.y(), point.x());Code-Sprache: Java (java)

    Falls wir möglicherweise andere Koordinatenpaare tauschen wollen, bräuchten wir entsprechend weitere Wither-Methoden. Bei komplexeren Records wird das schnell unübersichtlich und fehleranfällig.

    Die Lösung: Derived Record Creation Expressions

    Wäre es nicht schöner, wenn wir den ganzen Boilerplate-Code vermeiden und uns darauf konzentrieren könnten, anzugeben, welche Record-Komponenten sich auf welche Weise ändern sollen?

    JEP 468 wird mit Derived Record Creation Expressions genau das möglich machen!

    Beachte:
    Derived Record Creation Expressions sind aktuell noch in keiner Java-Version verfügbar, d. h. du musst dich noch etwas gedulden, bis du den folgenden Code ausprobieren kannst.

    Zunächst einmal entfernen wir alle expliziten Wither-Methoden aus unserem Record – der sieht nun wieder wie ganz zu Beginn aus:

    public record Point3D(double x, double y, double z) { }Code-Sprache: Java (java)

    Um jetzt z. B. die Z-Koordinate um 10,0 zu erhöhen, können wir ganz einfach das neue with-Keyword verwenden:

    Point3D pointNew = point with {
      z += 10;
    };Code-Sprache: Java (java)

    Das bedeutet: „Erzeuge einen neuen Record mit z um 10,0 erhöht“. Dieser Code ist deutlich lesbarer und wartbarer als alles vorherige, da er sich nur auf das fokussiert, was sich ändert, und keinen weiteren Boilerplate-Code erfordert.

    Noch ein paar weitere Beispiele…

    x und y vertauschen könnten wir wie folgt:

    Point3D pointNew = point with {
      double helper = x;
      x = y;
      y = helper;
    };Code-Sprache: Java (java)

    Alle Koordinaten mit 2,0 multiplizieren könnten wir so:

    Point3D pointNew = point with {
      x *= 2.0;
      y *= 2.0;
      z *= 2.0;
    };Code-Sprache: Java (java)

    Wir können auch das with-Keyword mehrfach aufrufen – die Multiplikation aller Koordinaten mit 2,0 wäre auch wie folgt möglich:

    Point3D pointNew = point 
        with { x *= 2.0 }
        with { y *= 2.0 }
        with { z *= 2.0 };Code-Sprache: Java (java)

    Aber Achtung: Die letzten zwei Beispiele sind nicht identisch! Im ersten wird ein neuer Record erzeugt. Im zweiten werden drei neue Records erzeugt – bei jedem with-Aufruf einer. Das ist zum einen mehr Aufwand, und zum anderen könnten dabei Validierungen fehlschlagen, die sich auf die Kombination mehrerer Felder beziehen.

    Mehr dazu im nächsten Abschnitt.

    Wie genau funktioniert Derived Record Creation?

    Ich werde die genaue Funktionsweise von Derived Record Creation Expressions am ersten with-Beispiel von oben erklären – hier ist es noch einmal:

    Point3D pointNew = point with {
      z += 10;
    };Code-Sprache: Java (java)

    Eine Derived Record Creation Expression besteht aus drei Teilen:

    1. der Origin Expression (dem „Ursprungsausdruck“) – im Beispiel: point
    2. dem Keyword with
    3. dem Transformation Block – im Beispiel: { z += 10; }

    Innerhalb des Transformationsblocks werden alle Felder des ursprünglichen Records durch Aufruf von dessen Accessor-Methoden in lokalen, änderbaren Variablen bereitgestellt. Es ist so, als würde vor der Ausführung des Transformationsblocks folgender Code ausgeführt werden:

    double x = x();
    double y = y();
    double z = z();Code-Sprache: Java (java)

    Es wird also nicht direkt auf die Felder zugegriffen: Sollten die Accessor-Methoden überschrieben worden sein und weitere Logik enthalten, so wird diese ausgeführt.

    Dann wird der Transformationsblock ausgeführt – im Beispiel also die lokale Variable z um 10,0 erhöht.

    Am Ende des Transformationsblocks wird ein neuer Record anhand der (möglicherweise veränderten) lokalen Variablen erzeugt, also so:

    new Point3D(x, y, z)Code-Sprache: Java (java)

    Dabei wird auch tatsächlich der Record-Konstruktor aufgerufen, um dort möglicherweise vorhandene Validierungen auszuführen.

    Damit verstehst du auch, warum die letzten beiden Beispiele des vorherigen Abschnitts unterschiedlich sind: Beim einmaligen Aufruf von with wird der Konstruktor einmal aufgerufen – mit allen Änderungen. Beim mehrmaligen Aufruf von with wird der Konstruktor mehrfach aufgerufen – jeweils mit nur einer Änderung. So würden Validierungen, die sich auf die Kombination mehrerer Parameter beziehen, bei einem ungültigen Zwischenzustand fehlschlagen.

    Hier noch einige Hinweise:

    • Sollte der ursprüngliche Ausdruck – im Beispiel also pointnull sein, kommt es zu einer NullPointerException.
    • Der Transformationsblock darf auch leer sein – in dem Fall wird eine unveränderte Kopie des ursprünglichen Records zurückgegeben.
    • Falls außerhalb der Derived Record Creation Expression eine Variable existiert mit demselben Namen wie ein Feld des Records, dann ist diese innerhalb des Transformationsblocks nicht sichtbar (sie ist „geshadowed“).

    Hier ist der dritte Punkt nochmal an einem Beispiel erklärt:

    double x = 50;
    
    Point3D point = new Point3D(10, 20, 30);
    Point3D pointNew = point with {
      // x is 10 here, not 50.
      // The "outer" x is not visible here.
      x = 20;
    }
    
    // x is 50 here, not 20.
    // The "inner" x is not visible here.Code-Sprache: Java (java)

    Verschachtelte Derived Record Creation

    Bei ineinander verschachtelten Records können auch die with-Ausdrücke verschachtelt werden. Der folgende Record definiert eine Linie im dreidimensionalen Raum:

    public record Line3D(Point3D start, Point3D end) { }Code-Sprache: Java (java)

    Legen wir einmal eine solche Linie an:

    Line3D line = new Line3D(new Point3D(1, 2, 3), new Point3D(4, 5, 6));Code-Sprache: Java (java)

    Dann könnten wir jetzt z. B. den Endpunkt wie folgt ändern (noch nicht verschaltelt):

    line = line with { 
      end = new Point3D(4, 5, 10); 
    };Code-Sprache: Java (java)

    Der neue Endpunkt unterscheidet sich nur in der Z-Koordinate vom ursprünglichen Endpunkt. Das können wir auch wie folgt – mit verschachtelten with-Ausdrücken – prägnanter schreiben:

    line = line with { 
      end = end with { z = 10; }
    };Code-Sprache: Java (java)

    Innere Derived Record Creation

    Derived Record Creation Expressions dürfen auch innerhalb des Records eingesetzt werden. Beispielsweise könnten wir für den Point3D-Record eine Skalierungsmethode anbieten:

    public record Point3D(double x, double y, double z) {
      // . . .
    
      public Point3D scale(double factor) {
        return this with {
          x *= factor;
          y *= factor;
          z *= factor;
        };    
      }
    }Code-Sprache: Java (java)

    Einschränkungen von Derived Record Creation Expressions

    Für Derived Record Creation Expressions gelten folgende Einschränkungen:

    • Der Transformationsblock darf keinen return-Ausdruck enthalten.
    • Der Transformationsblock darf keinen yield-, break– oder continue-Ausdruck enthalten, dessen Ziel außerhalb des Transformationsblocks liegt.

    Den zweiten Punkt erkläre ich auch noch einmal an einem Beispiel.

    Erlaubt ist z. B. der folgende (zugegebenermaßen stark konstruierte) Code, in dem die Ziele von yield und break innerhalb des Transformationsblocks liegen:

    Point3D point = new Point3D(10, 20, 30);
    Point3D pointNew = point with {
      y = switch (x) {
        case double d when d < 0.0 -> -1;
        case double d when d > 0.0 -> {
          double newValue = d;
          for (int i = 0; i < 10; i++) {
            newValue *= 2.0;
            if (newValue > 100.0) break;  // allowed
          }
          yield newValue;  // allowed
        }
        default -> 0;
      };
    }Code-Sprache: Java (java)

    Folgender (ebenso stark konstruierter) Code hingegen wäre nicht erlaubt, da hier das Ziel von break die for-Schleife außerhalb der Derived Record Creation Expression wäre:

    Point3D point = new Point3D(10, 20, 30);
    for (int i = 0; i < 10; i++) {
      point = point with {
        if (x > 0.0) {
          x -= 1.0;
        } else {
          break;  // not allowed
        }
      };
    }Code-Sprache: Java (java)

    Fazit

    Derived Record Creation Expressions sind eine prägnante Syntax, um abgeleitete Records zu erstellen und dabei nur die geänderten Felder zu spezifizieren – ohne dafür selbst explizite, wartungsintensive Wither-Methoden schreiben zu müssen.

    Aktuell ist leider noch nicht abzusehen, wann Derived Record Creation verfügbar sein wird – sobald sich das ändert, wirst du es hier frühzeitig erfahren.

  • Primitive Typen in Patterns, instanceof und switch

    Primitive Typen in Patterns, instanceof und switch

    In diesem Artikel erfährst du:

    • Was ist Pattern Matching?
    • Wie können wir primitive Typen im Pattern Matching mit instanceof verwenden?
    • Wie können wir primitive Typen im Pattern Matching mit switch verwenden?
    • Was ist der Unterschied zwischen Pattern Matching mit primitiven Typen und mit Objekt-Typen („Referenztypen“)?
    • Was sind dominierende und dominierte primitive Typen?

    Wir beginnen mit einem kurzen Auffrischer über Pattern Matching in Java. Wenn du bereits mit Pattern Matching im Allgemeinen vertraut bist, überspringe gerne das Einführungskapitel und gehe direkt zum zweiten Kapitel, Neuerungen in Java 23.

    Was ist Pattern Matching?

    Pattern Matching in Java wurde erstmals in Java 16 mit Pattern Matching for instanceof als finales Feature veröffentlicht und in Java 21 um Pattern Matching for switch erweitert.

    Das folgende Code-Beispiel benutzt Pattern Matching, um herauszufinden, ob die Variable obj vom Typ String ist, und wenn ja, diesen in Großbuchstaben konvertiert auszugeben:

    Object obj = . . .
    if (obj instanceof String s) {
      System.out.println(s.toUpperCase());
    }Code-Sprache: Java (java)

    Das Pattern ist in diesem Beispiel String s. Der Code prüft zunächst, ob die Object-Variable obj auf dieses Pattern „matcht“. Das tut sie, wenn obj vom Typ String ist. Wenn das der Fall ist, wird der Inhalt von obj in der String-Variablen s zur Verfügung gestellt, in Großbuchstaben konvertiert und ausgegeben.

    Das folgende Beispiel ist etwas komplexer und benutzt switch statt instanceof, um die Variable obj mit verschiedenen Patterns abzugleichen und um je nach Typ verschiedene Aktionen durchzuführen:

    switch (obj) {
      case String s when s.length() >= 5 -> System.out.println(s.toUpperCase());
      case Integer i                     -> System.out.println(i * i);
      case Number n                      -> System.out.println(n + " is a number");
      case null, default                 -> System.out.println(obj);
    }Code-Sprache: Java (java)

    Das erste Pattern, String s when s.length() >= 5, ist ein sogenanntes „Guarded Pattern“, ein Pattern mit einer Einschränkung, und s.length() >= 5 ist der „Guard“. Die Variable obj matcht dieses Pattern dann, wenn sie vom Typ String ist und dieser String mindestens fünf Zeichen lang ist.

    Das zweite Pattern, Integer i, matcht, wenn obj vom Typ Integer ist.

    Das dritte Pattern, Number n, matcht, wenn obj von einem von der abstrakten Klasse Number abgeleiteten Typ ist, also z. B. Long oder Double, aber auch AtomicInteger oder BigDecimal. Das Pattern würde auch auf Variablen vom Typ Integer matchen, aber die werden bereits in der Zeile zuvor durch das Pattern Integer i „abgefangen“.

    Neuerungen in Java 23

    Bisher funktioniert Pattern Matching nur mit Referenztypen, also z. B. String und Integer, nicht aber mit primitiven Typen wie int, long und double.

    In Java 23 wurde durch JDK Enhancement Proposal 455 das Preview-Feature „Primitive Types in Patterns, instanceof, and switch“ vorgestellt, in Java 24 wird das Feature durch JEP 488 ohne Änderungen wiedervorgelegt.

    Wenn du dieses Feature mit --enable-preview aktivierst, kannst du:

    1. primitive Typen im Pattern Matching verwenden,
    2. in switch Konstanten der Typen long, float, double und boolean verwenden.

    Die erste Änderung beschreibe ich im Detail im kommenden Abschnitt, Primitive Typen im Pattern Matching. Die zweite Änderung erkläre ich schnell an dieser Stelle:

    Seit jeher können wir mit switch eine Variable mit Konstanten vergleichen, z. B. wie folgt:

    int code = . . .
    switch (code) {
      case 200 -> System.out.println("OK");
      case 400 -> System.out.println("Bad Request");
      case 404 -> System.out.println("Not Found");
      . . .
    }Code-Sprache: Java (java)

    Das funktioniert allerdings bisher nur mit den Typen byte, short, char und int. Wenn du in der ersten Zeile int beispielsweise durch long ersetzt, bekommst du einen Compilerfehler:

    error: selector type long is not allowedCode-Sprache: Klartext (plaintext)

    Wenn du in Java 23 oder 24 das Feature „Primitive Types in Patterns, instanceof, and switch“ mit --enable-preview aktivierst, verschwindet diese Fehlermeldung. Du darfst dann jeden beliebigen primitiven Typ in switch verwenden.

    Primitive Typen im Pattern Matching

    Ein Objekt matcht ein Pattern, wenn das Objekt einer Variable vom Typen des Pattern zugewiesen kann. Wie du im vorherigen Abschnitt gesehen hast, matcht z. B. ein Integer-Objekt auf das Pattern Integer i – es würde aber auch auf die Pattern Number n oder Object o – und sogar auf Comparable c oder Serializable s matchen, denn Integer erbt von Number und implementiert u. a. Comparable, und Number erbt von Object und implementiert Serializable:

    Klassendiagramm: Integer erweitert Number, Number erweitert Object

    Bei primitiven Typen gibt es allerdings keine Vererbung. Daher funktioniert Pattern Matching bei primitiven Typen nicht genau wie bei Referenztypen – aber ähnlich.

    Im folgenden Abschnitt erkläre ich dir zunächst, wie primitive Typen im Pattern Matching mit instanceof verwendet werden. Im darauf folgenden Abschnitt zeige ich dir dann primitive Typen im Pattern Matching mit switch.

    Primitive Typ-Pattern mit instanceof

    Nicht erschrecken: ich beginne mit einer mathematisch klingenden Formulierung, erkläre dann aber sofort an einem Beispiel, was gemeint ist.

    Sei a eine Variable eines primitiven Typen (also byte, short, int, long, float, double, char oder boolean) und B einer eben dieser primitiven Typen. Dann ergibt a instanceof B genau dann true, wenn der präzise Wert von a auch in einer Variablen vom Typ B gespeichert werden kann.

    Beispiel 1

    Hier kommt schon das Beispiel:

    int value = . . .
    if (value instanceof byte b) {
      System.out.println("b = " + b);
    }Code-Sprache: Java (java)

    Der Code ist wie folgt zu lesen: Wenn der Wert der Variablen value auch in einer byte-Variablen gespeichert werden kann, dann weise der byte-Variablen b diesen Wert zu und gebe ihn aus.

    Für value = 5 wäre das z. B. der Fall, für value = 1000 hingegen nicht, da eine Variable vom Typ byte lediglich Werte von -128 bis 127 speichern kann.

    Beispiel 2

    Hier ein zweites Beispiel:

    double value = . . .
    if (value instanceof float f) {
      System.out.println("f = " + f);
    }Code-Sprache: Java (java)

    Hier wird geprüft, ob das double value auch als float dargestellt werden kann. Das wäre z. B. für value = 1.5 der Fall, für value = Math.PI aber nicht, da float nicht präzise genug ist, um alle Stellen der double-Konstante Math.PI aufzunehmen.

    Beispiel 3

    Weisen wir value mal einen konkreten Wert zu und prüfen diesen gegen alle numerischen primitiven Typen (ein Vergleich numerischer Typen mit boolean ist nicht erlaubt und führt zu einem Comilerfehler).

    Hier ist, anstelle eines Code-Schnipsels, ein vollständiges, ausführbares Demo-Programm:

    void main() {
      int value = 65;
      if (value instanceof byte b)   System.out.println(value + " instanceof byte:   " + b);
      if (value instanceof short s)  System.out.println(value + " instanceof short:  " + s);
      if (value instanceof int i)    System.out.println(value + " instanceof int:    " + i);
      if (value instanceof long l)   System.out.println(value + " instanceof long:   " + l);
      if (value instanceof float f)  System.out.println(value + " instanceof float:  " + f);
      if (value instanceof double d) System.out.println(value + " instanceof double: " + d);
      if (value instanceof char c)   System.out.println(value + " instanceof char:   " + c);
    }Code-Sprache: Java (java)

    Wenn du das Programm z. B. in der Datei Test.java speicherst, dann kannst du es in Java 23 und 24 wie folgt starten:

    java --enable-preview Test.javaCode-Sprache: Klartext (plaintext)

    Du siehst dann folgende Ausgabe:

    65 instanceof byte:   65
    65 instanceof short:  65
    65 instanceof int:    65
    65 instanceof long:   65
    65 instanceof float:  65.0
    65 instanceof double: 65.0
    65 instanceof char:   ACode-Sprache: Klartext (plaintext)

    Der Wert 65 kann also in Variablen aller anderen primitiven Typen (außer boolean) gespeichert werden. Du siehst hier sehr schön, dass dieser Wert als float und double mit einer Nachkommastelle dargestellt wird und als char als das Zeichen ‚A‘ (dessen ASCII-Code die 65 ist).

    Beispiel 4

    Wenn wir value auf 100.000 ersetzen, kommt folgende Ausgabe heraus:

    100000 instanceof int:    100000
    100000 instanceof long:   100000
    100000 instanceof float:  100000.0
    100000 instanceof double: 100000.0Code-Sprache: Klartext (plaintext)

    Der Wert 100.000 kann also in Variablen vom Typ int, long, float und double gespeichert werden, nicht aber in Variablen vom Typ byte, short und char. Deren Zahlenraum geht nur bis 127, 32.767 und 65.535.

    Beispiel 5

    Interessant wird es für value = 16_777_217:

    16777217 instanceof int:    16777217
    16777217 instanceof long:   16777217
    16777217 instanceof double: 1.6777217E7Code-Sprache: Klartext (plaintext)

    Die Zahl 16.777.217 kann also in int, long und double gespeichert werden, nicht aber in float?

    Das ist tatsächlich der Fall! Lass einmal folgenden Code laufen:

    float f = 16_777_217;
    System.out.printf("f = %.1f%n", f);Code-Sprache: Java (java)

    Das Ergebnis ist unerwartet:

    f = 16777216.0Code-Sprache: Klartext (plaintext)

    Die ausgegebene Zahl endet auf 6, nicht auf 7!

    Das liegt daran, dass der Gleitkommatyp float eine begrenzte Genauigkeit hat und zwar beispielsweise 16.777.216, 16.777.218 und 16.777.220 speichern kann, nicht aber die dazwischen liegenden Werte 16.777.217 und 16.777.219.

    Beispiel 6

    Im folgenden Beispiel ist value eine Gleitkommazahl vom Typ float:

    void main() {
      float value = 3.5f;
      if (value instanceof byte b)   System.out.println(value + " instanceof byte:   " + b);
      if (value instanceof short s)  System.out.println(value + " instanceof short:  " + s);
      if (value instanceof int i)    System.out.println(value + " instanceof int:    " + i);
      if (value instanceof long l)   System.out.println(value + " instanceof long:   " + l);
      if (value instanceof float f)  System.out.println(value + " instanceof float:  " + f);
      if (value instanceof double d) System.out.println(value + " instanceof double: " + d);
      if (value instanceof char c)   System.out.println(value + " instanceof char:   " + c);
    }Code-Sprache: Java (java)

    Jetzt gibt das Programm folgendes aus:

    3.5 instanceof float:  3.5
    3.5 instanceof double: 3.5Code-Sprache: Klartext (plaintext)

    Klar, denn eine Zahl mit Nachkommastellen ist natürlich nur mit float und double darstellbar.

    Beispiel 7

    Wenn wir value aber auf 100000.0f setzen, sieht das Ergebnis wie folgt aus:

    100000.0 instanceof int:    100000
    100000.0 instanceof long:   100000
    100000.0 instanceof float:  100000.0
    100000.0 instanceof double: 100000.0Code-Sprache: Klartext (plaintext)

    Die Gleitkommazahl 100.000,0 kann, da sie keine Nachkommastellen hat, auch in einem int oder einem long gespeichert werden.

    Pattern Matching mit boolean

    boolean darf übrigens nur mit boolean vergleichen werden. Jeder Vergleich von boolean mit einem anderen Typ oder einem anderen Typ mit boolean führt zu einem „incompatible types“ Compilerfehler.

    Viel bringt uns Pattern Matching mit boolean ohnehin nicht, denn ein Pattern-Abgleich einer booleschen Variablen mit dem Typ boolean ergibt immer true.

    Primitive Typ-Pattern mit instanceof und &&

    Genau wie bei Referenztypen darfst du auch bei primitiven Typen direkt im instanceof-Check mit && weitere Prüfungen anschließen. Der folgende Code z. B. gibt nur positive byte-Werte (also 1 bis 127) aus:

    int a = . . .
    if (a instanceof byte b && b > 0) {
      System.out.println("b = " + b);
    }Code-Sprache: Java (java)

    Primitive Typ-Pattern mit switch

    Wir können primitive Pattern nicht nur in instanceof einsetzen, sondern auch in switch:

    void main() {
      double value = 100000.0;
      switch (value) {
        case byte   b -> System.out.println(value + " instanceof byte:   " + b);
        case short  s -> System.out.println(value + " instanceof short:  " + s);
        case char   c -> System.out.println(value + " instanceof char:   " + c);
        case int    i -> System.out.println(value + " instanceof int:    " + i);
        case long   l -> System.out.println(value + " instanceof long:   " + l);
        case float  f -> System.out.println(value + " instanceof float:  " + f);
        case double d -> System.out.println(value + " instanceof double: " + d);
      }
    }Code-Sprache: Java (java)

    Das Programm führt zu folgender Ausgabe:

    100000.0 instanceof int:    100000Code-Sprache: Klartext (plaintext)

    Wir sehen hier nicht alle matchenden Pattern, sondern nur das erste, da durch switch ja immer nur ein einziger Programmpfad ausgeführt wird.

    Hier ein paar Beispiele für value zusammen mit dem Typ des ersten matchenden Patterns:

    valueErster matchender TypZahlenraum des matchenden Typs
    0byte-128 bis 127
    10.000short-32.768 bis 32.767
    50.000char0 bis 65.535
    1.000.000int-2.147.483.648 bis 2.147.483.647
    1.000.000.000.000longca. minus bis plus 9 Trillionen
    0.125floatFließkommazahlen mit einfacher Genauigkeit
    0.126doubleFließkommazahlen mit doppelter Genauigkeit

    Primitive Typ-Pattern mit switch und when („Guarded Pattern“)

    Auch bei primitiven Typ-Pattern in switch können wir „guards” verwenden, also das Pattern mit when mit einem booleschen Ausdruck versehen. Das kann z. B. dann hilfreich sein, wenn wir nach Zahlenbereichen gruppieren wollen, wie z. B. nach HTTP-Statuscodes.

    Hier ein Beispiel, das bisher nur mit einer if-else-Kette möglich war:

    private String getHttpStatusMessage(int code) {
      if (code == 200) return "OK";
      else if (code == 400) return "Bad request";
      else if (code == 404) return "Not found";
      else if (code == 500) return "Internal server error";
      else if (code > 100 && code < 200) return "Informational";
      else if (code > 200 && code < 300) return "Success";
      else if (code > 302 && code < 400) return "Redirection";
      else if (code > 400 && code < 500) return "Client error";
      else if (code > 502 && code < 600) return "Server error";
      else return "Unknown code";
    }Code-Sprache: Java (java)

    Diese Methode können wir in Zukunft – meiner Meinung nach deutlich übersichtlicher – mit einem switch wie folgt schreiben:

    private String getHttpStatusMessage(int code) {
      return switch (code) {
        case 200 -> "OK";
        case 400 -> "Bad request";
        case 404 -> "Not found";
        case 500 -> "Internal server error";
    
        case int i when i > 100 && i < 200 -> "Informational";
        case int i when i > 200 && i < 300 -> "Success";
        case int i when i > 302 && i < 400 -> "Redirection";
        case int i when i > 400 && i < 500 -> "Client error";
        case int i when i > 502 && i < 600 -> "Server error";
    
        default -> "Unknown code";
      };
    }Code-Sprache: Java (java)

    Dominierende und dominierte primitive Typen

    Bei switch mit primitiven Typen müssen wir – genau wie bei Objekttypen – das Prinzip der dominierenden und dominierten Typen beachten.

    Ein dominierender Typ ist einer, der alle Werte eines dominierten Typs darstellen kann.

    Z. B. wird byte von int dominiert, da jedes byte auch als int dargestellt werden kann. Schau dir einmal den folgenden Code an.

    In den folgenden Beispielen habe die in Java 22 finalisierte unbenannte Variable _ (den Unterstrich) verwendet.

    double value = . . .
    switch (value) {
      case int    _ -> System.out.println(value + " instanceof int");    // dominating type
      case byte   _ -> System.out.println(value + " instanceof byte");   // dominated type
      case double _ -> System.out.println(value + " instanceof double");
    }Code-Sprache: Java (java)

    Das case byte-Label würde in diesem Fall niemals matchen, da jedes byte auch ein int ist und somit bereits vom case int-Label abgefangen werden würde.

    Wenn du versuchen würdest, diesen Code zu compilieren, würde das zu folgendem Compilerfehler führen:

     error: this case label is dominated by a preceding case label
        case byte   _ -> System.out.println(value + " instanceof byte");
             ^Code-Sprache: Klartext (plaintext)

    Generell gilt: Ein dominierter Typ muss immer vor einem dominierenden Typ aufgeführt sein. Folgendes ist also OK:

    double value = . . .
    switch (value) {
      case byte   _ -> System.out.println(value + " instanceof byte");   // dominated type
      case int    _ -> System.out.println(value + " instanceof int");    // dominating type
      case double _ -> System.out.println(value + " instanceof double");
    }Code-Sprache: Java (java)

    Vollständigkeitsprüfung bei switch

    Bei allen neuen (d. h. bei allen seit Java 21 hinzugekommenen) switch-Features gilt: der switch muss vollständig sein, es muss also für jeden möglichen Wert des Selektor-Ausdrucks (im Beispiel die Variable value) ein matchendes case-Label existieren.

    Deshalb enthielten die vorherigen Beispiele auch ein case double-Label. Folgendes wäre nicht erlaubt:

    double value = . . .
    switch (value) {
      case byte   _ -> System.out.println(value + " instanceof byte");
      case int    _ -> System.out.println(value + " instanceof int");
    }Code-Sprache: Java (java)

    Dieser switch ist unvollständig und damit ungültig, da z. B. für den Wert 3,5 kein case-Label matchen würde. Der Compiler würde dies mit folgendem Fehler quitieren:

    error: the switch statement does not cover all possible input values
      switch (value) {
      ^Code-Sprache: Klartext (plaintext)

    Folgender switch ist hingegen vollständig:

    short value = . . .
    switch (value) {
      case byte   _ -> System.out.println(value + " instanceof byte");
      case int    _ -> System.out.println(value + " instanceof int");
    }Code-Sprache: Java (java)

    Hier gibt es zwar kein case short-Label, aber ein case int-Label, und gegen das matcht jeder mögliche short-Wert.

    Zusammenfassung

    Mit der Option --enable-preview kannst du in Java 23 und 24 das Feature „Primitive Types in Patterns, instanceof, and switch“ aktivieren. Damit kannst du mit instanceof und switch gegen primitive Typ-Pattern wie z. B. int i oder double d matchen.

    Da es bei primitiven Typen keine Vererbung gibt, funktionieren primitive Pattern etwas anders als Pattern mit Referenztypen: eine Variable matcht ein primitives Pattern dann, wenn eine Variable des Zieltyps sie ohne Präzisionsverlust aufnehmen kann.

  • Module importieren in Java: Module Import Declarations

    Module importieren in Java: Module Import Declarations

    In diesem Artikel erfährst du:

    • Wie können mit import module ganze Java-Module importiert werden?
    • Wie können dabei Mehrdeutigkeiten aufgelöst werden?
    • Was sind transitive Modul-Importe, und wie funktionieren sie?
    • Welche Module werden standardmäßig importiert?

    Beginnen wir mit einem ganz kurzen Rückblick…

    Java Imports

    Seit Java 1.0 können wir mit dem import-Statement einzelne Klassen („single-type-import declaration“) oder ganze Pakete („type-import-on-demand declaration“) importieren, z. B. wie folgt:

    import java.util.*;
    import java.util.stream.Stream;Code-Sprache: Java (java)

    Die Klassen des Pakets java.lang werden seit jeher automatisch importert. Deshalb müssen wir für Klassen wie Object, String, Integer, Exception, usw. keine import-Statements angeben.

    Module Import Declarations

    Mit import module können wir ab Java 25 (und in Java 23 und Java 24 im Preview-Modus) auch ganze Module importieren. Dadurch können wir alle Klassen, die sich innerhalb eines Moduls befinden und von diesem exportiert werden, direkt verwenden.

    Im folgenden Beispiel importieren wir das Modul java.base und können dadurch die Klassen List, Map, Stream und Collectors verwenden, ohne sie einzeln oder paketweise importieren zu müssen (String und Character liegen im Paket java.lang und wurden daher schon immer automatisch importiert).

    import module java.base;
    
    public class ModuleImportTest {
    
      public static Map<Character, List<String>> groupByFirstLetter(String... values) {
        return Stream.of(values).collect(
            Collectors.groupingBy(s -> Character.toUpperCase(s.charAt(0))));
      }
    }Code-Sprache: Java (java)

    Wenn du dieses Code-Beispiel in der Datei ModuleImportTest.java speicherst, kannst du die Klasse im aktuellen Java 24 wie folgt kompilieren (in Java 23 musst du den Parameter --source 24 durch --source 23 ersetzen; in Java 25 können --enable-preview und --source wegfallen):

    javac --enable-preview --source 24 ModuleImport.javaCode-Sprache: Klartext (plaintext)

    Um import module zu verwenden ist es – wie im vorangegangenen Beispiel gesehen – nicht nötig, dass sich die importierende Klasse selbst in einem Modul befindet.

    Compact Source Files (zuvor bekannt als „Implizit deklarierte Klassen“ und „simple source files“) und JShell importieren automatisch das java.base-Modul, d. h. Klassen wie List und Map kannst du dort ohne explizite Imports verwenden.

    Mehrdeutige Klassennamen

    Manchmal kommt es vor, dass ein Klassenname nicht eindeutig ist. In folgendem Beispiel gibt es eine List-Klasse sowohl im importierten Modul java.base (java.util.List) als auch im Modul java.desktop (java.awt.List):

    import module java.base;     // ← Contains java.util.List
    import module java.desktop;  // ← Contains java.awt.List
    
    public class Ambiguous {
      List list;                 // ← Ambiguous reference to List
    }Code-Sprache: Java (java)

    Wenn du die Datei unter Ambiguous.java abspeicherst und dann wie folgt mit Java 24 kompilierst:

    javac --enable-preview --source 24 Ambiguous.javaCode-Sprache: Klartext (plaintext)

    … oder wie folgt mit Java 23:

    javac --enable-preview --source 23 Ambiguous.javaCode-Sprache: Klartext (plaintext)

    … dann bricht der Compiler mit folgender Fehlermeldung ab:

    Ambiguous.java:5: error: reference to List is ambiguous
      List list;
      ^
      both class java.awt.List in java.awt and interface java.util.List in java.util matchCode-Sprache: Klartext (plaintext)

    Das bedeutet, dass der Compiler nicht weiß, welche der beiden List-Klassen gemeint ist.

    Mehrdeutige Klassennamen auflösen

    Nehmen wir an, du möchtest java.util.List verwenden (und nicht java.awt.List). Dann hast du zwei Möglichkeiten, um diese Mehrdeutigkeit aufzulösen:

    Option 1: Du importierst zusätzlich die Klasse java.util.List direkt:

    import module java.base;
    import module java.desktop;
    
    import java.util.List;  // ← Ambiguity resolved by single-type-import declaration
    
    public class Ambiguous {
      List list;
    }Code-Sprache: Java (java)

    Option 2: Du importierst zusätzlich das Paket java.util (in der Java-Terminologie heißt das übrigens nicht „Package Import“ sondern „Type-Import-on-Demand Declaration“):

    import module java.base;
    import module java.desktop;
    
    import java.util.*;  // ← Ambiguity resolved by type-import-on-demand declaration
    
    public class Ambiguous {
      List list;
    }Code-Sprache: Java (java)

    Die zweite Option ist erst seit Java 24 verfügbar. Wenn du das letzte Beispiel mit Java 23 kompilierst, führt dies ebenfalls zur Fehlermeldung „reference to List is ambiguous“.

    Mehrdeutige Klassennamen kann es übrigens auch – wenn auch selten – geben, wenn du nur ein Modul importierst. Beispielsweise enthält das Modul java.desktop sowohl das Interface javax.swing.text.Element als auch die Klasse javax.swing.text.html.parser.Element.

    Transitive Modul-Importe

    Wenn ein mit import module importiertes Modul ein drittes Modul transitiv importiert, dann sind auch alle Klassen der exportierten Pakete dieses dritten Moduls ohne explizite Imports nutzbar.

    Ich möchte dir das am Beispiel der Module java.sql und java.xml erklären.

    • Das Modul java.sql hat (u. a.) eine transitive Abhängigkeiten auf das Modul java.xml.
    • Das Modul java.sql exportiert die Pakete java.sql und javax.sql.
    • Das Modul java.xml exportiert die Pakete javax.xml und org.w3c.dom, jeweils mit zahlreichen Unterpaketen.

    Die folgende Grafik zeigt die Module, ihre Abhängigkeiten und die exportierten Pakete:

    java module import declarations

    In der Modul-Deklarationen des java.sql-Moduls sieht das so aus:

    module java.sql {
      . . .
      requires transitive java.xml;
    
      exports java.sql;
      exports javax.sql;
      . . .
    }Code-Sprache: Java (java)

    Und in der Modul-Deklaration von java.xml so:

    module java.xml {
      exports javax.xml;
      exports javax.xml.parsers;
      . . .
    }Code-Sprache: Java (java)

    Wenn wir nun ein Programm schreiben, das das Modul java.sql importiert, dann benötigen wir beispielsweise keine expliziten Imports für die Klassen SAXParserFactory und SAXParser aus dem Paket javax.xml.parsers des java.xml-Moduls – und auch keinen expliziten Import dieses Moduls:

    import module java.sql;  // ← Transitively imports module java.xml
                             //   and its exported packages, e.g. javax.xml.parsers
    . . .
    SAXParserFactory factory = SAXParserFactory.newInstance();
    SAXParser saxParser = factory.newSAXParser();
    . . .Code-Sprache: Java (java)

    Denn aus der transitiven Abhängigkeit von Modul java.sql auf Modul java.xml und der Tatsache, dass java.xml das Paket javax.xml.parsers exportiert, folgt, dass das Programm auch ohne explizite Imports auf alle Klassen des Pakets javax.xml.parsers zugreifen kann.

    Beachte, dass in Java 23 ein Import des Moduls java.se (ein Agregator-Modul mit Abhängigkeiten auf alle Module der Java Standard Edition „Java SE“) nicht die Klassen des java.base-Moduls verfügbar macht. Dies wurde in Java 24 geändert.

    Automatischer java.base-Import in JShell

    Sobald Module Import Declarations Produktionsreife erlangt haben wird JShell automatisch das Modul java.base importieren. Aktuell kannst du dies bereits mit --enable-preview aktivieren:

    $ jshell --enable-preview
    |  Welcome to JShell -- Version 24
    |  For an introduction type: /help intro
    
    jshell> /imports
    |    import java.baseCode-Sprache: Klartext (plaintext)

    Wenn du JShell aktuell ohne --enable-preview startest und das /imports-Kommando eingibst, wirst du hingegen die folgenden zehn standardmäßige Paket-Importe sehen:

    $ jshell --enable-preview
    |  Welcome to JShell -- Version 24
    |  For an introduction type: /help intro
    
    jshell> /imports
    |    import java.io.*
    |    import java.math.*
    |    import java.net.*
    |    import java.nio.file.*
    |    import java.util.*
    |    import java.util.concurrent.*
    |    import java.util.function.*
    |    import java.util.prefs.*
    |    import java.util.regex.*
    |    import java.util.stream.*Code-Sprache: Klartext (plaintext)

    Fazit

    Module Import Declarations können Java-Programme kürzer und leichter wartbar machen, indem nicht mehr einzelne Klassen und Pakete, sondern ganze Module importiert werden können. Andererseits können sie auch die ewige Diskussion darüber, ob man Klassen einzeln oder paketweise importieren soll, weiter ausufern lassen.

    In Compact Source Files (bzw. Simple Source Files bzw. implizit deklarierten Klassen) und JShell wird automatisch das java.base-Modul importiert, so dass hier alle Klassen dieses Moduls ohne Imports direkt verwendet werden können.

    Module Import Declarations befindet sich bis Java 24 noch in der Preview-Phase und müssen mit --enable-preview --source <Java-Version> aktiviert werden. Sie werden in Java 25 finalisiert.

  • Flexible Constructor Bodies in Java: Code vor super() aufrufen

    Flexible Constructor Bodies in Java: Code vor super() aufrufen

    In diesem Artikel erfährst du:

    • wie du ab Java 25 (ab Java 22 als Preview-Feature) Code in Konstruktoren auch vor dem Aufruf von super(...) oder this(...) ausführen kannst,
    • welche Einschränkungen es dabei gibt,
    • was Prolog und Epilog eines Konstruktors sind,
    • und ob die Neuerungen auch für Records und Enums gelten.

    Gehen wir einen Schritt zurück: Warum sollte man Code vor super(...) oder this(...) aufrufen wollen?

    Code in Konstruktoren – Status Quo vor Java 25

    Die folgenden Beispiele zeigen zum einen Workarounds, die bisher erforderlich waren, um vor dem Aufruf von super() oder this() Parameter zu validieren oder zu berechnen – und zum anderen, was schief gehen konnte, wenn der Konstruktor der Elternklasse eine Methode aufruft, die in der Kindklasse überschrieben wird.

    Use Case 1: Validierung von Parametern

    Ein häufiger Use Case ist die Validierung von Parametern einer Kindklasse. Im folgenden Beispiel ruft der Konstruktur von Rectangle erst den Konstruktor der Elternklasse, Shape, auf und validiert und setzt danach die Breite und Höhe:

    public class Shape {
      private final Color color;
    
      public Shape(Color color) {
        this.color = color;
      }
    }
    
    public class Rectangle extends Shape {
      private final double width;
      private final double height;
    
      public Rectangle(Color color, double width, double height) {
        super(color);
        if (width < 0 || height < 0) throw new IllegalArgumentException();
        this.width = width;
        this.height = height;
      }
    }Code-Sprache: Java (java)

    Effizienter wäre es allerdings, die Parameter zu validieren, bevor der Super-Konstruktor aufgerufen wird. Doch das ist bisher nur mit dem folgenden, extrem unschönen Workaround möglich:

    public Rectangle(Color color, int width, int height) {
      super(validateParams(color, width, height));
      this.width = width;
      this.height = height;
    }
    
    private static Color validateParams(Color color, int width, int height) {
      if (width < 0 || height < 0) throw new IllegalArgumentException();
      return color;
    }Code-Sprache: Java (java)

    Use Case 2: Berechnung eines Arguments, das an mehrere Parameter übergeben wird

    Ein weiterer Use Case ist die Berechnung von Werten, die an mehr als einen Superklassen-Konstruktorparameter weitergegeben werden sollen. Im folgenden Beispiel wollen wir ein Quadrat mit vorgegebener Fläche erzeugen (dass eine statische Factory-Methode mit aussagekräftigem Namen dafür geeigneter wäre als der Konstruktor wollen wir an dieser Stelle ignorieren):

    public class Square extends Rectangle {
      public Square(Color color, int area) {
        super(color, Math.sqrt(area), Math.sqrt(area));
      }
    }Code-Sprache: Java (java)

    Um die Wurzel der Fläche nicht zweimal zu berechnen, müssten wir einen Hilfskonstruktor einführen:

    public class Square extends Rectangle {
      public Square(Color color, int area) {
        this(color, Math.sqrt(area));
      }
    
      private Square(Color color, double sideLength) {
        super(color, sideLength, sideLength);
      }
    }Code-Sprache: Java (java)

    Das ist hier aber auch nur deshalb möglich, weil area vom Typ int ist. Wäre area wie sideLength vom Typ double, würde das nicht funktionieren, da wir dann zwei Konstruktoren mit identischer Signatur hätten.

    Und wollten wir zuvor sichergehen, dass area nicht negativ ist, müssten wir eine dritte Methode einführen, da wir auch vor this(...) keinen anderen Code ausführen dürfen:

    public class Square extends Rectangle {
      public Square(Color color, int area) {
        this(color, Math.sqrt(validateArea(area)));
      }
    
      private static double validateArea(int area) {
        if (area < 0) throw new IllegalArgumentException();
        return area;
      }
    
      private Square(Color color, double sideLength) {
        super(color, sideLength, sideLength);
      }
    }
    Code-Sprache: Java (java)

    Es ist kaum noch ersichtlich, was dieser Code tut.

    Use Case 3: Aufruf einer überschriebenen Methode im Super-Konstruktor

    Wir bleiben beim Shape/Rectangle-Beispiel und fügen eine printMe()-Methode hinzu, die im Konstruktor von Shape aufgerufen und in Rectangle überschrieben wird:

    public class Shape {
      private final Color color;
    
      public Shape(Color color) {
        this.color = color;
        printMe();
      }
    
      void printMe() {
        System.out.println("color = " + color);
      }
    }
    
    public class Rectangle extends Shape {
      private final double width;
      private final double height;
    
      public Rectangle(Color color, double width, double height) {
        super(color);
        if (width < 0 || height < 0) throw new IllegalArgumentException();
        this.width = width;
        this.height = height;
      }
    
      @Override
      void printMe() {
        super.printMe();
        System.out.println("width = " + width + ", height = " + height);
      }
    }Code-Sprache: Java (java)

    Wenn wir nun z. B. new Rectangle(Color.RED, 29.7, 21.0) aufrufen, dann wird nicht etwa color = RED und width = 29.7, height = 21.0 ausgegeben, sondern:

    color = RED
    width = 0.0, height = 0.0Code-Sprache: Klartext (plaintext)

    Der Grund dafür ist, dass printMe() vom Shape-Konstruktor aufgerufen wird, bevor im Rectangle-Konstruktor width und height initialisiert werden. printMe() sieht also noch die Default-Werte von width und height, also jeweils 0,0.

    Java-Code vor super(…) und this(…)

    Mit JDK Enhancement Proposal 447 wurde in Java 22 – zunächst als Preview-Feature und unter dem Namen „Statements before super(…)” – die Möglichkeit eingeführt, Code auch vor dem Aufruf von super(...) oder this(...) aufzurufen.

    Wir können damit zunächst die Validierung der Fläche vor den Aufruf von this(...) ziehen:

    public class Square extends Rectangle {
      public Square(Color color, int area) {
        if (area < 0) throw new IllegalArgumentException();  // ⟵ Validation before `this`
        this(color, Math.sqrt(area));
      }
    
      private Square(Color color, double sideLength) {
        super(color, sideLength, sideLength);
      }
    }Code-Sprache: Java (java)

    Und auch den Hilfs-Konstruktor brauchen wir nicht mehr. Parametervalidierung und Berechnung der Seitenlänge können nun direkt im Konstruktor untergebracht werden:

    public Square(Color color, int area) {
      if (area < 0) throw new IllegalArgumentException();  // ⟵ Validation before `super`
      double sideLength = Math.sqrt(area);                 // ⟵ Calculation before `super`
      super(color, sideLength, sideLength);
    }Code-Sprache: Java (java)

    Bei diesem Konstruktor ist auf einen Blick erkennbar, was der Code macht.

    Mit JDK Enhancement Proposal 482 wurde in Java 23 darüber hinaus die Möglichkeit geschaffen, vor dem Aufruf von super(...) Felder der Klasse zu initialisieren. Wir dürfen damit die Rectangle-Klasse so schreiben:

    public class Rectangle extends Shape {
      private final double width;
      private final double height;
    
      public Rectangle(Color color, double width, double height) {
        this.width = width;    // ⟵ Field initialization before `super`
        this.height = height;  // ⟵ Field initialization before `super`
        super(color);
      }
    
      . . .
    }Code-Sprache: Java (java)

    Bei einem Aufruf von new Rectangle(Color.RED, 29.7, 21.0) liefert die vom Konstruktor aufgerufene printMe()-Methode nun die erwartete Ausgabe:

    color = RED
    width = 29.7, height = 21.0Code-Sprache: Klartext (plaintext)

    Durch JDK Enhancement Proposal 513 wurden Flexible Constructor Bodies in Java 25 finalisiert.

    Konstruktor-Prolog und -Epilog

    Der Block vor dem Aufruf von super(...) oder this(...) in einem Konstruktor wird Prolog genannt.

    Code nach dem Aufruf von super(...) oder this(...) oder Code in einem Konstruktor ohne Aufruf von super(...) oder this(...) wird als Epilog bezeichnet.

    Einschränkungen

    Im Prolog darf der Code Felder initialisieren, aber nicht lesend auf Felder der Klasse zugreifen und keine nicht-statische Methoden der Klasse aufrufen. Er darf außerdem keine Instanzen von nicht-statischen inneren Klassen erzeugen, da diese dann eine Referenz auf das potentiell uninitialisierte Elternobjekt haben würden.

    Der Prolog des Konstruktors einer inneren Klasse darf hingegen uneingeschränkt auf Felder und Methoden der äußeren Klasse zugreifen.

    Records und Enums

    Records und Enums können zwar keine Elternklasse haben, deren Konstruktoren können allerdings mit this(...) alternative Konstruktoren aufrufen.

    Auch davor darf nun Code, der den oben genannten Einschränkungen standhält, ausgeführt werden.

    Fazit

    Der Aufruf von Code vor super(...) oder this(...) erlaubt es, Felder zu initialisieren und Parameter zu validieren oder zu berechnen, bevor der Super-Konstruktor oder ein alternativer Konstruktor aufgerufen wird. Das macht den Code sicherer und ermöglicht deutlich ausdrucksstärkeren Code als die Workarounds, die wir bisher für solche Zwecke konstruieren mussten.

    Flexible Constructor Bodies befinden sich zum Stand von Java 24 noch im Preview-Stadium und müssen wir folgt aktiviert werden:

    --enable-preview --source 24

    Flexible Constructor Bodies werden in Java 25 finalisiert.

    Musstest du auch schon komplizierte Workarounds implementieren, und wie findest du das neue Feature? Lass es mich über die Kommentarfunktion wissen!

  • String Templates in Java

    String Templates in Java

    Breaking News: Am 05.04.2024 hat Gavin Bierman bekanntgegeben, dass String Templates in der hier beschriebenen Form nicht veröffentlicht werden. Es besteht Einigkeit darüber, dass das Design geändert werden soll, es besteht allerdings kein Konsens darüber, wie es geändert werden soll. Die Sprachentwickler wollen sich nun Zeit nehmen, das Design zu überarbeiten. String Templates werden daher in Java 23 nicht weiter enthalten sein, auch nicht mit --enable-preview.

    In diesem Artikel erfährst du:

    • Wie vereinfachen String Templates die Zusammensetzung von Strings aus Text, Variablen und berechneten Werten?
    • Was ist ein Template-Prozessor?
    • Welche Template-Prozessoren gibt es?

    String Templates wurden in Java 21 im Rahmen von Project Amber als Preview-Feature eingeführt. In Java 22 wurden sie ohne Änderungen in eine zweite Preview-Runde geschickt.

    Status Quo der String-Verkettung

    Um Strings zur Laufzeit anhand von Variablen und berechneten Werten zusammenzusetzen, gibt es verschiedene Möglichkeiten. Die geläufigsten sind die folgenden:

    int a = ...;
    int b = ...;
    
    String concatenated = a + " times " + b + " = " + a * b;
    String format       = String.format("%d times %d = %d", a, b, a * b);
    String formatted    = "%d times %d = %d".formatted(a, b, a * b);Code-Sprache: Java (java)

    Oft wird auch ein StringBuilder, weniger oft java.text.MessageFormat eingesetzt. Aber keine der verfügbaren Varianten ist wirklich gut zu lesen.

    String-Interpolation mit String Templates

    Nahezu jede moderne Programmiersprache bietet die Möglichkeit der String-Interpolation, d. h. die Möglichkeit, Platzhalter in einem String auszuwerten und sie durch das Ergebnis dieser Auswertung zu ersetzen.

    Genau das (und noch mehr, s. u.) ermöglichen die durch JDK Enhancement Proposal 430 eingeführten „String Templates”.

    Damit können wir das Beispiel von oben wie folgt umschreiben:

    int a = ...;
    int b = ...;
    
    String interpolated = STR."\{a} times \{b} = \{a * b}";Code-Sprache: Java (java)

    Zur Laufzeit wird der Platzhalter \{a} durch den Wert der Variablen a ersetzt und der Platzhalter \{b} durch den Wert der Variablen b.

    Die durch \{...} gekennzeichneten Platzhalter dürfen nicht nur Variablen und arithmetische Ausdrücke enthalten – sie können jeden beliebigen Java-Ausdruck enthalten, z. B. einen statischen Methodenaufruf:

    String interpolated = STR."\{a} times \{b} = \{Math.multiplyExact(a, b)}";Code-Sprache: Java (java)

    Der Platzhalter darf auch Anführungszeichen enthalten, und der Übersicht halber kannst du ihn auf mehrere Zeilen aufteilen und mit Kommentaren versehen (an dieser Stelle muss ich allerdings das WordPress-Syntax-Highlighting-Plugin abschalten, da der folgende Code es überfordern würde):

    String dateMessage = STR."Today's date: \{
            LocalDate.now().format(
                      // We could also use DateTimeFormatter.ISO_DATE
                      DateTimeFormatter.ofPattern("yyyy-MM-dd")
            )}";Code-Sprache: Klartext (plaintext)

    String Templates können auch mit mehrzeiligen Strings verwendet werden:

    int    httpStatus   = ...;
    String errorMessage = ...;
    
    String json = STR."""
        {
          "httpStatus": \{httpStatus},
          "errorMessage": "\{errorMessage}"
        }""";Code-Sprache: Klartext (plaintext)

    Falls du dich gefragt hast, was das STR. vor den Strings zu bedeuten hat – die Antwort findest du im nächsten Abschnitt.

    String Template Processor

    STR ist der Name eines sogenannten Template Processors (genauer gesagt: eine automatisch in jede Java-Datei importierte Konstante vom Typ StringTemplate.Processor). Ein Template Processor definiert, wie Template-Text und Platzhalter zu einem Ergebnis zusammengesetzt werden. Im Fall des STR-Template-Prozessors werden – wie oben beschrieben – die Java-Ausdrücke in den Platzhaltern aufgelöst und die Platzhalter durch die dabei herauskommenden Werte ersetzt.

    FMT Template Processor

    Ein weiterer Template Processor ist FMT. Dieser wertet den Platzhaltern vorangestellte Formatierungsangaben – wie wir sie auch von String.format() kennen – aus.

    Hier noch einmal unser Multiplikationsbeispiel mit Fließkommazahlen, die mit dem FMT-Template-Prozessor auf zwei Stellen nach dem Komma gerundet werden:

    double a = ...;
    double b = ...;
    
    String interpolated = FMT."%.2f\{a} times %.2f\{b} = %.2f\{a * b}";Code-Sprache: Java (java)

    SQL Template Processor

    Ein Template Processor muss übrigens nicht zwangsläufig einen String zurückliefern. So könnte z. B. ein Template Processor implementiert werden, der SQL-Kommandos zusammenstellt und ein SQL-Statement zurückliefert – und ganz nebenbei noch vor Injektionsangriffen schützt:

    String searchQuery = ...
    Statement statement = SQL."""
        SELECT * FROM User u
        WHERE u.userName LIKE '%\{searchQuery}%'""";Code-Sprache: Java (java)

    Wenn du noch tiefer in die Materie einsteigen willst, empfehle ich dir den JDK Enhancement Proposal 459 zu lesen. Dort erfährst du auch, wie du deinen eigenen Template Processor schreiben kannst und dass ein Template Processor nicht immer einen String zurückliefern muss.

  • Unbenannte Variablen und Patterns in Java

    Unbenannte Variablen und Patterns in Java

    In diesem Artikel erfährst du:

    • Was ist eine unbenannte Variable, und welchen Zweck erfüllt sie?
    • Was sind unbenannte Patterns und unbenannte Pattern-Variablen, und welchen Zweck erfüllen sie?
    • Wie kann man Switch-Ausdrücke mit unbenannten Pattern-Variablen prägnanter schreiben?

    Unbenannte Variablen und Patterns wurden in Java 21 im Rahmen von Project Amber als Preview-Feature eingeführt. In Java 22 wurden sie finalisiert.

    Unbenannte Variablen

    Oft kommt es vor, dass wir eine Variable definieren müssen, die wir gar nicht benötigen. Hier zwei Beispiele, die wahrscheinlich die meisten von euch kennen:

    Beispiel 1: Exceptions – hier wird e nicht verwendet:

    try {
      int number = Integer.parseInt(string);
    } catch (NumberFormatException e) {
      System.err.println("Not a number");
    }Code-Sprache: Java (java)

    Beispiel 2: Map.computeIfAbsent() – hier wird k nicht verwendet:

    map.computeIfAbsent(key, k -> new ArrayList<>()).add(value);Code-Sprache: Java (java)

    Ab Java 22 (bzw. ab Java 21 mit aktivierten Preview-Features) müssen wir solche Variablen nicht mehr benennen, sondern dürfen (wie in anderen Programmiersprachen lange üblich) stattdessen den Unterstrich (_) verwenden:

    Hier das Exception-Beispiel mit unbenannter Variable:

    try {
      int number = Integer.parseInt(string);
    } catch (NumberFormatException _) {
      System.err.println("Not a number");
    }Code-Sprache: Java (java)

    Und das computeIfAbsent()-Beispiel:

    map.computeIfAbsent(key, _ -> new ArrayList<>()).add(value);Code-Sprache: Java (java)

    Bei einer Exception mag man sich über das Für und Wider einer unbenannten Variable streiten. Wir sind daran gewöhnt eine Exception mit „e” zu bezeichnen, bzw. unsere IDE das automatisch machen zu lassen.

    Bei computeIfAbsent() hingegen habe ich mir immer Gedanken darüber gemacht, wie ich die nicht benötigte Variable benennen soll. Manchmal wurde es ein k (für „key”), manchmal ein ignored und manchmal ein __ (doppelter Unterstrich²). Hier ist die unbenannte Variable eine große Hilfe.

    ² Der einfache Unterstrich war seit Java 9 in Vorbereitung auf genau dieses Feature nicht mehr erlaubt.

    Unbenannte Patterns und Pattern-Variablen

    Das Feature heißt allerdings nicht „Unnamed Variables”, sondern „Unnamed Patterns and Variables” und hat somit noch einiges mehr zu bieten – und zwar bei den in Java 21 finalisierten Features Record Patterns und Pattern Matching for Switch.

    Die Variable y wird im „then-Block” des folgenden Code-Beispiels nicht benötigt:

    if (object instanceof Position(int x, int y)) {
      System.out.println("object is a position, x = " + x);
    }Code-Sprache: Java (java)

    Auch hier können wir deswegen y durch einen Unterstrich ersetzen:

    if (object instanceof Position(int x, int _)) {
      System.out.println("object is a position, x = " + x);
    }Code-Sprache: Java (java)

    Das nennt sich dann „unbenannte Pattern-Variable”.

    Wir können sogar noch einen Schritt weiter gehen und das komplette Teil-Pattern int y durch einen Unterstrich ersetzen:

    if (object instanceof Position(int x, _)) {
      System.out.println("object is a position, x = " + x);
    }Code-Sprache: Java (java)

    Das nennt sich dann „unbenanntes Pattern”.

    Im vorigen Beispiel hat das noch keine große Auswirkung; bei verschachtelten Patterns hingegen lässt sich damit eine Menge Platz sparen. Im folgenden Beispiel verwenden wir nur die Variablen x1 und y1, während x2 und y2 unbenutzt sind:

    if (object instanceof Path(Position(int x1, int y1), Position(int x2, int y2))) {
      System.out.printf("object is a path starting at x = %d, y = %d%n", x1, y1));
    }Code-Sprache: Java (java)

    Hier können wir das komplette zweite Position-Pattern durch den Unterstrich ersetzen:

    if (object instanceof Path(Position(int x1, int y1), _)) {
      System.out.printf("object is a path starting at x = %d, y = %d%n", x1, y1));
    }Code-Sprache: Java (java)

    Das stellt doch eine deutliche Verbesserung dar!

    Unbenannte Pattern-Variablen und Pattern Matching for Switch

    Hier ein Beispiel mit nicht verwendeten Variablen bei Pattern Matching for Switch:

    switch (obj) {
      case Byte    b -> System.out.println("Integer number");
      case Short   s -> System.out.println("Integer number");
      case Integer i -> System.out.println("Integer number");
      case Long    l -> System.out.println("Integer number");
    
      case Float  f -> System.out.println("Floating point number");
      case Double d -> System.out.println("Floating point number");
    
      default -> System.out.println("Not a number");
    }Code-Sprache: Java (java)

    Auch hier dürfen wir alle Variablennamen durch Unterstriche ersetzen:

    switch (obj) {
      case Byte    _ -> System.out.println("Integer number");
      case Short   _ -> System.out.println("Integer number");
      case Integer _ -> System.out.println("Integer number");
      case Long    _ -> System.out.println("Integer number");
    
      case Float  _ -> System.out.println("Floating point number");
      case Double _ -> System.out.println("Floating point number");
    
      default -> System.out.println("Not a number");
    }Code-Sprache: Java (java)

    Wir können sogar noch einen Schritt weitergehen und alle Fälle mit gleichen Aktionen zusammenfassen:

    switch (obj) {
      case Byte _, Short _, Integer _, Long _ -> System.out.println("Integer number");
      case Float _, Double _                  -> System.out.println("Floating point number");
    
      default -> System.out.println("Not a number");
    }Code-Sprache: Java (java)

    Und das ist – neben der prägnanteren Schreibweise – der zweite große Vorteil der unbenannten Pattern-Variable! Mit benannten Variablen wäre das nämlich nicht möglich gewesen. Der folgende Code ist nicht gültig:

    switch (obj) {
      // Not allowed!          
      case Byte b, Short s, Integer i, Long l -> System.out.println("Integer number");
      case Float f, Double d                  -> System.out.println("Floating point number");
    
      default -> System.out.println("Not a number");
    }Code-Sprache: Java (java)

    Dieser Code führt zu folgendem Compilerfehler:

    error: illegal fall-through from a pattern
      case Byte b, Short s, Integer i, Long l -> System.out.println("Integer number");
                   ^Code-Sprache: Klartext (plaintext)

    Der entscheidende Unterschied ist, dass auf benannte Variablen von nachfolgendem Code aus zugegriffen werden kann, während auf unbenannte Variablen nicht zugegriffen werden darf. Da der Compiler nicht weiß, welches Pattern zur Laufzeit matchen wird, weiß er auch nicht, auf welche der Variablen b, s, i und l zugegriffen werden darf. Daher lässt er pro Fall nur eine benannte Variable, aber beliebig viele unbenannte Variablen zu.

    Unnamed Variables & Patterns werden in JDK Enhancement Proposal 456 definiert. Dort findest du noch ein paar weitere Beispiele für den Einsatz unbenannter Variablen.

  • Java Switch Expressions

    Java Switch Expressions

    Switch Expressions wurden in Java 14 im Rahmen von Project Amber veröffentlicht.

    Bei Switch Expressions handelt es sich eigentlich um zwei Erweiterungen, die unabhängig voneinander, aber auch kombiniert, eingesetzt werden können:

    1. Pfeilnotation ohne break und fall-throughs
    2. Verwendung von switch als Ausdruck mit Rückgabewert

    Schauen wir uns die Änderungen eine nach der anderen an (ich verwende dazu die Beispiele aus JEP 361, leicht abgewandelt).

    Ausgangslage

    Im folgenden Beispiel wird für einen Wochentag die Wortlänge ausgegeben. Für die letzten beiden Fälle habe ich den Code etwas komplizierter als nötig geschrieben – um im Folgenden zu demonstrieren, was mit Switch Expressions möglich ist.

    switch (day) {
      case MONDAY:
      case FRIDAY:
      case SUNDAY:
        System.out.println(6);
        break;
      case TUESDAY:
        System.out.println(7);
        break;
      case THURSDAY:
      case SATURDAY:
        System.out.println((int) Math.pow(2, 3));
        break;
      case WEDNESDAY:
        int three = 1 + 2;
        System.out.println(three * three);
        break;
    }Code-Sprache: Java (java)

    Diese Schreibweise ist zum einen unübersichtlich, zum anderen fehleranfällig aufgrund der sogenannten „Fall-Throughs“, also der Ausführungsfortsetzung beim Folgefall, wenn der vorherige nicht mit einem break-Statement abgeschlossen wurde.

    Entsprechend werden solche Konstrukte auch von den gängigen Tools für statische Code-Analyse (SCA) wie Sonar, Checkstyle und PMD als Code Smells moniert:

    Warnungen der statischen Code-Analyse (SCA) bei Switch-Statements ohne break
    Warnungen der statischen Code-Analyse (SCA) bei Switch-Statements ohne break

    Switch mit Pfeilnotation

    Das folgende Beispiel zeigt, wie du ab Java 14 statt des Doppelpunkts einen Pfeil verwenden kannst. Es gelten dabei folgende Regeln:

    • Vor dem Pfeil darfst du mehrere Fälle Komma-separiert auflisten.
    • Nach dem Pfeil darf dann entweder ein einzelnes Code-Statement folgen (Zeilen 2, 3 und 4) oder ein Code-Block in geschweiften Klammern (Zeilen 5 bis 8).
    • Die break Statements fallen in dieser Notation weg.

    Hier der Beispiel-Code für ein switch-Statement in Pfeil-Notation:

    switch (day) {
      case MONDAY, FRIDAY, SUNDAY -> System.out.println(6);
      case TUESDAY                -> System.out.println(7);
      case THURSDAY, SATURDAY     -> System.out.println((int) Math.pow(2, 3));
      case WEDNESDAY -> {
        int three = 1 + 2;
        System.out.println(three * three);
      }
    }
    Code-Sprache: Java (java)

    Moderne IDEs wie IntelliJ erkennen das Verbesserungspotenzial und bieten eine automatische Konvertierung in das neue Format an:

    Automatische Ersetzung von 'switch' Statements
    Automatische Ersetzung von ’switch‘ Statements

    Switch als Ausdruck mit Rückgabewert

    Oft verwenden wir switch, um einer Variablen einen Fall-spezifischen Wert zuzuweisen. Im folgenden Beispiel geben wir die Länge des Wochentages nicht aus, sondern speichern sie in der Variablen numLetters.

    (Die letzten zwei Fälle sind wieder absichtlich umständlich geschrieben, um zu zeigen, was mit Switch Expressions möglich ist.)

    int numLetters;
    switch (day) {
      case MONDAY:
      case FRIDAY:
      case SUNDAY:
        numLetters = 6;
        break;
      case TUESDAY:
        numLetters = 7;
        break;
      case THURSDAY:
      case SATURDAY:
        numLetters = (int) Math.pow(2, 3);
        break;
      case WEDNESDAY:
        int three = 1 + 2;
        numLetters = three * three;
        break;
      default:
        throw new IllegalStateException("Unknown day: " + day);
    }Code-Sprache: Java (java)

    Damit wir die Variable im Anschluss verwenden können, müssen wir bei dieser herkömmlichen Schreibweise – obwohl wir jeden Wochentag abgedeckt haben – entweder die Variable vorab mit einem Standardwert belegen oder einen default-Case angeben. Andernfalls würde der Compiler mit der Fehlermeldung „Variable ’numLetters‘ might not have been initialized“ abbrechen.

    Ab Java 14 können wir dieses Statement in einen Ausdruck umwandeln. Dabei geben wir mit dem neuen Keyword yield jeweils einen Wert zurück. Das Ergebnis des Switch-Ausdrucks weisen wir dann direkt der Variablen zu:

    int numLetters = switch (day) {
      case MONDAY:
      case FRIDAY:
      case SUNDAY:
        yield 6;
    
      case TUESDAY:
        yield 7;
    
      case THURSDAY:
      case SATURDAY:
        yield (int) Math.pow(2, 3);
    
      case WEDNESDAY:
        int three = 1 + 2;
        yield three * three;
    
      default:
        throw new IllegalStateException("Unknown day: " + day);
    };Code-Sprache: Java (java)

    yield ist ein sogenanntes „contextual keyword“ und damit nur im Kontext eines switch-Ausdrucks von Bedeutung. Falls du yield in deinem Quellcode als Variablenname verwendet haben solltest – keine Sorge, das kannst du weiterhin tun. Selbst so etwas wäre erlaubt:

    int yield = 5; yield yield + yield;

    Der default-Case ist hier (im Gegensatz zur herkömmlichen Schreibweise) übrigens überflüssig – mehr dazu im Abschnitt Vollständigkeitsanalyse bei Enums.

    Kombination von Pfeilnotation und Switch-Ausdruck

    Deutlich eleganter wird der eben gezeigte Switch-Ausdruck, wenn wir ihn in Pfeilnotation schreiben. Dabei können wir den Rückgabewert

    • direkt hinter den Pfeil schreiben (Zeilen 2 und 3),
    • als Ausdruck oder Methodenaufruf hinter den Pfeil schreiben (Zeile 4)
    • oder aus einem Code-Block heraus mit yield zurückgeben (Zeile 7).
    int numLetters = switch (day) {
      case MONDAY, FRIDAY, SUNDAY -> 6;
      case TUESDAY                -> 7;
      case THURSDAY, SATURDAY     -> (int) Math.pow(2, 3);
      case WEDNESDAY              -> {
        int three = 1 + 2;
        yield three * three;
      }
      default -> throw new IllegalStateException("Unknown day: " + day);
    };
    Code-Sprache: Java (java)

    Das komplette Refactoring vom herkömmlichen Switch Statement zur Switch Expression mit Pfeilnotation können wir unsere IDE erledigen lassen:

     Automatische Ersetzung von 'switch' Statements durch 'switch' Expressions
    Automatische Ersetzung von ’switch‘ Statements durch ’switch‘ Expressions

    Vollständigkeitsanalyse bei Enums

    Da es sich bei der Variablen day um ein Enum handelt, kann der Compiler erkennen, dass wir alle Fälle abgedeckt haben. Somit darf der default-Case wegfallen:

    int numLetters = switch (day) {
      case MONDAY, FRIDAY, SUNDAY -> 6;
      case TUESDAY                -> 7;
      case THURSDAY, SATURDAY     -> (int) Math.pow(2, 3);
      case WEDNESDAY              -> {
        int three = 1 + 2;
        yield three * three;
      }
    };Code-Sprache: Java (java)

    Auch das erledigt unsere IDE gerne für uns:

    Automatische Entfernung des 'default'-Falls
    Automatische Entfernung des ‚default‘-Falls

    Die Schreibweise ohne default-Fall ist nicht nur kürzer, sondern hilft uns auch bei zukünftigen Erweiterungen des Enums. Sollten wir dieses – z. B. um einen NEWDAY – erweitern, wird der Compiler uns mitteilen, dass der Switch-Ausdruck nun unvollständig ist:

    Unvollständige 'switch' Expression
    Unvollständige ’switch‘ Expression

    Switch-Ausdrücke können unseren Code also auch robuster machen.

    Fazit

    Switch Expressions sind ein mächtiges Werkzeug. Die Pfeilnotation und die Verwendung als Ausdruck mit Rückgabewert erlauben eine deutlich kompaktere, übersichtlichere und weniger fehleranfällige Schreibweise als bisher.

    Switch Expressions wurden erstmals in Java 12 als Preview-Feature vorgestellt. Im zweiten Preview in Java 13 wurde das ursprünglich zur Rückgabe von Werten verwendete Keyword break durch yield ersetzt. Mit JDK Enhancement Proposal 361 wurden Switch Expressions in Java 14 ohne weitere Änderungen als finales Feature veröffentlicht.

    Hinterlasse mir einen Kommentar: Hattest du bereits Gelegenheit die neuen Switch Expressions einzusetzen? Wenn ja, wie gefallen sie dir. Wenn nein, warum nicht?

  • Java Text Blocks

    Java Text Blocks

    In Java 15 wurden im Rahmen von Project Amber Text Blocks (mehrzeilige Strings) eingeführt.

    In diesem Artikel erfährst du:

    • Warum brauchen wir Text Blocks?
    • Wie notiert man Text Blocks in Java?
    • Wie muss ein Text Block eingerückt werden?
    • Welche Escape-Sequenzen kann bzw. muss man im Text Block verwenden?

    Mehrzeilige Strings in Java

    Wenn wir vor Java 15 einen mehrzeiligen String notieren wollten, sah das in der Regel so aus:

    String sql =
        "  SELECT id, firstName, lastName\n"
            + "    FROM Employee\n"
            + "   WHERE departmentId = \"IT\"\n"
            + "ORDER BY lastName, firstName";
    
    String html =
        "<html>\n"
            + "  <body>\n"
            + "    <p>Hello World!</p>\n"
            + "  </body>\n"
            + "</html>";Code-Sprache: Java (java)

    Zeilenumbrüche und Anführungszeichen mussten wir durch Escape-Sequenzen (\n und \") ersetzen. Und um den String einigermaßen leserlich auf mehrere Zeilen aufzuteilen, mussten wir ihn unterteilen und mit + wieder konkatenieren. Schlimm war das nicht (da der Compiler wieder einen einzigen String daraus machte) aber schön war es auch nicht.

    Text Block Notation

    Ab Java 15 können wir mehrzeilige Strings als „Text Blocks“ notieren:

    String sql = """
          SELECT id, firstName, lastName
            FROM Employee
           WHERE departmentId = "IT"
        ORDER BY lastName, firstName""";
    
    String html = """
        <html>
          <body>
            <p>Hello World!</p>
          </body>
        </html>""";Code-Sprache: Java (java)

    Der Textblock startet und endet mit jeweils drei Anführungszeichen. Es gelten folgende Regeln:

    • Hinter den startenden Anführungszeichen muss ein Zeilenumbruch folgen; dieser wird nicht Teil des Strings.
    • Wenn vor den endenden Anführungszeichen ein Zeilenumbruch erfolgt, ist dieser Zeilenumbruch Teil des Strings.
    • Innerhalb des Textblocks müssen einzelne oder doppelte Anführungszeichen nicht escaped werden, dürfen aber (wobei SCA-Tools wie SonarLint empfehlen es nicht zu tun).
    • Möchtest du mehr als zwei Anführungszeichen schreiben, musst du jedes dritte davon escapen.

    Eine der ersten Fragen, die Entwicklerinnen und Entwickler sich stellen ist:

    Wie weit muss der Textblock eingerückt werden?

    Die Antwort ist: es ist egal.

    Der Textblock beginnt bei demjenigen Zeichen, das am weitesten links steht (im ersten Beispiel oben bei dem „O“ von „ORDER BY“ bzw. im zweiten Beispiel bei den spitzen Klammern in der ersten und letzten Zeile).

    Die folgenden drei Notationen führen alle zu demselben Ergebnis:

        String sql1 = """
              SELECT id, firstName, lastName
                FROM Employee
               WHERE departmentId = "IT"
            ORDER BY lastName, firstName""";
    
        String sql2 = """
                       SELECT id, firstName, lastName
                         FROM Employee
                        WHERE departmentId = "IT"
                     ORDER BY lastName, firstName""";
    
        String sql3 = """
      SELECT id, firstName, lastName
        FROM Employee
       WHERE departmentId = "IT"
    ORDER BY lastName, firstName""";Code-Sprache: Java (java)

    Alle drei Strings haben – unabhängig von der Einrückung im Quellcode – den folgenden Inhalt:

      SELECT id, firstName, lastName
        FROM Employee
       WHERE departmentId = "IT"
    ORDER BY lastName, firstNameCode-Sprache: Klartext (plaintext)

    Moderne IDEs geben uns hier eine Hilfestellung, in dem sie den linken Rand des Textblocks anzeigen (IntelliJ durch eine grüne Linie):

    Darstellung des linken Randes von Java-Textblöcken in IntelliJ
    Darstellung des linken Randes von Java-Textblöcken in IntelliJ

    Was aber, wenn man einen Textblock erstellen möchte, der eingerückt ist?

    Dafür gibt es einen Trick: Du fügst vor den abschließenden Anführungszeichen einen Zeilenumbruch ein und setzt die Anführungszeichen an die Stelle, an der der Textblock beginnen soll, z. B. so:

    String sql = """
          SELECT id, firstName, lastName
            FROM Employee
           WHERE departmentId = "IT"
        ORDER BY lastName, firstName
      """;Code-Sprache: Java (java)

    Der Textblock ist nun um zwei Zeichen eingerückt.

    Allerdings hat er am Ende auch einen Zeilenumbruch. Diesen können wir mit einer Escape-Sequenz wieder entfernen. Escape-Sequenzen werden im folgenden Kapitel behandelt.

    Escape-Sequenzen in Text Blocks

    Text Blocks haben den Vorteil, dass die in Strings am häufigsten verwendeten Escape-Sequenzen, nämlich \" für Anführungszeichen und \n für einen Zeilenumbruch nicht mehr benötigt werden.

    Stattdessen gibt es zwei neue Escape-Sequenzen:

    Escape-Sequenz: Backslash am Zeilenende

    Im vorherigen Kapitel hast du gesehen, dass man einen Textblock einrücken kann, indem man vor die abschließenden Anführungzeichen einen Zeilenumbruch einfügt. Dieser Zeilenumbruch ist dann alerdings auch im String enthalten. Um ihn zu entfernen, kannst du an das Ende der letzten Zeile einen Backslash setzen:

    String sql = """
          SELECT id, firstName, lastName
            FROM Employee
           WHERE departmentId = "IT"
        ORDER BY lastName, firstName\
      """;Code-Sprache: Java (java)

    Der Backslash am Zeilenende sorgt dafür, dass der String an dieser Stelle keinen Zeilenumbruch enthält.

    Diese Funktion ist nicht auf die letzte Zeile beschränkt – du kannst jede beliebige Zeile mit einem Backslash enden lassen, wie in folgendem Beispiel:

    String sql = """
        SELECT id, firstName, lastName \
        FROM Employee
        WHERE departmentId = "IT" \
        ORDER BY lastName, firstName""";Code-Sprache: Java (java)

    Dieser String enthält somit nur noch einen Zeilenumbruch hinter „Employee“:

    SELECT id, firstName, lastName FROM Employee
    WHERE departmentId = "IT" ORDER BY lastName, firstNameCode-Sprache: Klartext (plaintext)

    Das ist dann sinnvoll, wenn du einen einzeiligen String, z. B. ein sehr langes Log-Statement, im Quellcode mehrzeilig darstellen möchtest.

    Escape-Sequenz: \s

    Eine weitere Escape-Sequenz, mit der du einen Text-Block formatieren kannst, ist „\s“.

    Abschließende Leerzeichen werden standardmäßig aus jeder Zeile entfernt, wie in folgendem Beispiel (die Punkte sollen Leerzeichen darstellen):

    String text = """
        one·····
        two·····
        three···""";
    
    text.lines().map(line -> "|" + line + "|").forEachOrdered(System.out::println);Code-Sprache: Java (java)

    Die Ausgabe dieses Code-Snippets lautet:

    |one|
    |two|
    |three|Code-Sprache: Klartext (plaintext)

    Um die Leerzeichen zu erhalten, kannst du diese durch die Escape-Sequenz „\s“ ersetzen:

    String text = """
        one\s\s\s\s\s
        two\s\s\s\s\s
        three\s\s\s""";Code-Sprache: Java (java)

    Übersichtlicher und völlig ausreichend ist es, wenn wir nur das letzte Leerzeichen escapen:

    String text = """
        one    \s
        two    \s
        three  \s""";Code-Sprache: Java (java)

    Somit gibt das Programm das gewünschte Ergebnis aus:

    |one     |
    |two     |
    |three   |Code-Sprache: Klartext (plaintext)

    Damit hast den den kompletten Funktionsumfang der Text Blocks kennengelernt. Viel Spaß beim Einsatz!

    Geschichte der Text Blocks in Java

    Text Blocks wurden erstmal durch JDK Enhancement Proposal (JEP) 355 als Preview-Feature in Java 13 vorgestellt. Sie waren ein Ersatz für den von der Community nicht akzeptierten und daraufhin zurückgezogenen JEP 326, „Raw String Literals“. Falls du dich für die Gründe dahinter interessierst, du findest sie in diesem Beitrag von Brian Goetz in der jdk-dev-Mailingliste.

    Im zweiten Preview, JEP 368, wurden in Java 14 die o. g. Escape-Sequenzen hinzugefügt.

    Aufgrund des positiven Feedbacks wurden Text Blocks in Java 15 durch JEP 378 ohne weitere Änderungen als produktionsreifes Feature veröffentlicht.

    Fazit

    Textblöcke ermöglichen es uns endlich, mehrzeilige Zeichenketten in Java-Code komfortabel zu notieren. Sie werden in dreifache Anführungszeichen eingeschlossen. Anführungszeichen und Zeilenumbrüche müssen nicht mehr durch unübersichtliche Escape-Sequenzen ersetzt werden.

    Setzt du Textblöcke bereits ein? Wie gefallen sie dir? Hinterlasse einen Kommentar!

  • Sealed Classes in Java

    Sealed Classes in Java

    Sealed Classes (deutsch: versiegelten Klassen) und Interfaces waren die große Neuerung in Java 17. In diesem Artikel erfährst du:

    • Was sind versiegelte Klassen und Interfaces?
    • Wie genau funktionieren versiegelte Klassen und Interfaces?
    • Wofür brauchen wir sie?
    • Warum sollte man die Erweiterbarkeit einer Klassenhierarchie einschränken?

    Fangen wir an mit einem Beispiel…

    Ausgangslage: Beispiel-Klassenhierarchie

    Ausgangspunkt sei die folgende Klassenhierarchie:

    Sealed Classes Beispiel - Ausgangslage
    Sealed Classes Beispiel – Ausgangslage

    Hier der Java-Quellcode zu dem Beispiel:

    public class Shape { ... }
    
    public class Circle     extends Shape { ... }
    public class Rectangle  extends Shape { ... }
    public class Square     extends Shape { ... }
    public class WeirdShape extends Shape { ... }
    
    public class TranspRectangle extends Rectangle { ... }
    public class FilledRectangle extends Rectangle { ... }Code-Sprache: Java (java)

    Für gewöhnlich kann jede Entwicklerin und jeder Entwickler diese Klassenhierarchie an allen Stellen erweitern. Eine erweiterte Struktur könnte wie folgt aussehen (die hinzugekommenen Klassen sind hellgelb gefärbt):

    Sealed Classes Beispiel - Erweiterungsmöglichkeiten ohne Versiegelung
    Sealed Classes Beispiel – Erweiterungsmöglichkeiten ohne Versiegelung

    Nun kann es sein, dass wir die Erweiterung unserer Klassenhierarchie einschränken wollen. Z. B. könnten wir festlegen wollen, dass Entwicklerinnen und Entwickler ausschließlich die Klasse WeirdShape erweitern dürfen.

    Warum könnten wir das wollen, und wie können wir das tun?

    Warum die Erweiterbarkeit einer Klassenhierarchie einschränken?

    Es kann mehrere Gründe geben, warum wir die freie Erweiterbarkeit unserer Klassenhierarchie einschränken wollen:

    • Wir wollen den inneren Zustand einer Klasse bzw. einer Hierarchie von Klassen schützen und diesen nicht durch eine abgeleitete Klasse auf inkonsistente Weise manipulieren lassen.
    • Wir wollen interne Objekte, deren Thread-Sicherheit durch unsere Klasse bzw. Klassenhierarchie garantiert wird, vor Veröffentlichung schützen, damit die Thread-Sicherheit nicht durch fremden Code gefährdet werden kann.
    • Wir wollen sicherstellen, dass das Liskovsche Substitutionsprinzip (LSP) nicht verletzt wird. Das heißt: Wir wollen nicht, dass ein Entwickler eine abgeleitete Klasse implementiert, die den API-Vertrag der Elternklasse bricht.
    • Wir wollen uns die Vollständigkeitsanalyse bei „Pattern Matching for switch“ zunutze machen.

    Da wir jetzt die Gründe kennen, aus denen wir eine Klassenhierarchie einschränken wollen, kommen wir zur nächsten Frage: Wie können wir das tun?

    Klassenhierarchie versiegeln – Schritt für Schitt

    Die erste Möglichkeit kennen wir bereits…

    Klassenhierarchie einschränken mit „final“

    Indem wir Klassen als „final“ markieren, können wir deren Erweiterung generell verhindern.

    Eine zweite Möglichkeit wäre es eine Klasse als package-private zu markieren, um nur Unterklassen innerhalb desselben Pakets zu erlauben. Das hätte allerdings zur Folge, dass die Oberklasse nicht mehr außerhalb des Pakets sichtbar wäre, was in den meisten Fällen unterwünscht ist.

    Versuchen wir einmal „final“ in unserem Beispiel einzusetzen. Wir markieren die Klassen Circle, TranspRectangle, FilledRectangle und Square als final (zur Erinnerung: WeirdShape soll als einzige Klasse erweiterbar bleiben).

    Die Erweiterungsmöglichkeiten unserer Klassenhierarchie werden dadurch wie in folgendem Klassendiagramm dargestellt eingeschränkt:

    Klassenhierarchie mit "final" einschränken
    Klassenhierarchie mit „final“ einschränken

    Um die Übersicht zu verbessern, habe ich in der folgenden Grafik die durchgestrichenen Kästchen unter den finalen Klassen entfernt:

    Klassenhierarchie mit "final" einschränken
    Klassenhierarchie mit „final“ einschränken

    Damit sind wir auf einem guten Weg, aber noch lange nicht am Ziel. Was jetzt? Shape und Rectangle können wir offensichtlich nicht final machen, denn von diesen Klassen sollen ja andere erben.

    An dieser Stelle kommen die Sealed Classes zum Einsatz…

    Klassenhierarchie versiegeln mit „sealed“ und „permits“

    Mit „Sealed Classes“ können wir eine sogenannte „versiegelte Klassenhierarchie“ implementieren. Das funktioniert wie folgt:

    • Wir markieren die Klasse, deren Unterklassen wir restriktieren wollen, mit dem Keyword sealed.
    • Mit dem Keyword permits listen wir die erlaubten Unterklassen auf.

    Wir erweitern den Code der Klassen Shape und Rectangle wie folgt:

    public sealed class Shape permits Circle, Square, Rectangle, WeirdShape { ... }
    
    public sealed class Rectangle extends Shape permits TranspRectangle, FilledRectangle { ... }Code-Sprache: Java (java)

    Wir sagen mit diesem Code das Folgende aus:

    • Die Klasse Shape darf nur durch die Klassen Circle, Square, Rectangle und WeirdShape erweitert werden.
    • Die Klasse Rectangle darf nur durch die Klassen TranspRectangle und FilledRectangle erweitert werden.

    Das folgende Klassendiagramm zeigt die durch sealed und permits hinzugekommenen Einschränkungen:

    Klassenhierarchie mit "sealed" und "permits" einschränken
    Klassenhierarchie mit „sealed“ und „permits“ einschränken

    Der Übersicht halber, hier noch einmal ohne die durchgestrichenen Klassen:

    Klassenhierarchie mit "sealed" und "permits" einschränken
    Klassenhierarchie mit „sealed“ und „permits“ einschränken

    Es sieht so aus, als hätten wir damit unser Ziel erreicht. Doch ein Schritt fehlt noch…

    Versiegelte Klassenhierarchie mit „non-sealed“ öffnen

    Durch die bisherigen Änderungen sieht unser Code wie folgt aus:

    public sealed class Shape permits Circle, Square, Rectangle, WeirdShape { ... }
    
    public final  class Circle     extends Shape { ... }
    public sealed class Rectangle  extends Shape permits TranspRectangle, FilledRectangle { ... }
    public final  class Square     extends Shape { ... }
    public        class WeirdShape extends Shape { ... }
    
    public final class TranspRectangle extends Rectangle { ... }
    public final class FilledRectangle extends Rectangle { ... }Code-Sprache: Java (java)

    Wenn wir versuchen diesen Code zu compilieren, erhalten wir die folgende Fehlermeldung:

    $ javac *.java
    WeirdShape.java:3: error: sealed, non-sealed or final modifiers expected
    public class WeirdShape extends Shape {
           ^Code-Sprache: Klartext (plaintext)

    Um versehentliche Öffnungen der versiegelten Klassenhierarchie zu verhindern, müssen alle Klassen der Hierarchie mit sealed, non-sealed oder final markiert werden.

    Unsere Klasse WeirdShape soll erweiterbar sein, d. h. an dieser Klasse soll die Versiegelung geöffnet werden. Dazu müssen wir diese Klasse mit non-sealed markieren:

    public non-sealed class WeirdShape extends Shape { ... }Code-Sprache: Java (java)

    Unsere abschließende Klassenhierarchie sieht damit so aus:

    Versiegelte Klassenhierarchie mit "non-sealed" öffnen
    Versiegelte Klassenhierarchie mit „non-sealed“ öffnen

    Prüfen, ob eine Klasse versiegelt ist und welche Klassen sie erweitern können

    Die Klasse Class wurde um die folgenden zwei Methoden erweitert:

    • isSealed() – gibt true zurück, wenn diese Klasse oder dieses Interface versiegelt ist.
    • getPermittedSubclasses() – gibt ein Array der Klassen oder Interfaces zurück, die diese Klasse bzw. dieses Interface erweitern dürfen, bzw. null, wenn diese Klasse/dieses Interface nicht versiegelt ist.

    Besonderheiten

    Bei der Verwendung von versiegelten Klassenhierarchien gilt es einige Besonderheiten zu beachten.

    Versiegelung innerhalb einer „Compilation Unit“

    Das Keyword permits kann weggelassen werden, wenn innerhalb einer Klassendatei („compilation unit“) von einer versiegelten Klasse abgeleitete Unterklassen definiert werden. Diese gelten dann als „implizit deklarierte zulässige Unterklassen“ („implicitly declared permitted subclasses“).

    In folgendem Beispiel ist ChildInSameCompilationUnit eine solche Unterklasse; das permits-Keyword darf daher weggelassen werden:

    public sealed class SealedParentWithoutPermits {
    
      public final class ChildInSameCompilationUnit extends SealedParentWithoutPermits {
        // ...
      }
    
    }Code-Sprache: Java (java)

    Lokale Klassen

    Lokale Klassen (also innerhalb von Methoden definierte Klassen) dürfen versiegelte Klassen nicht erweitern.

    Der folgende Code zeigt eine lokale Klasse, die eine nicht versiegelte Klasse erweitert. Dieser Code ist gültig:

    public class NonSealedParent {
    
      public void doSomethingSmart() {
        class LocalChild extends NonSealedParent {  // Allowed
          // ...
        }
        // ...
      }
    }Code-Sprache: Java (java)

    Wenn die äußere Klasse allerdings versiegelt ist, darf die lokale Klasse nicht von ihr erben (auch nicht, wenn diese in der permits-Liste angegeben ist):

    public sealed class SealedParent {
    
      public void doSomethingSmart() {
        class LocalChild extends SealedParent {  // Not allowed
          // ...
        }
        // ...
      }
    }Code-Sprache: Java (java)

    instanceof-Tests mit versiegelten Klassen

    Bei instanceof-Tests prüft der Compiler, ob die Klassenhierarchie es zulässt, dass der Check jemals true ergeben kann. Ist das nicht der Fall, meldet der Compiler einen „incompatible types“-Fehler, wie z. B. in folgendem Code:

    Number n = getNumber();
    if (n instanceof String) {  // Not allowed
      // ...
    }Code-Sprache: Java (java)

    Ein Number-Objekt kann nie eine Instanz eines Strings sein. Der Compiler meldet daher:

    incompatible types: Number cannot be converted to String

    Auch die Informationen aus versiegelten Klassenhierarchien werden mit in diese Prüfung aufgenommen. Was das bedeutet, erkläre ich am besten an einem Beispiel:

    Nehmen wir an, wir haben ein Interface A und eine Klasse B:

    interface A {}
    class B {}Code-Sprache: Java (java)

    Damit ist folgender Check valide:

    public boolean isAaB(A a) {
      return a instanceof B;
    }Code-Sprache: Java (java)

    Wie kann dieser Check true ergeben? Indem wir eine Klasse C definieren, die von B erbt und A implementiert:

    class C extends B implements A {}Code-Sprache: Java (java)

    Der Check isAaB(new C()) ergibt dann true.

    Nun versiegeln wir das Interface A und erlauben als Unterklasse nur noch AChild; Klasse B lassen wir unverändet:

    sealed interface A permits AChild {}
    final class AChild implements A {}
    class B {}Code-Sprache: Java (java)

    Der Compiler erkennt nun, dass ein Objekt vom Typ A niemals auch eine Instanz von B sein kann. Entsprechend wird die Prüfung if (a instanceof B) ab sofort mit folgendem Compilerfehler quittiert:

    incompatible types: A cannot be converted to B

    Contextual Keywords

    Die Einführung neuer Keywords wie sealed, non-sealed, permits (oder auch yield aus den Switch-Expressions) warf bei den JDK-Entwicklern folgende Frage auf: Was soll mit bestehendem Code passieren, der diese Keywords als Methoden- oder Variablennamen verwendet?

    Da Java einen hohen Wert auf Abwärtskompatibilität legt, entschied man sich dazu bestehenden Code möglichst nicht zu beeinträchtigen. Möglich machen das sogenannte „Contextual Keywords“ – Keywords, die nur in einem bestimmten Kontext eine Bedeutung haben.

    Die Begriffe sealed und permits z. B. sind solche „Contextual Keywords“ und haben nur im Kontext der Klassendefinition eine Bedeutung. In anderen Kontexten können sie als Methoden- oder Klassenname verwendet werden. Folgendes ist also erlaubt:

    public void sealed() {
      int permits = 5;
    }Code-Sprache: Java (java)

    Vollständigkeitsanalyse bei „Pattern Matching for switch“

    In Java 17 wurde „Pattern Matching for switch“ als Preview-Feature vorgestellt. In Kombination mit diesem Feature erlauben versiegelte Klassen eine Erschöpfungsanalyse, d. h. der Compiler kann prüfen, ob ein switch-Statement oder -Ausdruck alle möglichen Fälle abdeckt.

    Hier eine kleine Klassenhierarchie mit einem versiegelten Interface als Wurzel:

    public sealed interface Color permits Red, Blue {}
    public final class Red implements Color {}
    public final class Blue implements Color {}Code-Sprache: Java (java)

    „Pattern Matching for switch“ ermöglicht Code wie den folgenden:

    Color color = getColor();
    switch (color) {
      case Red  r -> ...
      case Blue b -> ...
    }
    Code-Sprache: Java (java)

    Der Compiler erkennt, dass das Objekt color nur eine Instanz von Red oder Blue sein kann; das switch-Statement ist also vollständig und benötigt keinen default-Fall.

    Ein weiterer Vorteil ist, dass wir bei einer eventuellen späteren Erweiterung der Klassenhierachie vom Compiler auf den fehlenden switch-Fall hingewiesen werden.

    Erweitern wir unsere Klassenhierarchie um die Farbe grün (nicht vergessen: die permits-Liste von Color erweitern):

    public sealed interface Color permits Red, Blue, Green {}
    public final class Red implements Color {}
    public final class Blue implements Color {}
    public final class Green implements Color {}Code-Sprache: Java (java)

    Beim Versuch das switch-Statement zu compilieren bricht der Compiler jetzt mit folgender Fehlermeldung ab:

    $ javac --enable-preview -source 17 SwitchTest.java
    SwitchTest.java:6: error: the switch statement does not cover all possible input values
    switch (color) {
    ^
    Code-Sprache: Klartext (plaintext)

    Der Compiler kann uns also bei versiegelten Klassenhierarchien helfen unvollständige switch-Statements oder -Ausdrücke – eine häufige Fehlerursache bei der Erweiterung von Klassenhierarchien – zu vermeiden.

    Fazit

    Versiegelte Klassen wurden durch JDK Enhancement Proposal 409 in Java 17 eingeführt. Sie erlauben uns eine Klassenhierarchie vor ungewünschten Erweiterungen zu schützen.

    Für das in 17 als Preview-Feature eingeführte „Pattern Matching for Switch“ ermöglichen sie darüber hinaus eine Vollständigkeitsanalyse.

    Sealed Classes wurden zusammen mit anderen neuen Sprachfeatures wie Records, Switch Expressions, Text Blocks und Pattern Matching in Projekt Amber entwickelt.

  • Java Records (mit Beispielen)

    Java Records (mit Beispielen)

    Records sind eine von zwei großen Neuerungen in Java 16 (die zweite ist „Pattern Matching for instanceof“). In diesem Artikel erfährst du:

    • Was sind Java Records, und wofür brauchen wir sie?
    • Wie implementiert und benutzt man Records in Java?
    • Wie kann man einen Java Record um zusätzliche Funktionen erweitern?
    • Was ist im Zusammenhang mit der Vererbung wichtig?
    • Was ist bei der Serialisierung und Deserialisierung von Records zu beachten?
    • Wozu braucht man Records, wenn man dessen Komponenten auch von der IDE … oder von Lombok erzeugen lassen kann?

    Fangen wir mit einem Beispiel aus den Zeiten vor Records an…

    Wofür brauchen wir Records?

    Nehmen wir an, wir wollen eine unveränderliche Klasse Point erstellen, mit x– und y-Koordinaten und allem, was man braucht, um diese Klasse sinnvoll einzusetzen. Wir wollen Point-Objekte erstellen, deren Felder auslesen und sie in Sets speichern oder als Keys in Maps verwenden können.

    Heraus käme dabei in etwa der folgende Code:

    public class Point {
      private final int x;
      private final int y;
    
      public Point(int x, int y) {
        this.x = x;
        this.y = y;
      }
    
      public int getX() {
        return x;
      }
    
      public int getY() {
        return y;
      }
    
      @Override
      public boolean equals(Object obj) {
        if (obj == this) return true;
        if (obj == null || obj.getClass() != this.getClass()) return false;
        Point that = (Point) obj;
        return this.x == that.x && this.y == that.y;
      }
    
      @Override
      public int hashCode() {
        return Objects.hash(x, y);
      }
    
      @Override
      public String toString() {
        return "Point[x=%d, y=%d]".formatted(x, y);
      }
    }Code-Sprache: Java (java)

    Das ist schon eine ganze Menge Boilerplate-Code für die Anforderung „eine Klasse mit x- und y-Werten“.

    Wer Lombok in seinen Projekten einsetzen wollte und durfte, war klar im Vorteil. Lombok kann Konstruktoren, Getter, equals()-, hashCode()– und toString()-Methoden automatisch erstellen. Der Code reduziert sich dadurch auf wenige Zeilen:

    @AllArgsConstructor
    @Getter
    @EqualsAndHashCode
    @ToString
    public class Point {
      private final int x;
      private final int y;
    }Code-Sprache: Java (java)

    Das ist schon deutlich komfortabler. Lombok ist ausgereift und integriert sich nahtlos in fast jede IDE. Ich setze es seit über zehn Jahren immer wieder gerne ein.

    Seit Java 16 geht es allerdings noch kürzer:

    public record Point(int x, int y) {}
    Code-Sprache: Java (java)

    Mit Records wird aus den ursprünglich 22 Zeilen – bzw. den 7 Zeilen mit Lombok – nur noch eine Zeile! Das ist nicht nur kürzer, sondern auch sicherer (s. Abschnitte Java Records vs. Klassen und Java Records vs. Lombok).

    Schauen wir uns im Folgenden an, wie genau man Records schreibt und wie man sie nutzt.

    Wie implementiert und benutzt man Records in Java?

    Im vorherigen Abschnitt haben wir gesehen, wie man einen Record mit nur einer Zeile Code schreibt:

    public record Point(int x, int y) {}Code-Sprache: Java (java)

    Der Compiler generiert daraus die Klasse Point mit:

    • den finalen Felder int x und int y (den sogenannten „Komponenten“ des Records),
    • einem Konstruktor, der beide Felder setzt (dem sogenannten „kanonischen Konstruktor“),
    • den Accessor-Methoden x() und y() zum Lesen der Komponenten,
    • einer equals()-Methode, die zwei Point-Instanzen als gleich einstuft, wenn deren x– und y-Koordinaten gleich sind,
    • einer hashCode()-Methode, die für zwei gleiche Point-Instanzen den gleichen Hashwert liefert (im Point-Beispiel wird der Hashcode als x * 31 + y berechnet),
    • einer toString()-Methode, die einen lesbaren Text liefert (im Beispiel „Point[x=…, y=…]“).

    Du kannst Point wie eine reguläre Klasse einsetzen (in der ersten Zeile des folgenden Code-Beispiels wird der oben erwähnte, automatisch generierte „kanonische Konstruktor“ aufgerufen):

    Point p = new Point(5, 10);
    int x = p.x();
    int y = p.y();Code-Sprache: Java (java)

    Vergleichen kannst du zwei Punkte dann bspw. wie folgt:

    Point p1 = new Point(8, 4);
    Point p2 = new Point(4, 3);
    if (p1.equals(p2)) {
      // ...
    }Code-Sprache: Java (java)

    Java Record Konstruktoren

    Im vorangegangenen Abschnitt hast du gelernt, dass der Compiler automatisch einen Konstruktor erzeugt, den sogenannten kanonischen Konstruktor. In diesem Kapitel erfährst du, wie du diesen kanonischen Konstruktor überschreiben kannst, wie du sogenannte „kompakte“ Konstruktoren schreiben kannst – und beliebige weitere nicht-kanonische Konstruktoren.

    Kanonischen Konstruktor eines Records überschreiben

    Wir können den kanonischen Konstruktor eines Records auch selbst implementieren:

    public record Point(int x, int y) {
      /** Canonical constructor as the compiler would generate it */
      public Point(int x, int y) {
        this.x = x;
        this.y = y;
      }
    }Code-Sprache: Java (java)

    Sinn macht das aber erst, wenn wir vor oder nach der Zuweisung der Record-Felder zusätzlichen Code ausführen – z. B. könnten wir sicherstellen wollen, dass die Koordinaten nicht negativ sind:

    public record Point(int x, int y) {
      /** Canonical constructor */
      public Point(int x, int y) {
        if (x < 0 || y < 0) throw new IllegalArgumentException();
    
        this.x = x;
        this.y = y;
      }
    }Code-Sprache: Java (java)

    Neben der Validierung könnten wir auch Parameter transformieren oder z. B. eine defensive Kopie eines Arrays erzeugen.

    Wichtig ist bei dieser Form, dass die Konstruktor-Signatur exakt die gleiche ist wie die des Records. Folgendes ist hingegen nicht erlaubt:

    public record Point(int x, int y) {
      public Point(int a, int b) {  // Other names than x and y are not allowed!
        this.x = a;
        this.y = b;
      }
    }Code-Sprache: Java (java)

    Der Compiler würde das mit folgender Fehlermeldung quittieren:

    $ javac Point.java
    Point.java:4: error: invalid canonical constructor in record Point
        public Point(int a, int b) {
               ^
      (invalid parameter names in canonical constructor)
    1 error
    Code-Sprache: Klartext (plaintext)

    Ebenso wichtig ist, dass alle Felder gesetzt werden (logisch, sie sind ja final). Würden wir nur x setzen, nicht aber y, dann würde der Compiler mit folgender Meldung abbrechen:

    $ javac Point.java
    Point.java:4: error: variable y might not have been initialized
        }
        ^
    1 error
    Code-Sprache: Klartext (plaintext)

    Interessanterweise muss keine 1:1-Zuordnung der Konstruktor-Parameter zu den Felder erfolgen. Es müssen nicht einmal alle Parameter verwendet werden. So ist auch folgender Code gültig:

    public record Point(int x, int y) {
      public Point(int x, int y) {
        this.x = x;
        this.y = x;  // Assigning this.y to x here - and ignoring y
      }
    }Code-Sprache: Java (java)

    Glücklicherweise erkennen moderne IDEs das. IntelliJ z. B. warnt hier mit „’x‘ should probably not be assigned to ‚y’“.

    Schließlich darf die Sichtbarkeit des kanonischen Konstruktors nicht restriktiver sein als die Sichtbarkeit des Records selbst. Das bedeutet, dass ein als private gekennzeichneter Record einen als public markierten Konstruktor haben kann – ein als public deklarierter Record darf andersherum jedoch keinen privaten Konstruktor haben – folgendes ist also nicht erlaubt:

    public record Point(int x, int y) {
      private Point(int x, int y) {  // private constructor not allowed for public record
        this.x = x;
        this.y = y;
      }
    }Code-Sprache: Java (java)

    Der Compiler würde das mit der folgenden Fehlermeldung quittieren:

    javac Point.java
    Point.java:2: error: invalid canonical constructor in record Point
      private Point(int x, int y) {
              ^
      (attempting to assign stronger access privileges; was public)
    1 errorCode-Sprache: Klartext (plaintext)

    Kompakter Konstruktor („Compact Constructor“)

    Es gibt noch eine prägnantere Variante, um den kanonischen Konstruktor zu überschreiben. Man kann die Parameter in der Signatur und die Zuweisungen komplett weglassen. Diese Art wird kompakter Konstruktor („compact constructor“) genannt:

    public record Point(int x, int y) {
      /** Compact constructor */
      public Point { // ← No parameters here
        if (x < 0 || y < 0) throw new IllegalArgumentException();
        // ← No assignments here
      }
    }Code-Sprache: Java (java)

    Der Compiler fügt die Zuweisungen this.x = x und this.y = y automatisch am Ende des Konstruktors ein und erzeugt damit letztendlich aus dem kompakten Konstruktor exakt denselben Bytecode wie aus dem im vorherigen Abschnitt als zweites gezeigten kanonischen Konstruktor.

    Die Paramter dürfen innerhalb des Konstruktors auch verändert werden, z. B. könnten wir stillschweigend alle negativen Werte durch 0 ersetzen:

    public record Point(int x, int y) {
      /** Compact constructor */
      public Point {
        x = Math.max(x, 0);
        y = Math.max(y, 0);
      }
    }Code-Sprache: Java (java)

    Das würde dem folgenden kanonischen Konstruktor entsprechen:

    public record Point(int x, int y) {
      /** Canonical constructor */
      public Point(int x, int y) {
        x = Math.max(x, 0);
        y = Math.max(y, 0);
        this.x = x;
        this.y = y;
      }
    }Code-Sprache: Java (java)

    Beide Formen des Konstruktors sind letztendlich gleich, und es darf nur entweder ein kanonischer oder ein kompakter Konstruktor implementiert werden.

    Meine Empfehlung ist es, immer einen kompakten Konstruktor zu verwenden. Schließlich wollen wir Programmiererinnen und Programmierer unsere Ideen zum Ausdruck bringen – und nicht unnötigen Boilerplate-Code schreiben.

    Moderne IDEs wie IntelliJ können mit einem Klick einen kanonischen Konstruktor in einen kompakten Konstruktor umwandeln – und umgekehrt.

    Achtung: Da die Komponenten des Records erst am Ende des Konstruktors gesetzt werden, sollte man nicht innerhalb des Konstruktors auf die Accessor-Methoden – im Beispiel x() und y() – zugreifen. Die Komponenten sind zu diesem Zeitpunkt noch mit Default-Werten belegt (im Fall von int also mit 0).

    Man sollte stattdessen auf die (nicht explizit angegebenen) Konstruktor-Parameter zugreifen:

    public record Point(int x, int y) {
      /** Compact constructor */
      public Point {
        System.out.println(x());  // Prints 0 (fields are not yet assigned)
        System.out.println(x);    // Prints the x parameter passed to the constructor
      }
    }Code-Sprache: Java (java)

    Deutlich wird das, wenn man innerhalb des Konstruktors versucht über this auf die Felder zuzugreifen:

    public record Point(int x, int y) {
      /** Compact constructor */
      public Point {
        System.out.println(this.x);  // Not allowed - x is not yet initialized
      }
    }
    Code-Sprache: Java (java)

    Dieser Code führt zu einem Compilerfehler:

    javac Point.java
    Point.java:3: error: variable x might not have been initialized
        System.out.println(this.x);
                               ^
    1 error
    Code-Sprache: Klartext (plaintext)

    Konsistent wäre es gewesen, auch den Zugriff über x() und y() im Konstruktor zu verbieten.

    Zusätzliche Konstruktoren in Records

    Records können um zusätzliche Konstruktoren erweitert werden, wie z. B. einen Default-Konstruktor (einen ohne Parameter) oder einen, der x und y auf denselben Wert setzt:

    public record Point(int x, int y) {
      /** Default constructor */
      public Point() {
        this(0, 0);
      }
    
      /** Custom constructor */
      public Point(int value) {
        this(value, value);
      }
    }Code-Sprache: Java (java)

    Dabei müssen immer a) alle Felder gesetzt werden und b) muss das, wie im vorangegangenen Beispiel gezeigt, durch Delegation an den kanonischen Konstruktor per this(...) geschehen.

    Die Werte direkt zuzuweisen, wie in folgendem Code, ist hingegen erlaubt:

    public record Point(int x, int y) {
      /** Default constructor */
      public Point() {
        this.x = 0;  // Not allowed!
        this.y = 0;  // Not allowed!
      }
    
      /** Custom constructor */
      public Point(int value) {
        this.x = value;  // Not allowed!
        this.y = value;  // Not allowed!
      }
    }Code-Sprache: Java (java)

    Der Grund dafür ist, dass immer – egal welchen Konstruktor man verwendet – die möglicherweise im kanonischen oder kompakten Konstruktor implementierten Parameter-Validierungen aufgerufen werden sollen.

    Statische Felder in Records

    Records können um statische Felder (finale sowie nicht-finale) erweitert werden. Beispielsweise könnten wir die 0 aus dem oben gezeigten Default-Konstruktor in eine Konstante extrahieren:

    public record Point(int x, int y) {
      private static final int ZERO = 0;
    
      public Point() {
        this(ZERO, ZERO);
      }
    }Code-Sprache: Java (java)

    Wir könnten z. B. auch einen statischen Instanz-Counter hinzufügen, der im Konstruktor hochgezählt wird:

    public record Point(int x, int y) {
      private static long instanceCounter = 0;
    
      public Point {
        synchronized (Point.class) {
          instanceCounter++;
        }
      }
    }Code-Sprache: Java (java)

    Tatsächlich würde ich so einen Counter eher als AtomicLong oder LongAdder implementieren – die wären dann allerdings wieder final und damit nicht als Beispiel für ein nicht-finales statischen Feld geeignet. ;-)

    Methoden in Records

    Genau wie den kanonischen Konstruktor können wir auch die automatisch generierten Accessor-Methoden eines Records überschreiben. Der folgende Record enthält eine Array-Komponente und erzeugt im Konstruktor und im Accessor jeweils eine defensive Kopie des Arrays, um Änderungen am im Record gespeicherten Array zu verhindern:

    public record ImmutableArrayHolder(int[] array) {
      /* Compact constructor */
      public ImmutableArrayHolder {
        array = array.clone();
      }
    
      /* Accessor method */
      public int[] array() {
        return array.clone();
      }
    }Code-Sprache: Java (java)

    Neben zusätzlichen Konstruktoren und statischen Feldern lassen sich in Java-Records auch zusätzliche statische und nicht-statische Methoden definieren.

    Die folgende statische Methode gibt den Wert des Instanz-Zählers zurück:

    public record Point(int x, int y) {
      private static long instanceCounter = 0;
    
      // ... Constructor increasing instanceCounter ...
    
      public static synchronized long getInstanceCounter() {
        return instanceCounter;
      }
    }Code-Sprache: Java (java)

    Um Point threadsicher zu machen, wird jeglicher Zugriff auf instanceCounter synchronisiert. Das schließt auch den Getter mit ein, um sicherzustellen, dass wir nicht einen im CPU-Core-Cache zwischengespeicherten Wert erhalten, sondern immer den aktuellen Wert aus dem Hauptspeicher.

    Eine nicht-statische, also eine Instanz-Methode, könnten wir z. B. implementieren, um den euklidischen Abstand zu einem anderen Punkt zu berechnen:

    public record Point(int x, int y) {
      public double distanceTo(Point target) {
        int dx = target.x() - this.x();
        int dy = target.y() - this.y();
        return Math.sqrt(dx * dx + dy * dy);
      }
    }Code-Sprache: Java (java)

    Aufrufen können wir die Methode z. B. wie in folgendem Beispiel:

    Point p1 = new Point(17, 3);
    Point p2 = new Point(18, 12);
    double distance = p1.distanceTo(p2);
    Code-Sprache: Java (java)

    Bei der Implementierung und dem Aufruf von Record-Methoden gibt es keinen Unterschied zu normalen Klassen.

    Records und Vererbung

    Records können Interfaces implementieren:

    public interface WithXCoordinate {
      int x();
    }
    
    public record Point(int x, int y) implements WithXCoordinate {}Code-Sprache: Java (java)

    Das ist auch in Kombination mit den in Java 17 veröffentlichten Sealed Types möglich:

    public interface WithXCoordinate permits Point, Point3D {
      int x();
    }
    
    public record Point(int x, int y) implements WithXCoordinate {}
    
    public record Point(int x, int y, int z) implements WithXCoordinate {}Code-Sprache: Java (java)

    Records können hingegen nicht von Klassen erben. Das folgende ist also nicht erlaubt:

    public class TaggedElement {
      private String tag;
    }
    
    public record Point(int x, int y) extends TaggedElement {}  // Not allowed!Code-Sprache: Java (java)

    Das liegt daran, dass Records bereits von der Klasse java.lang.Record erben – und dass sie unveränderlich sein sollen. Das wären sie nicht, wenn sie von einer veränderlichen Klasse erben würden.

    Records Sie sind außerdem implizit final, man kann also auch nicht von ihnen erben. Folgender Code ist also ebenfalls ungültig:

    public record Point(int x, int y) {}
    
    public class TaggedPoint extends Point {  // Not allowed!
      private String tag;
    
      TaggedPoint(int x, int y, String tag) {
        super(x, y);
        this.tag = tag;
      }
    }Code-Sprache: Java (java)

    Besonderheiten von Records

    Im Vergleich zu regulären Klassen sollte man bei Records einige Besonderheiten kennen. Diese erkläre ich in den nächsten Abschnitten.

    Lokale Records

    Records dürfen auch lokal (d. h. innerhalb von Methoden) definiert werden. Das kann insbesondere dann hilfreich sein, wenn man Zwischenergebnisse mit mehreren zusammengehörigen Variablen speichern möchte.

    Im folgenden Beispiel definieren wir innerhalb der findFurthestPoint()-Methode den lokalen Record PointWithDistance: eine Kombination aus einem Point und einem double-Wert, der die Entfernung des Punktes zu einem Ursprungspunkt repräsentiert.

    Mit Hilfe des lokalen Records füllen wir eine Liste von Punkten und deren Entfernungen zum aktuellen Punkt. Aus dieser Liste ermitteln wir dann denjenigen PointWithDistance mit der größten Distanz – um daraus wiederum den zugehörigen Point zu extrahieren.

    public Point findFurthestPoint(Point origin, Point... points) {
      record PointWithDistance(Point point, double distance) {}
      
      List<PointWithDistance> pointsWithDistance = new ArrayList<>();
      for (Point point : points) {
        double distance = origin.distanceTo(point);
        pointsWithDistance.add(new PointWithDistance(point, distance));
      }
    
      PointWithDistance furthestPointWithDistance = Collections.max(
          pointsWithDistance,
          Comparator.comparing(PointWithDistance::distance));
    
      return furthestPointWithDistance.point();
    }Code-Sprache: Java (java)

    Records innerhalb von inneren Klassen

    Records dürfen auch innerhalb von inneren Klassen definiert werden:

    class OuterClass {
      // ...
    
      class InnerClass {
        record InnerClassRecord(String foo, int bar) {}
    
        // ...
      }
    }Code-Sprache: Java (java)

    Diese Möglichkeit ist insofern erwähnenswert, als dass sie erst mit dem finalen Release von Records durch JDK Enhancement Proposal 395 ermöglicht wurde.

    Records und Reflection

    Finale Felder von regulären Klassen können problemlos per Reflection verändert werden. Im folgenden Code ist Point die Klasse vom Beginn dieses Artikels.

    Point point = new Point(10, 5);
    System.out.println("point = " + point);
    
    Field xField = Point.class.getDeclaredField("x");
    xField.setAccessible(true);
    System.out.println("point.x = " + xField.get(point));
    
    xField.set(point, 55);
    System.out.println("point = " + point);Code-Sprache: Java (java)

    Der Code gibt folgendes aus:

    point = Point[x=10, y=5]
    point.x = 10
    point = Point[x=55, y=5]Code-Sprache: Klartext (plaintext)

    Das bedeutet, wir haben per Reflection das eigentlich private und finale x-Feld der Point-Klasse ausgelesen und geändert!

    Wenn wir den gleichen Code mit dem PointRecord aufrufen, dann erhalten wir folgende Ausgabe:

    point = Point[x=10, y=5]
    point.x = 10
    Exception in thread "main" java.lang.IllegalAccessException: 
    Can not set final int field eu.happycoders.records.Point.x to java.lang.IntegerCode-Sprache: Klartext (plaintext)

    Wir können also auch bei Records per Reflection private Felder auslesen (und damit z. B. einen Accessor umgehen, der eine defensive Kopie einer veränderlichen Komponente, wie z. B. einem Array, erzeugt).

    Records sind aber im Gegensatz zu Klassen geschützt vor Veränderungen durch Reflection.

    Records deserialisieren

    Records haben eine Besonderheit beim Deserialisieren. Diese zeige ich an folgendem Beispiel.

    Erweitern wir den Point-Konstruktor zunächst um eine Parameter-Validierung. Sagen wir, wir wollen negative Werte ausschließen. Wir verwenden die kompakte Notation für die Überschreibung des kanonischen Konstruktors. Außerdem machen wir die Klasse serialisierbar:

    public record Point(int x, int y) implements Serializable {
      @Serial private static final long serialVersionUID = -1482007299343243215L;
    
      public Point {
        if (x < 0) throw new IllegalArgumentException("x must be >= 0");
        if (y < 0) throw new IllegalArgumentException("y must be >= 0");
      }
    }Code-Sprache: Java (java)

    Um den Unterschied beim Deserialisieren zu sehen, erstellen wir eine analoge reguläre Klasse PointClass:

    public final class PointClass implements Serializable {
      @Serial private static final long serialVersionUID = 8411630734446201523L;
    
      private final int x;
      private final int y;
    
      public Point(int x, int y) {
        if (x < 0) throw new IllegalArgumentException("x must be >= 0");
        if (y < 0) throw new IllegalArgumentException("y must be >= 0");
    
        this.x = x;
        this.y = y;
      }
    
      // ... getters, equals(), hashCode(), toString() ...
    }Code-Sprache: Java (java)

    Wir kommentieren die Parameter-Validierung vorübergehend aus und serialisieren mit folgendem Code einen ungültigen Point-Record und eine ungültige PointClass-Klasse in jeweils eine Datei:

    PointClass pc = new PointClass(-5, 5);  // Parameter validation temporarily commented
    try (FileOutputStream fileOut = new FileOutputStream("point-class.bin");
        ObjectOutputStream objectOut = new ObjectOutputStream(fileOut)) {
      objectOut.writeObject(pc);
    }
    
    Point p = new Point(-5, 5);  // Parameter validation temporarily commented
    try (FileOutputStream fileOut = new FileOutputStream("point-record.bin");
        ObjectOutputStream objectOut = new ObjectOutputStream(fileOut)) {
      objectOut.writeObject(p);
    }Code-Sprache: Java (java)

    Danach kommentieren wir die Parameter-Prüfung wieder ein und versuchen die serialisierten Objekte zu deserialisieren:

    try (FileInputStream fileIn = new FileInputStream("point-class.bin");
        ObjectInputStream objectIn = new ObjectInputStream(fileIn)) {
      PointClass pointClass = (PointClass) objectIn.readObject();
      System.out.println("pointClass = " + pointClass);
    }
    
    try (FileInputStream fileIn = new FileInputStream("point-record.bin");
        ObjectInputStream objectIn = new ObjectInputStream(fileIn)) {
      Point point = (Point) objectIn.readObject();
      System.out.println("point = " + point);
    }Code-Sprache: Java (java)

    Das Egebnis macht einen weiteren Unterschied zwischen Records und Klassen sichtbar:

    pointClass = PointClass{x=-5, y=5}
    Exception in thread "main" java.io.InvalidObjectException: x must be >= 0
    	at ...
    Caused by: java.lang.IllegalArgumentException: x must be >= 0
    	at records.Point.<init>(Point.java:10)Code-Sprache: Klartext (plaintext)

    Die fehlerhafte Klasse lässt sich problemlos deserialisieren – der fehlerhafte Record hingegen nicht.

    Der Grund: Bei der Deserialisierung einer Klasse werden die aus dem ObjectInputStream gelesenen Werte direkt in die Felder der Klasse geschrieben. Bei einem Record hingegen wird der kanonische Konstruktor aufgerufen – und damit die ggf. darin enthaltenen Parameter-Überprüfungen ausgeführt.

    Java Records vs. Klassen

    Ich bekomme oft die Frage gestellt, wozu man Records braucht, wenn man doch einfach Konstruktor, Getter, equals(), hashCode() und toString() von einer IDE generieren lassen kann.

    Zum einen ist es meiner Meinung nach nicht die Aufgabe einer IDE, die Unzulänglichkeiten einer Programmiersprache auszugleichen. Eine Programmiersprache sollte so ausgereift sein, dass wir mit möglichst wenig Code unsere Ideen ausdrücken können – den Rest sollte der Compiler erledigen. Es verwenden auch nicht alle die gleiche IDE, und unterschiedliche IDEs generieren unterschiedlichen Quellcode.

    Records haben weitere, konkrete Vorteile:

    • Die Felder von Records sind wirklich unveränderlich. Während bei einer regulären Klasse finale Felder per Reflection verändert werden können, ist dies bei Records nicht möglich (s. Abschnitt Records und Reflection).
    • Es ist nicht möglich per Deserialisierung ungültige Records zu erzeugen, da bei der Deserialisierung (im Gegensatz zu Klassen) der kanonische Konstruktor des Records aufgerufen wird.
    • Für die Methoden equals(), hashCode() und toString() erzeugt der Compiler einen speziellen Bytecode, der spezielle Implementierungen dieser Methoden in der JVM aufruft. So können diese Methoden in zukünftigen Java-Versionen immer weiter optimiert werden, ohne dass existierender Code neu kompiliert werden muss.
    • Records arbeiten eng mit anderen Sprachmerkmalen zusammen, wie z. B. den in Java 21 finalisierten Records Patterns.

    Java Records vs. Lombok

    Ähnlich oft bekomme ich die Frage gestellt, was der Vorteil von Records gegenüber Lombok ist.

    Meiner Meinung nach sollten wir nicht die Verantwortung für etwas, das eine Sprache können sollte, an eine Library abgeben. Denn das birgt Risiken: Was, wenn die Library nicht weiter gewartet wird? Was, wenn sie nicht an neue Java-Versionen angepasst oder gar komplett eingestellt wird?

    Zudem gelten die gleichen konkreten Nachteilte wie bei regulären Klassen: Auch finale Felder von Klassen können per Reflection verändert werden; durch Deserialisierung können ungültige Instanzen erzeugt werden; equals(), hashCode() und toString() können nicht durch die JVM optimiert werden; und auch Pattern Matching funktioniert nicht mit Lombok-annotierten Klassen.

    Fazit

    Records bieten eine kompakte Notationsmöglichkeit, um Java-Klassen mit ausschließlich finalen Feldern zu definieren. Records enthalten automatisch einen Konstruktor, der alle finalen Felder setzt (den kanonischen Konstruktor), lesende Zugriffsmethoden für alle Felder (die Accessor-Methoden), sowie durch die JVM optimierte equals()-, hashCode()– und toString()-Methoden.

    Records können um weitere Konstruktoren, statische Felder und statische sowie nicht-statische Methoden erweitert werden. Der kanonische Konstruktor kann überschrieben werden.

    Records können Interfaces implementieren (auch versiegelte), aber keine Klassen erweitern, und von ihnen kann auch nicht geerbt werden.

    Beim Deserialisieren von Records wird immer deren kanonischer Konstruktor – und die darin ggf. enthaltenen Parameter-Validierungen – aufgerufen.

    Records wurden in Java 14 und Java 15 als Preview-Features und durch JDK Enhancement Proposal 395 in Java 16 als produktionsreif eingestuft. Records wurden im Rahmen von Projekt Amber entwickelt, innerhalb dessen auch Switch Expressions, Text Blocks und Pattern Matching und Sealed Classes entwickelt wurden.