Java21 で正式追加された switch でのパターンマッチ(JEP 441: Pattern Matching for switch)

blog1.mammb.com



はじめに

Java17 でファースト・プレビューとして公開された、 switch でのパターンマッチが、Java21 で正式リリースにになります。 JEP 440 Record Patterns と合わせて、レコードのパターンマッチができるようになりました。

ここでは JEP 441 Pattern Matching for switch の内容について見ていきます。

なお、レコードパターン(JEP 440 Record Patterns)については以下を参考にしてください。

blog1.mammb.com


プレビュー版からの変更点

JEP 441 Pattern Matching for switch は以下のプレビューを経てリリースとなりました。

  • Java17 : JEP 406 としてファースト・プレビュー

  • Java18 : JEP 420 としてセカンド・プレビュー

    • ファースト・プレビューからの主な変更点は以下
    • ガードされたパターンより前に、同じ型の定数ケースラベルを配置するよう優先度チェックを変更
    • sealed クラスの網羅性チェック強化
  • Java19 : JEP 427 としてサード・プレビュー

    • セカンド・プレビューからの主な変更点は以下
    • ガードされたパターンが、スイッチブロックの when 節に置き換えられた
    • セレクタ式の値が null の場合の実行時セマンティクスをレガシースイッチセマンティクスと整合するよう変更
  • Java20 : JEP 433 としてフォース・プレビュー

    • サード・プレビューからの主な変更点は以下
    • 実行時に、 enum に対するマッチが失敗した場合、IncompatibleClassChangeError ではなく MatchException をスローするよう変更
    • スイッチラベルの文法がより簡素化された
    • ジェネリック レコード パターンの型引数の推論が、switch式とステートメントでサポートされるようになった
  • Java21 : JEP 441 として正式リリース

    • フォース・プレビューからの主な変更点は以下
    • 括弧付きパターンを削除
    • 修飾されたenum定数を case 定数として使用できるようになった


Pattern Matching for switch による機能拡張

Pattern Matching for switch により大きく以下の機能拡張が行われます。

  • switch文とswitch式のセレクタ式で使用できる型の範囲を広げる(スイッチブロックの網羅性の分析を含む)
  • 定数に加え、パターンと null を含むように case ラベルを拡張する
  • when 節を case ラベルの後に付けることで、ガード条件を指定できるようにする
  • enum 定数の case ラベルを改善する


従来の switch のセレクタ式には、整数プリミティブ型(longを除く。対応するボックス型含む)、String、または enum 型のいずれかでなければなりませんでした。 Pattern Matching for switch ではこの制限が緩和され、任意の参照型を指定することができるようになります。

スイッチブロック内のスイッチラベルの文法は以下のように変更となり、ラベルに null と パターンを指定できるようになります。

SwitchLabel:
  case CaseConstant { , CaseConstant }
  case null [, default]   ← ★追加
  case Pattern [ Guard ]  ← ★追加
  default

詳細を以下に順に見ていきましょう。


switch ラベルによるパターンマッチ

case ラベルには、定数だけでなくパターンを使えるようになりました。 スイッチラベルがパターンを持つ case ラベルの場合、選択されるラベルは等号テストではなく、パターンマッチングの結果によって決定されます。

型へのマッチは以下のように書くことができます。

static String formatterPatternSwitch(Object obj) {
    return switch (obj) {
        case Integer i -> String.format("int %d", i);
        case Long l    -> String.format("long %d", l);
        case Double d  -> String.format("double %f", d);
        case String s  -> String.format("String %s", s);
        case int[] ia  -> String.format("Array of ints);
        default        -> obj.toString();
    };
}


レコードパターンと合わせて以下のような操作が可能となります。

sealed interface Shape permits Circle, Rectangle, Square {}

record Circle(double radius) implements Shape {}
record Rectangle(double width, double height) implements Shape {}
record Square(double side) implements Shape {}
public double getArea(Shape shape) {
    return switch (shape) {
        case Circle(var radius) -> Math.PI * radius * radius;
        case Rectangle(var width, var height) -> width * height;
        case Square(var side) -> side * side;
    };
}


case ラベルの guard 条件指定

switch ブロックの when 節に、パターンの case ラベルに対するガードを指定できるようになりました。

guarded pattern case labels が導入され、オプションの guard (ブール値表現) をパターンラベルの後に置くことができます。(Java18 までは case Triangle t && (t.calculateArea() > 100) -> xx のように && でガード条件を記載したが、 Java19 のプレビューから when 節が導入)

static void testStringNew(String response) {
    switch (response) {
        case null -> { }
        case "y", "Y" -> { System.out.println("You got it"); }
        case "n", "N" -> { System.out.println("Shame"); }
        case String s
             when s.equalsIgnoreCase("YES") -> { System.out.println("You got it");}
        case String s
             when s.equalsIgnoreCase("NO") -> { System.out.println("Shame"); }
        case String s -> { System.out.println("Sorry?"); }
    }
}

これにより、すべての条件ロジックをswitchラベルの中に取り込むことができます。なお、ガードを持つことができるのは、パターンラベルだけです。


null と switch

従来、switch文や式は、セレクタ式がnullと評価されるとNullPointerExceptionをスローしましたが、Pattern Matching for switch によりラベルに null を指定できるようになります。

static void testFooBarNew(String s) {
    switch (s) {
        case null         -> System.out.println("Oops");
        case "Foo", "Bar" -> System.out.println("Great");
        default           -> System.out.println("Ok");
    }
}

これは、case null を使わない以下の(従来の)コードと同等です。

static void testFooBarOld(String s) {
    if (s == null) {
        System.out.println("Oops!");
        return;
    }
    switch (s) {
        case "Foo", "Bar" -> System.out.println("Great");
        default           -> System.out.println("Ok");
    }
}

case null が無い場合は、従来と同様に NullPointerException をスローします。 switch のセマンティクスとの後方互換性を保つために、default ラベルは null セレクタにマッチしないことに注意してください。 つまり、以下のコードは、

switch (s) {
    case "Foo", "Bar" -> System.out.println("Great");
    default           -> System.out.println("Ok");
}

以下と同等になります。

switch (s) {
    case null         -> throw new NullPointerException();
    case "Foo", "Bar" -> System.out.println("Great");
    default           -> System.out.println("Ok");
}

null を含めて default の挙動を行うには、以下のように書くことができます。

switch (s) {
    case "Foo", "Bar"  -> System.out.println("Great");
    case null, default -> System.out.println("Ok");
}


enum 定数 ラベルの改善

switch で enum を利用する場合、swicheのセレクタ式はenum型でなければならず、ラベルはenumの定数の単純な名前である必要がありました。

例えば以下のように、case ラベルには HEARTS のような enum 定数名以外は指定できませんでした。

public enum Suit { CLUBS, DIAMONDS, HEARTS, SPADES }

static void testforHearts(Suit s) {
    switch (s) {
        case HEARTS -> System.out.println("It's a heart!");
        default -> System.out.println("Some other suit");
    }
}

新しいコードでは、セレクタ式がenum型であるという条件が緩和され、case定数にはenum定数の修飾名を使用することができるようになりました。

例えば以下のようなシールされた enum があった場合を考えます。

sealed interface CardClassification
        permits Suit, Tarot {}

public enum Suit implements CardClassification { CLUBS, DIAMONDS, HEARTS, SPADES }
final class Tarot implements CardClassification {}

enum に対する制限のあるコードでは以下のような冗長な条件指定が必要でした。

static void exhaustiveSwitchWithoutEnumSupport(CardClassification c) {
    switch (c) {
        case Suit s when s == Suit.CLUBS -> { System.out.println("It's clubs"); }
        case Suit s when s == Suit.DIAMONDS -> { System.out.println("It's diamonds"); }
        case Suit s when s == Suit.HEARTS -> { System.out.println("It's hearts"); }
        case Suit s -> { System.out.println("It's spades"); }
        case Tarot t -> { System.out.println("It's a tarot"); }
    }
}

上記は以下のように書くことができるようになります。

static void exhaustiveSwitchWithBetterEnumSupport(CardClassification c) {
    switch (c) {
        case Suit.CLUBS -> { System.out.println("It's clubs"); }
        case Suit.DIAMONDS -> { System.out.println("It's diamonds"); }
        case Suit.HEARTS -> { System.out.println("It's hearts"); }
        case Suit.SPADES -> { System.out.println("It's spades"); }
        case Tarot t -> { System.out.println("It's a tarot"); }
    }
}


case ラベルの Dominance(優位性)

case ラベルには、 try - catch 節で複数の例外をキャッチする際の、例外の指定順序と同じような、条件の優位性が存在します。

例えば以下の例は、コンパイルエラーとなります。

switch (obj) {
    case CharSequence cs -> // ...
    case String s ->        // Error - pattern is dominated by previous pattern
    default -> // ...
}

パターン String s にマッチするすべての値はパターン CharSequence cs にもマッチするが、その逆はないので、最初の case ラベル case CharSequence cs は、2番目の case ラベル case String sdominate していると言います(2番目のパターンの型である String が、1番目のパターン型である CharSequence のサブタイプであるため)。

以下の(match-all スイッチラベルを持つ)例も同様にコンパイルエラーとなります。

switch(s) {
    case Object o: // ...
 default: // ...
}


guard 条件指定がある場合は、guard の中身についてのチェックは決定不可能なので行われませんが、guard 無しのパターンの前に記載する必要があります。

case ラベルの順序は、定数 case ラベルがガードされたパターン case ラベルの前に書き、それらがガードされていないパターン case ラベルの前に書きます。

Integer i = ...
switch (i) {
    case -1, 1 -> ...                   // Special cases
    case Integer j when j > 0 -> ...    // Positive integer cases
    case Integer j -> ...               // All the remaining integers
}


型の網羅性(Exhaustiveness)

スイッチ式は、セレクタ式のすべての可能な値をスイッチブロック内で処理することを要求します。 これは、switch 式の評価が成功すると必ず値が得られるという特性を維持するためです。

以下は型のカバレッジが網羅的でないため、コンパイルエラーとなります。

static int coverage(Object obj) {
    return switch (obj) {           // Error - still not exhaustive
        case String s  -> s.length();
        case Integer i -> i;
    };
}

defaultラベルの型網羅性はすべての型なので、以下のようにすれば、網羅性を満たすため、合法になります。

static int coverage(Object obj) {
    return switch (obj) {
        case String s  -> s.length();
        case Integer i -> i;
        default -> 0;
    };
}

パターン以外の switch 式には、型のカバレッジの概念がすでに存在します。 例えば以下は網羅的と判断されます。

enum Color { RED, YELLOW, GREEN }

int numLetters = switch (color) {
    case RED -> 3;
    case GREEN -> 5;
    case YELLOW -> 6;
}

例えば、case GREEN の条件を削除した場合は、網羅的ではないため、コンパイルエラーとなります。

match-all 節 を付けた場合は以下のようになります。

int numLetters = switch (color) {
    case RED -> 3;
    case GREEN -> 5;
    case YELLOW -> 6;
    default -> throw new ArghThisIsIrritatingException(color.toString());
}

これは、将来 Color.BLUE などが追加された場合にコンパイルエラーとして検出できないため、可能であれば、match-all 節のない網羅的な switch 書くべきです。

網羅性の要件は、パターン switch 式とパターン switch 文の両方に適用されます。 後方互換性を確保するために、既存のすべての switch ステートメントは変更せずにコンパイルされます。

セレクタ式の型がシールクラスの場合には、シールクラスの permits 句が考慮されるため、型の網羅性が保証されます。 switch ステートメントが switch 拡張機能のいずれかを使用している場合、コンパイラーはそれが網羅的であるかどうかをチェックします(将来のJava言語のコンパイラーは、網羅的でないレガシーな switch ステートメントに対して警告を発するかもしれません)。


パターン変数宣言のスコープ

パターン変数宣言のスコープは以下のように拡張されます。

  • ガードされた case ラベルのパターン内で発生するパターン変数宣言のスコープは、ガード、すなわち when 式を含む
  • switch ルールの case ラベルに現れるパターン変数宣言のスコープには、矢印の右側に現れる式、ブロック、または throw ステートメントが含まれる
  • switch ラベルの付いたステートメントグループの case ラベルに現れるパターン変数宣言のスコープは、ステートメントグループのブロックステートメントを含む(パターン変数を宣言した case ラベルを通過することは禁止)

以下の例は、ケースブロックで ci の両方がスコープに入るが、どちらかしか初期化されていない状況となるため許可されません。

switch (obj) {
    case Character c, Integer i: // error
 default: break;
}

以下も同様に許可されていません。

switch (obj) {
    case Character c, Integer i -> ... // error
    default -> ...;
}

以下のように break が無く、フォールスルーする場合もコンパイルエラーとなります。

switch (obj) {
    case Character c: System.out.println("Character: " + c);
    case Integer i: System.out.println("Integer: " + i);
 default:
        break;
}

一方、以下のようにパターン変数を宣言していないラベルをパススルーするようなケースは合法です。

switch (obj) {
    case String s: System.out.println("A string: " + s);
 default: System.out.println("Done");
}


switch で発生するエラー

パターン switch の中にセレクタ式の値と一致するラベルがない場合も(パターンスイッチは網羅的でなければならないので) MatchException がスローされます。

パターンマッチの過程でエラーが発生する場合、パターンマッチは MatchException をスローするように定義されています(アクセサーメソッド内の処理で例外など)。 このようなパターンが switch のラベルとして現れると、 switchMatchException をスローします。 例えば以下のケースが該当します。

record R(int i) {
    public int i() { return i / 0; } // bad (but legal) accessor method for i
}

static void exampleAnR(R r) {
    switch(r) {
        case R(var i): System.out.println(i);
    }
}

以下のケースは、 example(new R(42))を実行すると、ArithmeticException がスローされます。

static void example(Object obj) {
    switch (obj) {
        case R r when (r.i / 0 == 1): System.out.println("It's an R!");
        default: break;
    }
}


enum に対する switch は、switch がコンパイルされた後に enum クラスが変更された場合に、従来は IncompatibleClassChangeError をスローしましたが、今後は MatchException をスローするよう変更されています。