Das Wissensportal für IT-Professionals. Entdecke die Tiefe und Breite unseres IT-Contents in exklusiven Themenchannels und Magazinmarken.

SIGS DATACOM GmbH

Lindlaustraße 2c, 53842 Troisdorf

Tel: +49 (0)2241/2341-100

kundenservice@sigs-datacom.de

Muster, Switch, Records und Sealed Classes in modernem Java

Ein Bereich, dem ich in der letzten Zeit nicht so viel Aufmerksamkeit gewidmet habe, ist die massive Verbesserung der Operationen switch und instanceof in Java seit Version 14, die einhergehend mit Records [Hun20] und „sealed classes” viel mächtigere deklarative Konstrukte wurden. Der Vortrag von José [Paumard] zu diesem Thema hat mich ziemlich beeindruckt, ich hatte davon nur einen Bruchteil der Features erwartet. Einiges davon ist schon in Java 18 verfügbar, anderes noch in Preview oder Diskussion, aber die Möglichkeiten, die sich aus diesen Bausteinen des [Amber]-Projekts auftun, sind sehr umfänglich.
Author Image
Michael Hunger

Teamleiter


  • 27.05.2022
  • Lesezeit: 10 Minuten
  • 82 Views

Damit wird es möglich werden, in Java bequem Dekonstruktion von komplexen Datentypen vorzunehmen, wie ich es in anderen Sprachen wie Kotlin und Clojure in vergangenen Kolumnen [Hun17] schon gezeigt habe. Operationen auf abstrakte Datentypen (Abstract Data Types, ADT) mittels „sealed classes” können komplexe Berechnungen, Regeln und Teilsprachen ausdrücken, die „komplett” sind, das heißt, keine Lücken im Eingabewertbereich erlauben.

Daher wollte ich in dieser Kolumne die Gelegenheit nutzen, die verschiedenen spannenden JEPs zu beleuchten und auszutesten. Wie immer können wir das unkompliziert mit jshell und Java 18 (via sdkman) interaktiv nachvollziehen, siehe Listing 1.

sdk install java 18-open
sdk use java 18-open
jshell --enable-preview
Listing 1: Installation von Java 18 und JShell

Verbessertes instanceof

Ein schon immer etwas nerviges Konstrukt in Java war der Boolesche instanceof-Operator, der für nicht-null Instanz-Referenzen „true” liefert, wenn es eine Instanz der angegebenen Klasse/Interface oder ihrer Subklassen ist value instanceof String. Aber danach mussten wir immer noch einmal einen Typcast ausführen.

Mit [JEP394] seit Java 16 (Previews waren in Java 14 und 15) kann neben dem Instance-Of gleich auch noch eine Zuweisung an eine gebundene Variable vorgenommen werden. Diese ist für abhängige Ausdrücke im if, zum Beispiel nach &&; (aber nicht ||) und im Geltungsbereich des if-Ausdrucks gültig (Listing 2). Das gilt auch nach dem if-Ausdruck, falls dieser negierend war.

if (value instanceof String str && !str.isEmpty())
  System.out.println(str.length());

// oder als Guard Clause
if (!(value instanceof String str)) return 0;
return str.length();
sdk install java 18-open
sdk use java 18-open
jshell --enable-preview
Listing 2: Typ-Check im instanceof

Damit können auch Methoden und Attribute der Instanz benutzt werden, entweder direkt im Ausdruck oder nach positiver Evaluation (Listing 3). Für Collection-Typen mit Generics funktioniert das leider nicht, aber für Felder (Listing 4).

Pattern-Variablen, werden vom Compiler wie lokale Variablen in einer Methode oder Block behandelt, das heißt, sie können andere Variablen verstecken (shadow) und ihnen kann auch ein neuer Wert zugewiesen werden. Ich persönlich finde gerade Letzteres nicht sinnvoll und hätte eine final-Deklaration für die Variable für sinnvoll gehalten, besonders da diese ein neues Konstrukt darstellen und man hier nicht die Fehler der Vergangenheit wiederholen sollte.

record Movie(String title, int year) {};
Object value = new Movie("Forrest Gump", 1994);

if (value instanceof Movie m && m.title().startsWith("F"))
  System.out.printf("Film %s veröffentlicht %d",
    m.title(), m.year());
// Film Forrest Gump veröffentlicht 1994
Listing 3: Zugriff auf Methoden von Klassen
Object value = List.of(new Movie("Encanto",2022));
if (value instanceof List<Movie> movies && !movies.isEmpty()) {
  System.out.println(movies.get(0).title());
}
// java.lang.Object cannot be safely cast to java.util.List<Movie>

Object value = new Movie[] {new Movie("Encanto",2022)};
if (value instanceof Movie[] movies && movies.length>0) {
  System.out.printf("Film %s veröffentlicht %d", movies[0].title(),
    movies[0].year());
}
// Film Encanto veröffentlicht 2022
Listing 4: Instanceof für Felder und Listen

Das „Shadowing” zu erlauben, ist auch ungünstig, da der Gültigkeitsbereich der Variablen auf dem Block nach beziehungsweise innerhalb der erfolgreichen instanceof-Bedingung beschränkt ist, siehe Listing 5.

record Star(String name, int planets) {};
class StarInfo {
  Object star = "Schwarzes Loch";

  public String info(Object quelle) {
    if (quelle instanceof Star star && star.name() == "Sun") {
      return "Unsere Sonne hat Planeten "+star.planets();
    } else {
      return "Stern "+star.name(); // Schlägt fehl
    }
  }
}
Listing 5: Shadowing-Problem

Switch-Ausdrücke

Switch war in Java immer sehr limitiert, ursprünglich nur auf Gleichheit konkreter, konstanter Werte primitiver Typen und Strings, dann erweitert um Enums in Java 5.
Die switch-Struktur ist seit Java 14 mit dem [JEP361] nicht mehr nur ein imperatives Konstrukt, sondern kann auch als Ausdruck benutzt werden. Ich hatte das bisher meist über extrahierte Berechnungsmethoden und ein return im switch-Konstrukt realisiert.

Der Hauptunterschied für den Switch-Ausdruck ist der Ersatz von Doppelpunkt nach dem case durch einen Pfeil ->, und dass das Ergebnis jetzt idealerweise einer Variablen oder Rückgabewert zugewiesen werden sollte, damit es genutzt werden kann.
Für komplexere Ausdrücke mit mehreren „Rückgabepunkten” in einem Zweig kann das yield-Schlüsselwort benutzt werden. Beides ist in Listing 6 zu sehen. Damit können auch Kaskaden von if-elseif-else kompakt als Switch-Ausdruck umgeformt werden.

var ergebnis = switch (antwort) {
  case 42 -> true;
  case 43 -> {if (Math.random() > 0.5) yield true; else yield false;}
  default -> false;
}
Listing 6: Switch-Ausdrücke

Musterausdrücke im Switch

Im „Pattern Matching for switch” [JEP406] [JEP420], das mit Java 17 und 18 als (Preview Feature) veröffentlicht wurde, sollte die Flexibilität des Pattern Matchings aus dem instanceof auf switch-Statements und Ausdrücke übertragen werden.

Dabei könnten im Switch Werte kompakt mit mehreren komplexeren Bedingungen (hier Muster genannt) überprüft werden, eine pro case-Zweig, was für analytische Anwendungszwecke und Geschäftsregeln hilfreich wäre. Damit wird der Geltungsbereich für Switch-Argumente von Strings, primitiven Werten und Enums auf beliebige Referenzen inklusive Arrays erweitert. Es muss nur sichergestellt werden, dass die Ausdrücke der Muster kompatibel zum Basistyp der Referenz sind.

Zum einen wären da Typausdrücke wie in Listing 7, sie können auch wie bisher mit Komma separiert für einen Zweig zusammengefasst werden. Diese können, wie in Listing 8 gezeigt, mit Zusatzbedingungen (guard clauses) ergänzt werden. Für komplexere Ausdrücke können diese auch geklammert und mit Booleschen Operatoren kombiniert werden.

double zahl = switch (value) {
  case Double d -> d;
  case Number n -> n.doubleValue();
  case String s -> Double.parseDouble(s);
  default -> 0d;
}
Listing 7: Typ-Ausdrücke in Switch-Zweigen
Object value = "43";
switch (value) {
  case Double d && ! d.isNaN() -> d.doubleValue();
  case Number n && n.intValue() % 2 == 0 -> n.doubleValue();
  case String s && s.matches("-?\\d+(\\.\\d+)?")
    -> Double.parseDouble(s);
  case null -> Double.NaN;
  default -> 0d;
}
Listing 8: Typausdrücke mit Zusatzbedingungen

Ohne default-Zweig wird der Ausdruck nicht kompiliert, da nicht alle „Subklassen” von Object abgehandelt wurden. Im Allgemeinen müssen Subklassen vor ihren Superklassen gelistet werden, um Compiler-Fehler zu vermeiden, wie in unserem Fall Double vor Number. Früher gab null immer eine NullPointerException, jetzt kann es explizit gehandhabt werden, damit die Exception nicht auftritt.

Durch die höhere Variabilität der Muster ist mehr auf Präzedenz zu achten, das kann Subtypen betreffen, die vor ihren Supertypen getestet werden müssen. Im Listing 9 wird sichtbar, dass vom Compiler für String, die Subklasse von CharSequence, ein „Dominierungs”-Fehler gemeldet wird, wenn der konkretere Typ später in der Liste der case-Muster erscheint.

switch(o) {
  case CharSequence cs ->
   "A sequence of length " + cs.length();
  // Fehler - pattern is dominated by previous pattern
  case String s ->
   "A string: " + s;
  default ->
   "Another value";
}

// Kein Fehler
switch(o) {
  case String s ->
   "A string: " + s;
  case CharSequence cs ->
   "A sequence of length " + cs.length();
  default ->
   "Another value";
}
Listing 9: Präzedenz von Typ-Mustern

Für bedingte Muster kann nicht generisch entschieden werden, welche Präzedenz es hat, daher schlägt dort der Compiler nur fehl, wenn der generelle Typ-Ausdruck vor dem bedingten kommt (siehe Listing 10).

switch(o) {
  case -1, 1 -> "Spezialfall";
  case Integer i -> "Andere Ganzzahlen";
  // Fehler: case label is dominated by a preceding case label
  case Integer i && i > 0 -> "Positive Ganzzahlen";
  default -> "Andere Werte";
}

// Kein Fehler
switch(o) {
  case -1, 1 -> "Spezialfall";
  case Integer i && i > 0 -> "Positive Ganzzahlen";
  case Integer i -> "Andere Ganzzahlen";
  default -> "Andere Werte";
}
Listing 10: Präzedenz von bedingten Typmustern

Vollständigkeit und Sealed Classes

Wie auch mit bisherigen Switch-Strukturen müssen mit Muster-Checks auch alle Möglichkeiten abgedeckt werden, bei fehlenden Zweigen (wie default) für die verbliebenen Fälle gibt es einen Compiler-Fehler.

Sealed Classes [JEP409] (seit Java 17) sind ein Feature, das die Anzahl der Subklassen einer Klasse oder Interfaces begrenzt und garantiert. Offensichtliche Beispiele dafür sind Subklassen von Optional wie Some(x) und None oder ein fixes Set von Operatoren/Ausdrücken, wie in Bereichen der Mathematik oder Logik. Mit dieser Gruppe an Subklassen kann dann eine Sprache, Berechnung oder Regelwerk abschließend spezifiziert werden.

Wie bei Enums kann mit „sealed classes”, die eine feste Anzahl von Subklassen haben, ein Vollständigkeitstest auch ohne generische default-Zweige erfolgen (siehe Listing 11). Damit können Ausdrücke über abstrakte Datentypen verifizierbar realisiert werden. Sobald der „sealed class” ein weiterer Typ hinzugefügt wird, werden alle switch-Statements ungültig.

sichesealed
interface Binary permits Zero, One {}
final class Zero implements Binary {};
final class One implements Binary {};

Binary o = new One();
// Fehler: the switch expression does not cover
// all possible input values
switch (o) {
  case Zero b -> 0;
}
// Kein Fehler, Ausgabe 1
switch (o) {
  case Zero b -> 0;
  case One b -> 1;
}
Listing 11: Sealed Classes und Switch

Records

Mit Records können „struct”-ähnliche Konstrukte erstellt werden, wie in einer vorherigen Kolumne erläutert [Hun20]. Diese können schon mit Typ-Mustern genutzt werden (siehe Listings 3 und 5).

Durch „Record Patterns” ([JEP405], preview in Java 19, im aktuellen 19.ea.14-open noch nicht vorhanden) werden diese in der Zukunft auch in destrukturierenden Muster-Ausdrücken auswertbar, was vor allem zum Zugriff auf (verschachtelte) Elemente genutzt wird. Dabei würde ein Ausdruck ähnlich dem Konstruktor (siehe Listing 12) mit instanceof oder case genutzt, der die Parameter als deklarierte lokale Variablen bereitstellt. Verschachtelte Ausdrücke wären möglich, um auf Werte von beinhalteten Records zuzugreifen.

record Movie(String title, int year) {};
Object value = new Movie("La-La-Land", 2016);

if (value instanceof Movie(String title, int year)
    && title.contains("La"))
  System.out.printf("Film %s veröffentlicht %d", title, year);
// Film La-La-Land veröffentlicht 2016
Listing 12: Record-Muster

Richtig schön wird das dann in switch<-Ausdrücken (besonders mit „sealed classes”), da damit DSLs und andere ADT-Konstrukte elegant behandelt werden können (siehe Listing 13 und 14).

sealed interface Option<T> permits None, Some {}
final record None() implements Option<Void> {}
final record Some<T>(T value) implements Option<T> {}

switch (result) {
  case Some(x) -> x;
  case None() -> defaultValue;
}
Listing 13: Record-Muster in Switch
sealed interface Entity permits Node, Relationship {}
final record Node(String type, String name) implements Entity {}
final record Relationship(Node start, String type, Node end)
    implements Entity {}

switch (entity) {
  case Node(var type, var name) ->
    String.format("(:%s {name:%s})", type, name);
  case Relationship(Node(var start, _), var type, Node(var end, _)) ->
    String.format("(:%s)-[:%s]->(:%s)", star,type,end);
}
Listing 14: Verschachtelte Record-Muster in Switch

Weitere vorgeschlagene Features sind:

  • var für die Elemente spart die Typdeklaration
  • _ Platzhalter für uninteressante Elemente
  • Unterstützung für (partielle) Felder, wie instanceof Movie[] {m1, m2}
  • Unterstützung nicht nur für Records

Fazit

Mit den im aktuellen Java 18 verfügbaren Verbesserungen für switch und instanceof-Ausdrücke kann man schon viel klareren und sichereren Code schreiben, ohne komplexe Ketten von if-Anweisungen. Mit Java 19 wird sich die Situation dann noch mal verbessern, besonders für deklarative und funktionale Ausdrücke.

Für einige Teile der Präsentation von José Paumard, wie Factory-Methoden, deconstructor-Methoden und match, gibt es noch keine JEPs, nur die Diskussion im [Pattern-Matching]-Dokument von Projekt Amber und einem Entwurf von Brian Goetz [DeconstructionGoetz].

Weitere Informationen

[Amber] https://openjdk.java.net/projects/amber/

[Baeldung-Switch] D. Strmecki, 28.10.2021, https://www.baeldung.com/java-switch-pattern-matching

[DeconstructionGoetz] B. Goetz, 2020, https://github.com/openjdk/amber-docs/blob/master/eg-drafts/deconstruction-patterns-records-and-classes.md

[Hun17-a] M. Hunger, Mit Kotlin schnell und sicher zum Ziel – Teil 1: Grundlagen, in JavaSPEKTRUM, 1/2017

[Hun17-b] M. Hunger, Mit Kotlin schnell und sicher zum Ziel – Teil 2: Die Zukunft, in JavaSPEKTRUM, 2/2017

[Hun20] M. Hunger, Records in Java 14, in: JavaSPEKTRUM, 2/2020

[JEP381] Switch Expressions, https://openjdk.java.net/jeps/361

[JEP394] Pattern Matching instanceof, https://openjdk.java.net/jeps/394

[JEP405] Record Patterns, https://openjdk.java.net/jeps/405

[JEP409] Sealed Classes, https://openjdk.java.net/jeps/409

[JEP420] Pattern Matching for switch (Second Preview), https://openjdk.java.net/jeps/420

[Paumard] J. Paumard, The Future of Java, 11.3.2022, https://www.slideshare.net/jpaumard/the-future-of-java-records-sealed-classes-and-pattern-matching

[Pattern-Matching-Amber] https://openjdk.java.net/projects/amber/design-notes/patterns/pattern-matching-for-java

. . .

Author Image

Michael Hunger

Teamleiter
Zu Inhalten

Michael Hunger interessiert sich für alle Belange der Softwareentwicklung, denn diese gehört zu seinen großen Leidenschaften. Der Umgang mit den beteiligten Menschen ist ein besonders wichtiger Aspekt. Michael Hunger leitet das Team der Neo4j Labs und unterstützt alle Nutzer der Graphdatenbank tagtäglich bei der erfolgreichen Realisierung ihrer Projekte und Lösung ihrer Fragen und Probleme.


Artikel teilen