Pattern Matching in Java 17

Switch on Steroids
11
Oct

Pattern Matching in Java 17

Pattern Matching kennt man in erster Linie aus funktionalen Programmiersprachen. Damit lassen sich Daten sehr einfach und effizient auf bestimmte Inhalte prüfen und die relevanten Informationen aus den Datenstrukturen für die weitere Verarbeitung extrahieren. Seit drei Jahren wird nun Pattern Matching Stück für Stück in Java eingeführt. Und auch wenn noch nicht alle Funktionen fertig implementiert sind, lohnt sich schon jetzt ein Blick auf die vorhandenen Möglichkeiten und natürlich der Ausblick auf die zukünftigen Optionen.

Es gibt diverse Sprachen auf der Java-Plattform, die bereits Pattern Matching oder ähnliche Ansätze mitbringen. Das sind mit Scala bzw. Clojure natürlich Vertreter der funktionalen Programmierung und mit Groovy eine Sprache, die schon vor 15 Jahren funktionale Ansätze auf die Java-Plattform gebracht hat. Java hat ab der Version 8 mit der Einführung von Lambdas, Streams, Higher Order Functions usw. ebenfalls diesen Weg eingeschlagen. Darum verwundert es nicht, dass nun auch Pattern Matching Einzug hält und die Sprache damit wieder ein Stück attraktiver und relevanter für Ansätze der funktionalen Programmierung wird.

In der Dokumentation von Scala findet sich die folgende, treffende Aussage: „Pattern matching is a mechanism for checking a value against a pattern. A successful match can also deconstruct a value into its constituent parts. It is a more powerful version of the switch statement in Java and it can likewise be used in place of a series of if/else statements.“ [1]

Wie in der Definition angedeutet, kann man in Java mit einem Switch Statement oder verknüpften if/else-Bedingungen das gleiche Ergebnis erzielen. Leider gibt es beim klassischen Switch in Java viele Stolperfallen, und lange if/else-Ketten sind sowieso nicht sonderlich schön anzusehen. Es leidet die Lesbarkeit und durch die vielen Redundanzen erhöht sich außerdem die Fehleranfälligkeit. Java-Code gilt sowieso immer als sehr aufgebläht (Boilerplate-Code) und viele Programmierer haben sich unter anderem deshalb bereits alternativen Sprachen zugewandt. Nichtsdestotrotz ist Java weiterhin eine der meistgenutzten Programmiersprachen und wird auch nach über 25 Jahren noch stetig weiterentwickelt. Der heute in Java geschriebene Quellcode profitiert davon und wird verständlicher sowie wartbarer.

Geschichte des Pattern Matching in Java

Die Pläne zur Einführung von Pattern Matching reichen bereits einige Jahre zurück [2]. Im Gegensatz zu den Big-Bang-Releases der früheren Jahre (bis Java 9), werden solche größeren Sprachänderungen durch das neue halbjährliche Releasemodell nun in kleinen Schritten verteilt auf mehrere Releases ausgeliefert. Los ging es im Frühjahr 2019 (OpenJDK 12) mit der Einführung der Switch Expressions und damit der Runderneuerung des klassischen Switch Statements. Es folgten neue Sprachkonstrukte (Records, Sealed Classes) sowie weitere Optimierungen bestehender Features (Pattern Matching for instanceof). Mit dem OpenJDK 17 ist dann im September 2021 die erste Preview für „Pattern Matching for switch“ (JEP 406) [3] erschienen.

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

Project Amber

Alle Teile des Pattern Matchings für Java wurden bzw. werden im Inkubatorprojekt Amber [4] entwickelt. Das Ziel von Amber ist die Einführung kleiner Sprachfeatures, die die Produktivität steigern. Es besteht aus einer Vielzahl von JDK Enhancement Proposals (JEP), von denen bereits 18 bis zum JDK 17 ausgeliefert wurden. Die meisten JEPs von Amber stehen direkt oder indirekt mit der Einführung von Pattern Matching in Zusammenhang. Es gibt aber auch davon unabhängige Features wie die Local-Variable Type Inference (var), die Text Blocks (beide bereits ausgeliefert) oder die aktuell gestoppten JEPs zu Enhanced Enums und Lambda Leftovers. Für das Pattern Matching wurden folgende JEPs in den vergangenen Releases umgesetzt:

  • JEP 325: Switch Expressions (Preview) – Java 12

  • JEP 354: Switch Expressions (Second Preview) – Java 13

  • JEP 361: Switch Expressions – Java 14

  • JEP 359: Records (Preview) – Java 14

  • JEP 384: Records (Second Preview) – Java 15

  • JEP 395: Records – Java 16

  • JEP 305: Pattern Matching for instanceof (Preview) – Java 14

  • JEP 375: Pattern Matching for instanceof

  • JEP 394: Pattern Matching for instanceof – Java 16

  • JEP 360: Sealed Classes (Preview) – Java 15

  • JEP 397: Sealed Classes (Second Preview) – Java 16

  • JEP 409: Sealed Classes – Java 17

  • JEP 406: Pattern Matching for switch (Preview) – Java 17

Auffällig ist, dass alle neuen Features mehrere Previewphasen durchlaufen haben. So konnten interessierte Java-Entwickler die neuen Funktionen frühzeitig ausprobieren und schnell Feedback geben – ein großer Vorteil der neuen halbjährlichen Releasefrequenz. Beim noch sehr frischen JEP 406 „Pattern Matching for switch“ startete dieser Prozess im aktuellen LTS-Release JDK 17 und der JEP 405 „Record Patterns and Array Patterns (Preview)“ wird voraussichtlich im OpenJDK 18 dazukommen [5]. Das bedeutet aber auch, dass wir frühestens im September 2024 mit Java 23 das komplette Pattern Matching in einem LTS-Release nutzen können.

Über den Tellerrand geschaut

Bevor wir tiefer in das Pattern Matching für Java einsteigen, wagen wir zunächst einen Blick auf die Umsetzung in Haskell. Haskell ist eine statisch typisierte, rein funktionale Sprache und existiert seit Anfang der 90er Jahre des 20. Jahrhunderts. Sie wurde im akademischen Umfeld entwickelt und diente aufgrund der fortschrittlichen Konzepte und Ansätze beim Entwurf oder der Erweiterung vieler Programmiersprachen als Vorlage. Haskell enthält keine imperativen Sprachkonstrukte und damit auch keine Switch-Anweisung. Patterns werden vielmehr bei der Deklaration von Funktionen angegeben und beim Aufruf wird dann der passende Zweig ausgewählt. Im Gegensatz zum klassischem Switch Statement in Java, wo man nur wenige Datentypen und diese auch nur auf Gleichheit prüfen kann, bietet Haskell sehr viele unterschiedliche und mächtigere Optionen. Einige schöne Beispiele kann man dem kostenfreien Onlinetutorial „Learn You a Haskell for Great Good!“ [6] (auch als Buch verfügbar) entnehmen. Listing 1 zeigt, wie die rekursive Berechnung der Fakultät aussehen könnte. In der ersten Zeile wird die Funktion factorial deklariert, wobei sie mit einem Parameter vom Typ Integral (Zahl) aufgerufen und ein Ergebnis des gleichen Typs zurückgeben wird. Bei der Implementierung gibt es zwei Pfade (analog zu den case-Blöcken bei switch). Erfolgt der Aufruf der Funktion mit 0 als Parameter, wird direkt 1 zurückgeliefert. Bei jedem beliebigen anderen Wert wird dieser als Parameter n bereitgestellt und damit die weitere Berechnung rekursiv aufgerufen.

factorial :: (Integral a) => a -> a
factorial 0 = 1
factorial n = n * factorial (n - 1)

In der funktionalen Programmierung kommt der Verarbeitung von Listen eine besondere Bedeutung zu. In Listing 2 wird in Haskell die Funktion head’ definiert, die als Parameter eine Liste mit Werten eines beliebigen Typs (a) entgegennimmt und dann das erste Element zurückliefert. Der Sonderfall des Aufrufs mit einer leeren Liste (Zeile 2) wird mit einer Fehlermeldung quittiert. In allen anderen Fällen wird das Parameterobjekt (die Liste) dekonstruiert. In diesem konkreten Beispiel wird das erste Element als x zurückgegeben. Der Rest der Liste (könnte auch leer sein) interessiert an dieser Stelle nicht und wird darum mit dem Unterstrich ignoriert.

head' :: [a] -> a
head' [] = error "Can't call head on an empty list, dummy!"
head' (x:_) = x
 
ghci> head' [4,5,6]
4
ghci> head' "Hello"
'H'

Switch Expression

Werfen wir zunächst einen Blick auf die bereits im JDK 12 eingeführten Switch Expressions. Sie sind eine der Grundlagen für das Pattern Matching in Java. Das klassische Switch Statement kann dafür nicht verwendet werden. Es bleibt natürlich aus Abwärtskompatibilitätsgründen weiterhin verfügbar. Switch Statements haben aber einige Schwächen:

  • Sie sind nur auf bestimmte Datentypen anwendbar (einige Primitive bzw. deren Wrapper sowie Enums und Strings).

  • Die Werte können nur auf Gleichheit und nicht mit beliebigen booleschen Ausdrücken (kleiner oder größer als …) geprüft werden.

  • Der Fall-through-Mechanismus kann zu schwer erkennbaren Bugs führen, notwendige break-Anweisungen stören zudem die Lesbarkeit.

  • Der Compiler kann keine Hilfestellung dabei geben, ob alle möglichen Fälle als case-Zweig abgedeckt sind.

  • Werden neue Ausprägungen hinzugefügt, müssen alle betroffenen Switch Statements manuell ermittelt und erweitert werden.

  • Durch unnötigen Boilerplate-Code sind Switches aufwendig zu schreiben und schwer zu lesen.

  • Die case-Zweige besitzen keinen eigenen Gültigkeitsbereich, der gesamte Switch wird als ein Codeblock behandelt. Eine Variable kann daher nicht lokal sein, sondern ist in allen case-Zweigen sichtbar.

  • Es sind keine Nullwerte erlaubt, mögliche Nullreferenzen müssen vor dem Aufruf des Switch Statements manuell geprüft werden.

Mit den Switch Expressions wurden bereits einige dieser Nachteile behoben. Es gibt dabei verschiedene Varianten. Listing 3 zeigt eine mögliche Form, die einerseits mehrere Labels je case-Zweig erlaubt (als Ersatz für den Fall-through) und durch die Pfeil-Notation kompakter geschrieben werden kann. Das Ergebnis eines case-Zweigs wird dann automatisch zurückgegeben und somit kann das Resultat der Switch Expression einer Variablen zugewiesen oder als Rückgabewert verwendet werden. Muss man in einem Zweig mehrere Anweisungen unterbringen, können diese mit geschweiften Klammern in einen Codeblock eingeschlossen werden. Dann liefert die yield-Anweisung das Ergebnis analog zu einem return zurück. yield ist im Übrigen kein reserviertes Schlüsselwort, es hat nur im Kontext der Switch Expression eine besondere Bedeutung. Außerhalb kann man es auch für normale Variablennamen einsetzen.

String developerRating( int numberOfChildren ) {
  return switch (numberOfChildren) {
    case 0 -> "open source contributor";
    case 1, 2 -> "junior";
    case 3 -> "senior";
    default -> {
      if (numberOfChildren < 0) 
        throw new IndexOutOfBoundsException( numberOfChildren );
      yield "manager";
    }
  };
}

Pattern Matching for switch

Mit dem JEP 406 wird der Switch in Java nochmal auf ein höheres Level gehoben. Im Gegensatz zur Prüfung auf Gleichheit mit der begrenzten Anzahl von erlaubten Datentypen, kann man in den case-Blöcken nun auch Type Patterns und in Zukunft (JEP 405) noch ganz andere Muster verwenden. Die Type Patterns sind auch außerhalb eines Switch einsetzbar und wurden im JDK 16 als „Pattern Matching for instanceof“ offizieller Teil des Java-Sprachstandards. Listing 4 zeigt ein Beispiel, in dem sehr kompakt auf leere Strings oder Collections geprüft werden kann. Im Gegensatz zum klassischen instanceof wird hier der Parameter o im Erfolgsfall direkt auf den Typ gecastet und automatisch einer Variablen (s oder c) zugewiesen. Das erspart unnötige Redundanzen und macht den Quellcode zudem kompakter und lesbarer. Außerdem ist er weniger fehleranfällig, da man das Objekt beim Cast nicht aus Versehen in einen ganz anderen Typ umwandeln kann. Ein Fehler, der nicht vom Compiler abgefangen wird und somit erst zu Laufzeit auffallen würde.

boolean isNullOrEmpty( Object o ) {
  return o == null ||
    o instanceof String s && s.isBlank() ||
    o instanceof Collection c && c.isEmpty();
}

Das Type Pattern kann nun auch in den case-Zweigen einer Switch Expression verwendet werden. Es wird automatisch ein instanceof-Check ausgeführt und im Erfolgsfall in den zu prüfenden Typ umgewandelt sowie einer Variablen zugewiesen. Diese Variable (s oder o) kann dann im case-Zweig direkt verwendet werden (Listing 5).

String evaluateTypeWithSwitch( Object o ) {
  return switch(o) {
    case String s -> "String: " + s;
    case Collection c -> "Collection: " + c;
    default -> "Something else: " + o;
  };
}

Neben den Type Patterns wird es in Kürze noch mindestens zwei andere Arten geben. Bei Records und Array Patterns können dazu die Datenstrukturen dekonstruiert und damit in ihre einzelnen Bestandteile zerlegt werden. Denkbar wäre auch das Matchen von regulären Ausdrücken. Außerdem wird man Patterns kombinieren können, um auch komplexe, verschachtelte Datenstrukturen prüfen zu können.

Guarded Patterns und Prüfung auf null

Wenn man auf einen bestimmten Typ prüft, kann man über einen Guard das Suchergebnis noch weiter verfeinern. Die Guarded Patterns bestehen aus dem Primary Pattern, zu dem auch das Type Pattern gehört, gefolgt von der logischen Und-Verknüpfung (&&) und einem booleschen Ausdruck (ConditionalAndExpression). Damit spart man sich zusätzliche if/else-Bedingungen innerhalb des case-Zweigs und erhöht so wieder die Lesbarkeit. Listing 6 zeigt ein Beispiel dazu. Wenn die Variable o einen String referenziert und dieser leer ist, dann wird true zurückgeliefert. Bei jedem anderen String (der nicht leer ist), kommt entsprechend false zurück. Wobei case String s an dieser Stelle sogar unnötig wäre, da dieser Fall vom default-Zweig mit abgedeckt wird. Es soll aber zeigen, dass die Reihenfolge der case-Zweige relevant ist, da allgemeine Patterns dominant sind und darum unterhalb der speziellen stehen müssen. Sonst könnten sie nie aufgerufen werden. Hier hilft allerdings auch der Compiler mit einer Fehlermeldung.

Das klassische Switch Statement und auch die Switch Expression können nicht mit Nullwerten umgehen und werfen im Fall der Fälle einfach eine NullPointerException. Als Aufrufer ist man selbst dafür verantwortlich, vorher einen Nullcheck durchzuführen. Beim „Pattern Matching for switch“ darf es aber einen case-Zweig mit einem null-Label geben, der das default-Verhalten (NullPointerException) quasi neu definieren kann. In Listing 6 wird entsprechend beim Aufruf mit einer Nullreferenz direkt true zurückgeliefert.

boolean isNullOrEmptyWithSwitch( Object o ) {
  return switch(o) {
    case null -> true;
    case String s && s.isBlank() -> true;
    case String s -> false;
    case Collection c && c.isEmpty() -> true;
    default -> false;
  };
}

Vollständigkeit vom Compiler prüfen lassen

Bei einem Switch Statement interessiert es den Compiler nicht, ob durch die vorhandenen Zweige alle Fälle abgedeckt sind. Wenn man aus Versehen einen Block vergisst, wird der Switch ggf. lautlos ignoriert, weil kein passender Zweig gefunden werden konnte. Über den default-Block gibt es immerhin die Möglichkeit, auf solche Situationen zu reagieren. Allerdings auch nur zur Laufzeit, indem beispielsweise eine Exception geworfen wird.

Bei der Switch Expression sieht das schon anders aus. Da sie einen Wert zurückliefert, stellt der Compiler sicher, dass immer mindestens ein Zweig für beliebige Eingaben aufgerufen und somit ein valider Wert zurückgegeben werden kann. Im Fall von Constant Expression (Integer, String, …) brauchen wir damit immer den default-Zweig als Backup. Wenn wir Aufzählungstypen (enum) verwenden, kann der default-Zweig aber immerhin entfallen, wenn auf alle Enum-Werte reagiert wird. Der Compiler kann durch die konstante Anzahl an möglichen Werten die Vollständigkeit sicherstellen und uns somit ein sehr frühes Feedback geben. Und je früher ein Fehler gefunden wird, desto günstiger ist es, ihn zu beheben.

Um unsere Real-World-Probleme in unserem objektorientierten Datenmodell abzubilden, reichen Enums aber nicht immer aus. Um abstrahieren zu können, modellieren wir ebenso Vererbungshierarchien aus Klassen und Interfaces. Dank der im OpenJDK 17 finalisierten Sealed Classes [7] kann man die Ableitungen eines Interface oder eine Superklasse konfigurativ einschränken und erhält somit wieder eine feste Menge an existierenden Implementierungen zu einem bestimmten Typ. Der Compiler kann dadurch beim „Pattern Matching for switch“ genauso auf Vollständigkeit prüfen wie bei den Aufzählungstypen.

Sealed Classes und Interfaces legen dazu im Quellcode fest, welche abgeleiteten Klassen in der Vererbungshierarchie erlaubt sind (Listing 7). Die Subklassen müssen entweder auch wieder sealed, non-sealed oder final sein. In diesem Fall wurden sie als Records implementiert und sind dadurch automatisch final. Weitere Informationen zu Sealed Classes kann man in der Dokumentation des JEP 409 [7] nachlesen.

public sealed interface Tarif permits Privat, Business, Profi {}
 
public record Profi(double preisProMinute) implements Tarif {}
public record Business(double preisProMinute) implements Tarif {}
public record Privat(double preisProMinute) implements Tarif {
  public int getNettoMinuten(int minuten) { return Math.max(minute - 1, 0); }
}

Die Preisberechnung der Telefontarife kann dann in einem Switch erfolgen. Wenn alle drei erlaubten Subklassen verwendet werden, kann der Compiler die Vollständigkeit prüfen und der default-Zweig darf entfallen (Listing 8). Sollte eine neue Tarifart hinzugefügt oder eine bestehende geöffnet werden (non-sealed oder sealed), muss man den Switch wieder entsprechend anpassen, um den Compiler zu befriedigen.

double preis = switch(tarif) {
  case Privat p -> p.getNettoMinuten(minuten) * p.preisProMinute();
  case Business b -> minuten * b.preisProMinute();
  case Profi p -> minuten * p.preisProMinute();
};

Sealed Classes können einfach mit Records integriert werden (Listing 7). Mit dieser Kombination lassen sich in Java algebraische Datentypen nachbilden. Die kommen in zwei Ausprägungen: Produkt- und Summentypen. Unveränderbare POJOs sind Produkttypen. Sie enthalten mehrere Werte, eine Person hat z. B. Vor- und Nachname. Mit Records kann man diese Produkttypen sehr einfach implementieren und spart sich eine Menge Boilerplate-Code.

Mit Java Enums und Sealed Classes lassen sich wiederum Summentypen abbilden. Sie definieren eine feste Anzahl an Aufzählungswerten oder möglichen Subklassen. Das gibt dem Compiler dann die angesprochene Möglichkeit, auf Exhaustiveness (Vollständigkeit) zu prüfen.

Ausblick

Demnächst wird es eine erste Preview zu „Record Patterns & Array Patterns“ geben. Das führt schlussendlich zu folgender Grammatik für Patterns in Java (Listing 9).

Pattern:
PrimaryPattern
GuardedPattern
 
GuardedPattern:
PrimaryPattern && ConditionalAndExpression
 
PrimaryPattern:
TypePattern
RecordPattern
ArrayPattern
( Pattern )

Records sind selbst ein recht neues Sprachfeature. Sie dienen als transparente Datenträger, die ohne viel Aufwand implementiert werden können. Sie kompilieren zu normalen Klassen, sind aber final und es werden einige notwendige Methoden (Konstruktoren, equals/hashCode, …) und die finalen Felder generiert. Bei der Verwendung von Records im Switch wird aber nicht nur der Datentyp geprüft und der Cast durchgeführt. Vielmehr werden die benannten Datenfelder extrahiert, sodass sie direkt im Quellcode weiterverarbeitet werden können (Listing 10).

record Point(int x, int y) {}
 
void printSum(Object o) {
  if (o instanceof Point(int x, int y)) {
    System.out.println(x+y);
  }
}

Arrays sind vergleichbar mit Records. Sie beinhalten aber beliebig viele Elemente nur eines Typs im Gegensatz zu den unterschiedlichen Werten eines Tuples (Record). Und so wird man in Kürze auch Arrays dekonstruieren können, um einzelne Elemente für die weitere Verarbeitung zu extrahieren. Listing 11 zeigt diverse Varianten für Array Patterns. Der Typ des Arrays ist hier immer String[]. In Zeile 1 gibt es nur dann einen Treffer, wenn die Liste genau zwei Werte enthält. Bei Verwendung der Ellipse (“…”) in Zeile 3 werden auch Arrays mit mehr als zwei Elementen gefunden. Da aber nur die ersten beiden Elemente relevant sind, werden alle weiteren einfach ignoriert. Mehrdimensionale Arrays (verschachtelt) werden auch unterstützt und zeigen gleichzeitig eine Kombination von verschiedenen Mustern.

String[] { String s1, String s2 } // genau zwei Elemente
String[] { String s1, String s2, ... } // mind. zwei Elemente
String[][] { { String s1, String s2, ...}, { String s3, String s4, ...}, ...}

Fazit

Switch Expressions sind nicht nur eine platzsparende Alternative zu den klassischen Switch Statements. Durch JEP 406 „Pattern Matching for switch“ können sie nun neben den altbekannten Konstanten auch Type Patterns (instanceof) verarbeiten (aktuell lassen sich die beiden Arten aber noch nicht mischen). Zusätzlich erlauben Guarded Patterns auf kompakte Weise die weitere Einschränkung der Suchkriterien. Dank Sealed Classes kann der Compiler zudem sicherstellen, dass alle möglichen Fälle abgedeckt sind. Wird der versiegelten Klassenhierarchie ein neuer Datentyp hinzugefügt, erhält man durch die Compilerfehler sofort eine Rückmeldung und vergisst somit nirgendwo die Auswertung der neuen Option.

Steter Tropfen höhlt den Stein und so entwickelt sich Java wie ein Uhrwerk von Release zu Release ein Stück weiter. Wir dürfen gespannt sein, wie sich das Pattern Matching in den nächsten Monaten integrieren und die Arbeit mit Java noch angenehmer machen wird.

 

Links & Literatur

[1] https://docs.scala-lang.org/tour/pattern-matching.html

[2] https://cr.openjdk.java.net/~briangoetz/amber/pattern-match.html

[3] https://openjdk.java.net/jeps/406

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

[5] https://openjdk.java.net/jeps/405

[6] http://learnyouahaskell.com/syntax-in-functions#pattern-matching

[7] https://openjdk.java.net/jeps/409

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