Inselupdate: enum im switch mit Pattern-Matching in Java 21

Seit Java 21 ist die Möglichkeit vom switch stark erweitert worden. Das betrifft auch Aufzählungstypen. Es war immer ein wenig umständlich, wenn ein switch Konstanten von verschiedenen Aufzählungstypen vergleichen sollte. Das ist einfacher geworden und dazu ein kleines Beispiel. Nehmen wir zwei enum an, die keine Typbeziehung haben:

enum CoffeeSize { SMALL, LARGE }

enum TeeSize { SMALL, LARGE }

Die Größen sollen nun zusammen behandelt werden. Wir können schreiben:

static void orderSize( Enum size ) {

  switch ( size ) {

    case CoffeeSize.SMALL -> System.out.println( "Small" );

    case TeeSize.SMALL -> System.out.println( "Small" );

    case CoffeeSize.LARGE -> System.out.println( "Large" );

    case TeeSize.LARGE -> System.out.println( "Large" );

    default -> {

    }

  }

}

Da der Compiler von allen einen beliebigen Enum ausgeht, kann er keine vollständige Abdeckung erkennen. Wir können das mit einem Basistyp allerdings lösen,

Wenn ein switch-Ausdruck mit einem einzelnen Aufzählungstyp verwendet wird, kann der Compiler überprüfen, ob alle Aufzählungselemente abgedeckt sind und ob ein default-Zweig entfallen kann. Der Compiler kann auch eine vollständige Abdeckung überprüfen, wenn der switch-Block verschiedene Aufzählungstypen behandelt und dabei ein versiegeltes Interface als Basistyp verwendet wird. Betragen wir das Beispiel aus dem vorherigen Kapitel erneut und schreiben es um:

sealed interface DrinkSize permits CoffeeSize, TeeSize {}

enum CoffeeSize implements DrinkSize { SMALL, LARGE }

enum TeeSize implements DrinkSize { SMALL, LARGE }




static void orderSize( DrinkSize size ) {

  switch ( size ) {

    case CoffeeSize.SMALL -> System.out.println( "Small" );

    case TeeSize.SMALL -> System.out.println( "Small" );

    case CoffeeSize.LARGE -> System.out.println( "Large" );

    case TeeSize.LARGE -> System.out.println( "Large" );

  }

}

 

Inselupdate: Neue Math Methoden clamp(…)

Seit Java 21 gibt es in der Klasse Math neue Methoden, die einen Wert in einem Bereich halten:

  • static double clamp(double value, double min, double man)
  • static float clamp(float value, float min, float max)
  • static int clamp(long value, int min, int man)
  • static long clamp(long value, long min, long max)

Die Methoden basieren im Kern auf einem verschachtelten Math.min(max, Math.max(value, min)), lösen aber Ausnahmen aus, wenn der Endwert vor dem Startwert liegt.

Inselupdate: Records implementieren Schnittstellen

Obwohl Records keine Klassen erweitern können, können sie Schnittstellen implementieren. Dies ermöglicht uns, Abstraktionen zu erstellen, was nützlich ist, um gemeinsame Record-Komponenten zu teilen.

Betrachten wir ein Beispiel. Wir wollen den Basistyp Event nicht mehr als abstrakte Oberklasse deklarieren, sondern als Schnittstelle:

interface Event {

  String about();

  int duration();

}

Das erlaubt es uns, zwei Records zu deklarieren, die die Event-Schnittstelle implementieren:

record Nap( String about, int duration ) implements Event {}

record Workout( String about, int duration, int caloriesBurned ) implements Event {}

Der clevere Teil dabei ist, dass die Records bereits die Zugriffsmethoden String about() und int duration() besitzen, sodass keine zusätzliche Implementierung erforderlich ist.

Mit dieser Typbeziehung können wir Folgendes tun:

Event event = new Nap( "Snooze Olympics", 69 );

System.out.println( event.about() );

System.out.println( event.duration() );

In diesem Fall ist der Referenztyp Event und der Objekttyp Nap. Mit dieser Abstraktion lässt sich perfekt Pattern-Matching und Record-Pattern einsetzen:

switch ( event ) {

  case Nap nap ->

      System.out.printf( "%d minutes of ninja-level rest!%n", nap.duration );

  case Workout( var about, var duration, var calories ) ->

      System.out.printf("You just burned %d calories for a guilt-free gummy bear.%n", calories );

  default -> {}

}

Inselupdate: Record-Patterns in Java 21

Führen wir für die folgenden Beispiele ein Record für Punkte ein:

record Point( int x, int y ) {}

Nun möchten wir überprüfen, ob die X-Y-Koordinaten eines Punktes auf null stehen. Dafür erstellen wir eine Methode namens isZeroPoint(…), die alle Objekttypen akzeptiert und false zurückgibt, wenn es sich nicht um einen Punkt handelt:

static boolean isZeroPoint( Point object ) {

  if ( object instanceof Point ) {

    Point point = (Point) object;

    return point.x() == 0 && point.y() == 0;

  }

  return false;

}

Eine kompaktere Version des Tests, die auf eine Zwischenvariable verzichtet, kann so aussehen:

return object instanceof Point && ((Point) object).x() == 0 && ((Point) object).y() == 0;

Lesbarerer ist es nicht.

Der Einsatz einer Pattern-Variable verbessert die Lesbarkeit und Klarheit des Codes:

static boolean isZeroPoint( Object object ) {

  return object instanceof Point p && p.x() == 0 && p.y() == 0;

}

Der Test p.x() == 0 && p.y() == 0 lässt sich mit einem Trick noch weiter verkürzen:
static boolean isZeroPoint( Object object ) {

  return object instanceof Point p && (p.x() | p.y()) == 0;

}

Wenn zwei Zahlen mittels der bitweisen ODER-Operation verknüpft werden und eine davon nicht null ist, wird auch das Ergebnis nicht null sein. Dies ermöglicht eine elegante und kompakte Überprüfung der Nullbedingung für beide Koordinaten gleichzeitig.

Record-Pattern einsetzen

Auffällig an der bisherigen Lösung ist die Notwendigkeit einer Point-Variable p, um auf p.x() und p.y() zuzugreifen. Java 21 führt Record-Pattern[1] ein, die in anderen Programmiersprachen als Destrukturierung bezeichnet werden. Record-Pattern können sowohl bei instanceof als auch bei switch verwendet werden. Hier ist ein Beispiel für instanceof, wodurch isZeroPoint(…) etwas kürzer wird:

static boolean isZeroPoint( Point point ) {

  return point instanceof Point( int a, int b ) && (a | b) == 0;

}

Der Teil Point(int a, int b) nennt sich Record-Pattern. Nach einer Übereinstimmung werden neue lokale Variablen a und b eingeführt, die vom Punkt die Koordinaten enthalten. Das heißt, a wird mit point.x() und b mit point.y() belegt. Die Variablennamen müssen nicht mit den Record-Komponentennamen übereinstimmen; in diesem Fall heißen sie a und b, x und y wären aber möglich. Wichtig ist, alle Record-Komponenten aufzulisten; keine Record-Komponente darf ausgelassen werden.

Kommen wir von Punkten zu Linien. Betrachten wir ein neues Record:

record Line( Point start, Point end ) {}

Schreiben wir eine zweite Methode isZeroLine(…), die überprüft, ob die beiden Punkte der Linie Null sind oder nicht. Beginnen wir mit einer Pattern-Variablen:

static boolean isZeroLine( Object object ) {

  return    object instanceof Line line

         && (  line.start().x() | line.start().y()

             | line.end().x()   | line.end().y() ) == 0;

}

Da es sich bei Line um einen Record handelt, kann das Record-Pattern angewendet werden:

static boolean isZeroLine( Object object ) {

  return    object instanceof Line( Point start, Point end )

         && (start.x() | start.y() | end.x() | end.y()) == 0;

}

Die Datentypen können mit var abgekürzt werden:

static boolean isZeroLine( Object object ) {

  return    object instanceof Line( var start, var end )

         && (start.x() | start.y() | end.x() | end.y()) == 0;

}

Geschachtelte Record-Pattern

Record-Pattern können auch verschachtelt werden, um komplexere Strukturen abzubilden:

static boolean isZeroLine( Object object ) {

  return object instanceof Line(

    Point( int x1, int y1 ), Point( int x2, int y2 )

   ) && (x1 | y1 | x2 | y2) == 0;

}

Das Code-Volumen schrumpft nicht, daher ist es Geschmacksache, ob die Variante besser ist.

Record-Pattern bei switch

Das Pattern-Matching bei switch ist in Java 21 noch leistungsfähiger geworden. Erstellen wir eine zweite Methode isZero(Object), die sowohl Punkte als auch Linien überprüfen kann. Lösen wir die Aufgabe zuerst wie bekannt mit Pattern-Variablen:

static boolean isZero( Object o ) {

  return switch ( o ) {

    case Point p -> (p.x() | p.y()) == 0;

    case Line l -> (l.start().x() | l.start().y() | l.end().x() | l.end().y()) == 0;

    default -> false;

  };

}

Neben instanceof sind Record-Pattern auch bei switch-case möglich. Die Methode kann wie folgt umgeschrieben werden:

static boolean isZero( Object o ) {

  return switch ( o ) {

    case Point( int x, int y ) -> (x | y) == 0;

    case Line( Point s, Point e ) -> isZero( s ) && isZero( e );

    default -> false;

  };

}

Bei der Linie lässt sich das Record-Pattern wieder schachteln:

static boolean isZero( Object o ) {

  return switch ( o ) {

    …

    case Line( Point( int x1, int y1 ), Point( int x2, int y2 ) )

          -> (x1 | y1 | x2 | y2) == 0;

    …

  };

}

Allerdings ist auch hier der Code wieder länger.

Pattern-Matching mit Record-Pattern und when

Wir haben gesehen, dass Pattern-Matching mit Record-Pattern zur Destrukturierung möglich ist. Auch lässt sich ein when für eine weitere Abfrage einsetzen. Die Bedingung hinter when kann auf die Pattern-Variable oder Variable aus dem Record-Pattern zugreifen.

Ein Beispiel: Wir möchten Candy und Book als Records implementieren:

record Candy( int calories ) {}

record Book( String title, int numberOfPages ) {}

Ein Block kann wie folgt Ausgaben zu unterschiedlichen Objekttypen formulieren:

Object object = new Candy( 120 );




switch ( object ) {

  case Candy candy

  when candy.calories > 10_000 ->

      System.out.println( "Are you trying to sweeten the whole world?" );




  case Candy candy ->

      System.out.println("Is this candy trying to start a dance party in my mouth?");




  case Book( var title, var pages )

  when pages > 100 ->

      System.out.println(

          "Looks like someone was on a mission to make the dictionary jealous." );




  case Book( var title, var pages )

  when title.isEmpty() ->

      System.out.println("Diving into books that forgot to introduce themselves.");




  case Book -> System.out.println( "Opening minds, one page at a time" );




  default -> System.out.println("Who knew boredom could be so three-dimensional?");

};

Das Beispiel zeigt Pattern-Matching sowohl ohne als auch mit Record-Pattern. Auch hier müssen wir erneut die Dominanz beachten. Es wäre inkorrekt, case Book -> über die anderen Fallblöcke case Book( var title, var pages ) when … zu setzen.

[1] https://openjdk.org/jeps/440

Inselupdate: Pattern-Matching bei switch in Java 21

Wir haben bereits gesehen, dass der instanceof-Operator genutzt werden kann, um einen einzelnen Typ zu prüfen. Nun können wir diese Fallunterscheidung erweitern, um mehrere Typen zu testen. Nehmen wir an, dass die Zustände von Nap, Workout und Event im XML-Format gespeichert werden sollen. In diesem Kontext kann eine neue Methode die Abbildung auf XML übernehmen:

static String toXml( Object o ) {

  if ( o == null )

    return "<null />";

  if ( o instanceof Nap nap )

    return "<nap about=\"%s\" duration=%s />".formatted( nap.about, nap.duration );

  else if ( o instanceof Workout workout )

    return "<workout about=\"%s\" duration=%s caloriesBurned=%s/> "

        .formatted( workout.about, workout.duration,

                    workout.caloriesBurned );

  else if ( o instanceof Event event )

    return "<event about=\"%s\" duration=%s />".formatted( event.about, event.duration );

  else

    return "<object />";

}

Gerade für solche Abfragen wurde in Java 21 die switch-Anweisung bzw. der switch-Ausdruck erweitert, um die Möglichkeit zu bieten, Typen zu testen und somit eine kaskadierte Typprüfung durchzuführen. Diese Erweiterung wird als Pattern-Matching bei switch (engl. Pattern Matching for switch)[1] bezeichnet, und es handelt sich um das Pendant zum Pattern-Matching bei instanceof.

Die Methode toXml(…) kann folgendermaßen umgeschrieben werden:

static String toXml( Object o ) {

  switch ( o ) {

    case null -> {

      return "<null />";

    }

    case Nap nap -> {

      return "<nap about=\"%s\" duration=%s />".formatted( nap.about, nap.duration );

    }

    case Workout workout -> {

      return "<workout about=\"%s\" duration=%s caloriesBurned=%s/> "

          .formatted( workout.about, workout.duration,

                      workout.caloriesBurned );

    }

    case Event event -> {

      return "<event about=\"%s\" duration=%s />".formatted( event.about, event.duration );

    }

    default -> {

      return "<object />";

    }

  }

}

Die Überprüfung auf null ist mithilfe von case null möglich – eine weitere Erweiterung von Java 21.

Hinweis: Auch in Fällen, in denen eine switch-Anweisung verwendet wird, muss beim Pattern-Matching die Abdeckung vollständig sein. Daher ist in unserem Fall der default-Zweig erforderlich.

Die gewählte Lösung mit der switch-Anweisung ist zwar umsetzbar, doch da jeder switch-Block mit einem return endet, ist auch ein switch-Ausdruck möglich. Eine alternative Schreibeweise für toXml(…) lautet:

static String toXml( Object o ) {

  return switch ( o ) {

    case null -> "<null />";

    case Nap nap ->

        "<nap about=\"%s\" duration=%s />".formatted( nap.about, nap.duration );

    case Workout workout ->

        "<workout about=\"%s\" duration=%s caloriesBurned=%s/> "

            .formatted( workout.about, workout.duration,

                        workout.caloriesBurned );

    case Event event ->

        "<event about=\"%s\" duration=%s />".formatted( event.about, event.duration );

    default -> "<object />";

  };

}

Dominanz

Normalerweise spielt bei einem switch-case die Reihenfolge der case-Blöcke keine Rolle –abgesehen vom Durchfallen, was jedoch bei -> nicht mehr existiert. Beim Pattern-Matching spielt die Reihenfolge sehr wohl eine Rolle und folgendes wäre nicht korrekt:

return switch ( o ) {      

  case null -> "<null />";

  case Event event -> "…";       // ☠ case-Label dominiert

  case Nap nap -> "…";

  …

}

Das case-Label case Event event dominiert das case-Label case Nap nap, daher müssen wir die Reihenfolge berücksichtigen. Im Übrigen gibt es bei der Ausnahmebehandlung einen ähnlichen Fall, Details finden meine Leser im Abschnitt 9.3.5, „Schon gefangen?“

Pattern-Matching mit when Wächter

Bisher haben wir in den case-Blocken nur den Typ überprüft. Es ist jedoch möglich, zusätzliche Bedingungen anzufügen. Hierfür wird nach der Pattern-Variable das Schlüsselwort when verwendet, gefolgt von einer Bedingung, die auf die Pattern-Variable:

Event event = new Nap();

switch ( event ) {

  case Nap nap

  when nap.duration < 10 ->

      System.out.println( "Too brief a sleep, not worth it." );




  case Nap nap

  when nap.duration > 100 -> System.out.println( "That's too long, wake up." );

 

  case Nap nap -> System.out.println( "Recharge and renew with every sleep" );




  case Workout workout -> System.out.println( "Elevate your fitness game");




  default -> {}

}

Hinter dem Schlüsselwort when kann eine Bedingung angegeben werden, die als Wächter (engl. guard) bezeichnet wird. Die Auswertung des case-Blocks erfolgt erst, wenn der Typ übereinstimmt und die Bedingung erfüllt ist. Die Prüfung auf den gleichen Typ kann mehrfach in verschiedenen Blöcken erfolgen. Wir müssen auch hier wieder die Dominanz berücksichtigen. So wäre es falsch, mit case Nap nap -> zu beginnen und erst dahinter ein case Nap nap when … -> zu setzen.

[1] https://openjdk.org/jeps/433