Java Records

Ein Blick auf JEP 359
14
Mai

Datenklassen in Java: Einführung in Java Records

Datenklassen, also Java-Klassen, deren einziger Zweck darin besteht, Daten zu halten und diese über Getter und Setter zugänglich zu machen, gehören in vielen Softwareprojekten zu den größten Sammelstellen von Boilerplate-Code. Für jede neue Klasse jeweils Konstruktoren, die Methoden equals, hashCode und toString und für jedes Feld noch einen Getter und einen Setter zu erstellen, ist für viele Entwickler eine verhasste Zeremonie geworden – sofern sie nicht direkt Bibliotheken wie Lombok einsetzen, um dieser zu entgehen. JEP 359 soll Abhilfe schaffen.

Mit JEP 359 werden Records in die JVM eingeführt – wenn auch vorerst nur als Preview Feature. Um sie ausprobieren zu können, müssen sowohl der Compiler als auch das erstellte Programm mit dem Flag –enable-preview ausgeführt werden.

Das Problem, das mit Records gelöst werden soll

Was genau macht das Erstellen von Datenklassen in Java so mühsam? Das Herzstück solcher Klassen ist die definierte Liste von Instanzvariablen, die die Zustandsbeschreibung eines Objekts darstellen. Möchten Entwickler eine Klasse entwerfen, die einen Würfel widerspiegelt, kommen sie mit drei Variablen aus: Höhe, Breite und Tiefe. Um mit Instanzen des Typs Würfel arbeiten zu können, müssen sie allerdings weitere Formalitäten erfüllen: Die Klasse benötigt Konstruktoren, Getter und Setter. Ebenfalls müssen die Methoden equals, hashCode und toString überschrieben werden. Diese gesamte Arbeit läuft in den allermeisten Fällen so gleichförmig ab, dass Entwickler sie automatisiert von ihrer Entwicklungsumgebung vornehmen lassen: Für jede Instanzvariable werden Getter und Setter erstellt, alle sollen bei hashCode, equals und toString berücksichtigt werden (oder schlimmer: hashCode und equals werden erst gar nicht implementiert). Oft wird auch noch ein Konstruktor definiert, der sämtliche Variablen als Parameter enthalten kann, um ein Objekt vollständig initialisiert erstellen zu können. Nach dieser Prozedur hat die Klasse dann knapp 65 Zeilen Code, von denen etwa fünf ausreichen, um die wichtigste Information zu beschreiben – den Namen, den Modifier und welchen Zustand sie hält.

Natürlich können an einem Würfel auch seine Oberfläche, Kantenlänge oder sein Volumen interessant sein – diese Werte leiten sich aber von seiner Zustandsbeschreibung ab und stellen keine neuen Variablen dar. Um das Volumen des Würfels zu erhalten, würden Entwickler eine Methode calculateVolume() bereitstellen, die die Länge, Höhe und Breite des Würfels multipliziert. Auch diese Methoden enthalten Informationen, die für alle Entwickler des Teams wichtig sind, um die Eigenschaften eines Würfels zu verstehen. Sie zwischen den anderen neun Methoden zu identifizieren, die vorhin von der Entwicklungsumgebung generiert wurden, kann auf den ersten Blick unter Umständen schwierig sein. Die Klasse vermischt das „Was“ mit dem „Wie“, sie hat eine unnötig hohe kognitive Komplexität. Mit der Einführung von Records soll exakt dieses Problem vermieden werden, indem Daten auch als Daten modelliert werden und der Zugriff darauf sich aus dem Kontext ergibt.

Verschaffen Sie sich den Zugang zur Java-Welt mit unserem kostenlosen Newsletter!

Aufbau und Struktur von Records

Records sind eine neue Typendeklaration in Java und stellen eine spezialisierte Form einer Klasse dar. Sie sind für einen klar definierten Zweck gedacht, haben gegenüber herkömmlichen Klassen aber Einschränkungen, ähnlich wie Enums. Die Zustandsbeschreibung des Records wird über die Definition von sogenannten Komponenten vorgenommen, die aus einem Typen und einer Bezeichnung bestehen. Ein simpler Record, der einen Würfel mit den Komponenten „Höhe“, „Breite“ und „Tiefe“ beschreibt, findet sich nachfolgend:

public record Wuerfel(int hoehe, int breite, int tiefe) {

}

Eine vergleichbar aufgebaute herkömmliche Klasse zeigt Listing 1.

Listing 1

public final class Wuerfel {

  private final int height;
  private final int width;
  private final int depth;

  public Wuerfel(int height, int width, int depth) {
    this.height = height;
    this.width = width;
    this.depth = depth;
  }

  public int getHeight() {
    return height;
  }

  public int getWidth() {
    return width;
  }

  public int getDepth() {
    return depth;
  }

  @Override
  public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;
    Wuerfel wuerfel = (Wuerfel) o;
    return height == wuerfel.height &&
      width == wuerfel.width &&
      depth == wuerfel.depth;
  }

  @Override
  public int hashCode() {
    return Objects.hash(height, width, depth);
  }

  @Override
  public String toString() {
    return "Wuerfel{" +
      "height=" + height +
      ", width=" + width +
      ", depth=" + depth +
    '}';
  }
}

Auffällig hierbei ist, dass die Komponenten des Records einen Teil seiner Deklaration darstellen und nicht im Körper der Klasse definiert werden. Jede dieser Komponenten entspricht einer implizit definierten, finalen Instanzvariable sowie einer gleichnamigen Zugriffsmethode, die sowohl implizit als auch explizit definiert sein kann. Alle Komponenten sind darüber hinaus Bestandteil des Standardkonstruktors. Das „Wie“, also die Art und Weise, wie auf den Zustand eines Objekts zugegriffen werden kann, leitet sich also aus seiner Definition ab und muss nicht explizit aufgeschrieben werden. Mit folgendem Code kann demnach eine Instanz des oben gezeigten Records-Würfels erstellt und auf den Wert der Höhe zugegriffen werden:

var wuerfel = new Wuerfel(10, 10, 10);
wuerfel.hoehe();
> 10

Records verfügen nicht über Setter, da ihre Instanzvariablen, wie schon beschrieben, implizit final sind und folglich nach der Instanziierung nicht mehr aktualisiert werden können. Darüber hinaus müssen bei Records die Methoden equals, hashCode und toString nicht implementiert werden – sie ergeben sich ebenfalls aus den Komponenten. Bei Bedarf können sie überschrieben und damit angepasst werden.

wuerfel.toString();
> Wuerfel[hoehe=10, breite=10, tiefe=10]

var w1 = new Wuerfel(10, 10, 10);
var w2 = new Wuerfel(10, 10, 10);
w1.equals(w2);
> true

Aus der Tatsache, dass die Zugriffsmethoden denselben Namen tragen wie ihre Komponenten, folgt ein Problem: Einige Komponentennamen würden Methoden überschreiben, die beispielsweise zur Klasse Object gehören, beispielsweise getClass, finalize oder auch toString. Aus diesem Grund sind folgende Bezeichnungen für Records verboten und führen zu einem Compile-Fehler: clone, finalize, getClass, hashCode, notify, notifyAll, readObjectNoData, readResolve, serialPersistentFields, serialVersionUID, toString, wait, writeReplace.

Weitere Attribute von Records

Records können um statische Klassenvariablen ergänzt werden, beispielsweise um Literale zu benennen. Zusätzliche Instanzvariablen hingegen dürfen nicht deklariert werden, da diese zum Zustand eines Objekts gehören würden – dieser soll aber ausschließlich über die Komponenten eines Records definiert sein. Zusätzliche Methoden, etwa um Kalkulationen mit dem Zustand eines Objekts durchzuführen, sind erlaubt (Listing 2). Ebenfalls möglich sind statische Methoden und statische Initialisierungsblöcke.

Listing 2

public record Wuerfel(int hoehe, int breite, int tiefe) {
  private final static int DICHTE = 3;

  public int volumen() {
    return hoehe * breite * tiefe;
  }

  public int masse() {
    return volumen() * DICHTE;
  }
}

Konstruktoren

Der Zustand eines Objekts wird bei dessen Erzeugung über den Konstruktor definiert und kann anschließend nicht mehr geändert werden – zumindest auf der definierten Ebene. Im Text zum JEP wird dieser Zustand „shallowly immutable“ genannt. Da die Zugriffsmethoden von Komponenten Referenzen auf die enthaltenen Objekte liefern, ist es möglich, die Objekte, auf die sie verweisen, zu manipulieren. Tatsächlich unveränderliche Records sind nicht möglich – ähnlich wie auch bei normalen Klassen können die Referenzen zu gekapselten Werten entweichen.

Bei umfangreicheren Records, die etwa über mehr als zehn Komponenten verfügen, kann die Leserlichkeit von Code leiden. Während sich beim Aufruf von Settern anhand der Methodennamen noch der Kontext der Daten erkennen lässt, ist dies bei großen Parameterlisten in Konstruktoren weit weniger offensichtlich, beispielsweise in Listing 3. Die Darstellung mag durch die Benutzung von Literalen anstelle von sprechenden Variablennamen zwar überspitzt sein, das Problem der schwierigen Lesbarkeit existiert nichtsdestotrotz. Dieses Problem wird durch die Unterstützung von Entwicklungsumgebungen zwar abgeschwächt (die Early Acess Preview von IntelliJ zeigt beispielsweise schon vor jedem Konstruktorparameter den Variablennamen an), greift aber etwa bei der Betrachtung des Codes in einer Diff View nicht. Hier würden benannte Parameter helfen, wie sie zum Beispiel bei Kotlin verwendet werden. Mit diesem Gedanken spielte auch Brian Goetz schon in einem Artikel, der sich mit Möglichkeiten von Datenklassen und „Sealed Types“ beschäftigt.

Alternativ könnte dieses Problem mit einem Builder-Ansatz umgangen werden, wie Lombok ihn schon für Datenklassen ermöglicht. Eine Bibliothek, die der Autor dieses Artikels zu diesem Zweck geschrieben hat, findet sich auf GitHub.

Listing 3

var game = new Game(new Person("Rüdiger",
  "Behrens",
  LocalDate.of(1982, 3, 17),
  186),
  new Person("Frank",
    "Meier",
    LocalDate.of(1990, 2, 2),
    170),
  10,
  20);

Der Standardkonstruktor ist bei Records, anders als bei normalen Klassen, nicht parameterlos, sondern enthält Parameter für alle Komponenten des Records. Da jede Record-Instanz nach dem Erzeugen nicht mehr geändert werden kann, bietet sich der Konstruktor an, um zu prüfen, ob das Objekt vollständig und in sich schlüssig initialisiert wurde. In Listing 4 kann man sehen, dass Konstruktoren in Records genau so definiert werden können wie in üblichen Klassen. Leider führt das aber dazu, dass die elegante, kurzgefasste Zustandsbeschreibung des Records mehrfach wiederholt wird. Jeder Variablenname wird noch viermal getippt: in der Parameterliste des Konstruktors, in der Validierung und zweimal bei der Zuweisung zur Instanzvariable. Da dies der gewünschten Einfachheit von Records entgegenläuft, wurde eine Kurzschreibweise für Konstruktoren von Records eingeführt, die in Listing 5 aufgeführt ist. Sie benötigt weder eine Parameterliste noch die Zuweisung von Parametern zu den Instanzvariablen – beide werden implizit erledigt. Entwickler können sich ausschließlich auf die Validierungslogik konzentrieren, also erneut auf das „Was“, nicht auf das „Wie“.

Listing 4

public record Wuerfel(int hoehe, int breite, int tiefe) {
  public Wuerfel(int hoehe, int breite, int tiefe) {

    if(hoehe < 0 || breite < 0 || tiefe < 0) {
      throw new IllegalArgumentException("Werte dürfen nicht negativ sein!");
    }

    this.hoehe = hoehe;
    this.breite = breite;
    this.tiefe = tiefe;
  }
}

Listing 5

public record Wuerfel(int hoehe, int breite, int tiefe) {

  public Wuerfel {
    if (hoehe < 0 || breite < 0 || tiefe < 0) {
      throw new IllegalArgumentException("Werte dürfen nicht negativ sein!");
    }
  }

}

Vererbung

Records sind implizit final, von ihnen kann also keine andere Klasse erben. Betrachten wir die Grammatik von Records in Listing 6, fällt noch etwas auf: Records dürfen zwar ein oder mehrere Interfaces implementieren, nicht jedoch von einer Oberklasse oder einem anderen erben. Der Codeabschnitt in Listing 7 zeigt einen Record, der ein Interface implementiert. Diese Regeln zur Vererbung leiten sich erneut daraus ab, dass Records durch ihre Zustandsbeschreibung definiert sein sollen. Übliche Java-Klassen können ihren Zustand hingegen beliebig definieren und manipulieren. Würde ein Record von einer solchen Klasse erben, wäre diese Tatsache nicht mehr sicherzustellen. Auch das Erben von einem anderen Record würde die Klarheit der Deklaration verwässern. Ein Record würde weitere Komponenten erben, die Teil seiner Zustandsbeschreibung wären. Damit wäre ebendiese Zustandsbeschreibung über mehrere Typen verteilt und schwieriger zu erfassen.

Listing 6

RecordDeclaration:
  {ClassModifier} record TypeIdentifier [TypeParameters] 
    (RecordComponents) [SuperInterfaces] [RecordBody]

RecordComponents:
  {RecordComponent {, RecordComponent}}

RecordComponent:
  {Annotation} UnannType Identifier

RecordBody:
  { {RecordBodyDeclaration} }

RecordBodyDeclaration:
  ClassBodyDeclaration
  RecordConstructorDeclaration

RecordConstructorDeclaration:
  {Annotation} {ConstructorModifier} [TypeParameters] SimpleTypeName
    [Throws] ConstructorBody

Listing 7

public interface Koerper {
    int volumen();
}

public record Wuerfel(int hoehe, int breite, int tiefe) implements Koerper {

    @Override
    public int volumen() {
        return hoehe * breite * tiefe;
    }

}

Annotations und Reflection

Sowohl Records selbst als auch ihre Komponenten können mit Annotationen versehen werden. Für Annotationen auf Typebene gelten dieselben Regeln wie bei Klassen: Die Annotation muss TYPE als Target erlauben. An den Komponenten eines Records können Annotationen genutzt werden, die als Target PARAMETER, FIELD, METHOD oder das mit Records neu eingeführte Target RECORD_COMPONENT ermöglichen. Die Vielzahl erlaubter Annotationsziele ergibt sich aus den implizit abgeleiteten Elementen einer Record-Komponente: Konstruktorparameter, Instanzvariablen und Methoden.

Das Java Reflection API wurde ebenfalls erweitert, um die Arbeit mit Records zu ermöglichen. Der Typ java.lang.Class wurde um zwei Methoden erweitert: Mittels isRecord() lässt sich feststellen, ob es sich bei einer Klasse um einen Record handelt. Die Methode getRecordComponents() liefert ein Array vom neuen Typen java.lang.reflect.RecordComponent zurück, der Informationen zu den Komponenten eines Records enthält, wie zum Beispiel den Namen, Datentypen, generische Typen, Annotationen oder die Zugriffsmethode.

Zusammenfassung und Ausblick

Mit Records erhält Java einen interessanten, neuen Typen – wenn auch vorerst lediglich als Preview Feature. Der Plan, Daten kurz und prägnant eben auch als Daten zu modellieren und von der üblichen Zeremonie zu befreien, befindet sich auf einem guten Weg und wird von vielen Entwicklern sehnsüchtig erwartet. Nicht nur wird das Erzeugen von immer gleichförmigem Code obsolet und es entfällt die Gefahr, bei der Implementierung die wichtigen Methoden equals und hashcode zu vergessen. Vielmehr kann die Lesbarkeit von Code in Zukunft vielmehr stark vereinfacht werden, sodass die wichtigen Attribute eines Typs schneller erfasst werden können. Interessant wird sein, wie gängige Bibliotheken mit dem unveränderlichen Charakter der Record-Instanzen umgehen. Derzeit benutzen viele Mapper und Serialisierungstools die Setter von Datenklassen, um sie mit Werten zu befüllen. Hier ist eine Umstellung auf einen konstruktorbasierten Ansatz zu erwarten, um auch Records behandeln zu können.

Die Einschränkungen, die der neue Typ mit sich bringt, werden dafür sorgen, dass Records keineswegs die herkömmlichen, veränderlichen Datentypen in allen Situationen ablösen werden. Das ist auch explizit nicht beabsichtigt. Diese Restriktionen wurden aus gutem Grund und mit Blick auf die Zukunft beschlossen: Aufgrund der (von Menschen und Compilern) leicht zu erfassenden Zustandsbeschreibung von Records eignen sie sich in Kombination mit Sealed Types (JEP 360) sehr gut für Pattern Matching und Destrukturierung und damit für einen weiteren großen Schritt hin zu weiteren funktionalen Features in Java.

Es bleibt abzuwarten, welche Änderungen an den hier beschriebenen Konzepten in der Previewphase noch getätigt werden. Die Erfahrung mit bisherigen Previewfeatures zeigt, dass auf das Feedback der Community generell eingegangen wird und getätigte Annahmen noch einmal überprüft werden können. Trotzdem lässt sich schon jetzt absehen, dass Records eine große Bereicherung für Java sein werden, die die Tür zu weiteren Innovationen weit aufstößt.

 

Alle News der Java-Welt:
Alle News der Java-Welt:

Behind the Tracks

Agile & Culture
Teamwork & Methoden

Data Access & Machine Learning
Speicherung, Processing & mehr

Clouds, Kubernets & Serverless
Alles rund um Cloud

Core Java & JVM Languages
Ausblicke & Best Practices

DevOps & Continuous Delivery
Deployment, Docker & mehr

Microservices
Strukturen & Frameworks

Web Development & JavaScript
JS & Webtechnologien

Performance & Security
Sichere Webanwendungen

Serverside Java
Spring, JDK & mehr

Digital Transformation & Innovation
Technologien & Vorgehensweisen

Software-Architektur
Best Practices

Domain-driven Design
Grundlagen und Ausblick

Spring Ecosystem
Wissen in Spring-Technologien

Web-APIs
API-Technologie, Design und Management