Records in Java

Java Records

by Sven WoltmannDezember 7, 2021

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?

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 Konstrukturen, 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!

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,
  • einem Konstruktor, der beide Felder setzt (dem sogenannten "kanonischen Konstruktor"),
  • den Methoden x() und y() zum Lesen der Felder,
  • 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:

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)

Erweiterungsmöglichkeiten von Records

Records können um weitere Konstruktoren, statische Felder und Methoden erweitert werden. Mehr dazu in den folgenden Abschnitten.

Zusätzliche Konstruktoren in Records

Records können um zusätzliche Konstrukturen erweitert werden, wie z. B. einen Default-Konstruktor 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 (also den automatisch generierten) Konstruktor per this(...) geschehen.

Die Werte direkt zu setzen, wie in folgendem Code, ist hingegen nicht erlaubt:

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

Statische Felder in Records

Records können um statische Felder (finale sowie nicht-finale) erweitert werden. Beispielsweise könnten wir die 0 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)

Des weiteren könnten wir einen statischen Instanz-Counter hinzufügen, der im Konstruktor hochgezählt wird:

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

Abgesehen davon, dass man einen Counter eher als AtomicLong oder LongAdder implementieren würde (die dann allerdings wieder final wären und damit nicht als Beispiel für ein nicht-finales statischen Feld dienen könnten), wird im vorangegangenen Beispiel nur der Aufruf des zusätzlichen parameterlosen Konstruktors gezählt.

Können wir auch den Aufruf des kanonischen Konstruktors (also des automatisch generierten) zählen?

Kanonischen Konstruktor eines Records überschreiben

Ja, wir können den kanonischen Konstruktor auch selbst implementieren und vor oder nach der Zuweisung der Record-Felder zusätzlichen Code ausführen – z. B. den instanceCounter erhöhen:

public record Point(int x, int y) { private static int instanceCounter; /** Canonical constructor */ public Point(int x, int y) { this.x = x; this.y = y; synchronized (Point.class) { instanceCounter++; } } }
Code-Sprache: Java (java)

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

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)

Kompakter Konstruktor ("Compact Constructor")

Es gibt noch eine prägnantere Variante 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) { private static int instanceCounter; /** Compact constructor */ public Point { synchronized (Point.class) { instanceCounter++; } } }
Code-Sprache: Java (java)

Achtung: Bei dieser Variante werden die Felder erst am Ende des Konstruktors zugewiesen. Wenn man innerhalb des Konstruktors darauf zugreift, sind sie noch mit Default-Werten (im Fall von int also mit 0) belegt.

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() im Konstruktor nicht zu erlauben.

Methoden in Records

Außer zusätzlichen Konstruktoren und statischen Feldern können wir auch statische und nicht-statische Methoden definieren.

Die folgende statische Methode gibt den Wert des Instanz-Zählers zurück (um Point threadsicher zu machen, wird jeglicher Zugriff auf instanceCounter synchronisiert – auch der Getter, um sicherzustellen, dass wir nicht einen z. B. im CPU-Core-Cache zwischengespeicherten Wert erhalten, sondern den aktuellen Wert aus dem Hauptspeicher).

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

Eine nicht-statische 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 releasten 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)

Records Sie sind 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 deserialisieren

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

Erweitern wir den Point-Konstruktor zunächst um eine Parameter-Prüfung. 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-Prüfung 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 überrascht und 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 enthaltene Parameter-Überprüfungen ausgeführt.

Fazit

Records bieten eine kompakte Notationsmöglichkeit, um 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, sowie 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 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.

Wenn dir der Artikel gefallen hat, teile ihn gerne über einen der Share-Buttons am Ende oder hinterlasse einen Kommentar. Wenn du informiert werden möchtest, wenn der nächste Artikel auf HappyCoders.eu veröffentlicht wird, klicke hier, um dich für den HappyCoders-Newsletter anzumelden.

Sven Woltmann
About the author
I'm a freelance software developer with more than two decades of experience in scalable Java enterprise applications. My focus is on optimizing complex algorithms and on advanced topics such as concurrency, the Java memory model, and garbage collection. Here on HappyCoders.eu, I want to help you become a better Java programmer. Read more about me here.

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.

You might also like the following articles

Text Blocks in Java Java Text Blocks
Java substring Methode Java substring()-Methode
Java 18 Features mit Beispielen Java 18 Features (mit Beispielen)
Sealed Classes in Java Sealed Classes in Java