Switch

[Java] 大きく変化したswitchのすべて

こんにちは!

カサレアルでJavaのコースを担当している櫻庭です。

switchは、if文と同様に、処理の分岐に使用する制御構文です。Javaの基本的な構文なので、一度は使ったことがあるはずですね。

ところが、2020年にリリースされたJava 14から五月雨式にswitchの言語仕様が変更され、元々のswitchの書き方とは大きく変わってしまいました。それにともない、switchの使い方も変化してきています。

もちろん、Javaは後方互換性を重視するプログラミング言語ですので、今までの書き方も引き続き使用できます。しかし、新しい書き方を使えば、従来のswitchの問題を解消することができます。

そこで、本エントリーでは、大きく変化したswitchの言語仕様をまとめて、紹介していきましょう。

目次

1. 従来のswitchと、その問題点

switchはJavaの基本的な制御構文なので書き方もご存じだとは思いますが、ここでおさらいしておきましょう。

従来のswitchは、式を評価した結果の値によって処理を分岐させます。どの値と一致するかはcaseラベルを用いて記述します。

switch (式) {
    case 定数1:
        // 式を評価した結果が定数1と一致した場合に行う処理
           ...
        break;
    case 定数2:
        // 式を評価した結果が定数2と一致した場合に行う処理
           ...
        break;

      ...

    default:
        // 式を評価した結果がいずれの定数とも一致しない場合の処理
}

たとえば、int型の変数xの値が0と1、それ以外で出力を変更したいのであれば、次のように書きます。

int x = ...;

switch (x) {
    case 0:
        IO.println("ZERO");
        break;
    case 1:
        IO.println("ONE");
        break;
    default:
        IO.println("Other");
}

値と定数が一致しているかどうかで処理を分岐させる場合、if文よりもswitchの方が記述量も少なく、理解しやすいコードになります。

しかし、使う上での問題や制約もありました。たとえば、以下のようなものです。

  • フォールスルー
  • switchの処理結果の扱い
  • caseラベル

この3つについて、以下にもう少し詳しく説明しましょう。

1.1 フォールスルー

switchで想定していた動作と異なるバグが発生した場合、break文の書き忘れによることが多いのではないでしょうか。

たぶん誰もが一度や二度はbreak文を書き忘れているはず。もちろん、櫻庭も何度かやらかしています。

caseの最後にbreak文を書かなかった場合、そのまま次のcaseに記述した処理が実行されます。これをフォールスルーと呼びます。

たとえば、1, 2, 3, 4の値をとる変数に対し、1と3では”奇数”と表示し、2と4では”偶数”と表示させるには、次のように記述します。

int x = ...;

switch (x) {
    case 1:
    case 3:
        IO.println("奇数");
        break;
    case 2:
    case 4:
        IO.println("偶数");
        break;
    default:
        IO.println("想定外");
}

このコードはあえてフォールスルーを使用した例です。

問題は、この例のようにフォールスルーをあえて使用した場合と、break文を書き忘れてしまった場合の区別がつかないことです。

コメントが書いてあれば判別がつくかもしれませんが、コードだけではなかなか判別がつきません。

つまり、フォールスルーを使用したコードが、バグなのか仕様なのか分かりにくくなってしまうのです。

1.2 switchの処理結果の扱い

switchのcaseで何らかの処理をした結果を残したい場合、結果を代入する変数が必要になります。

たとえば、三角と四角を表すenumのShapeに対して、幅と高さを使用して面積を計算してみましょう。コードは以下のようになります。

enum Shape { TRIANGLE, SQUARE }

Shape shape = ...; // 形状
double width = ...; // 幅
double height = ...; // 高さ

// 面積
double area;

switch (shape) {
    case TRIANGLE:
        area = width * height / 2.0;
        break;
    case SQUARE:
        area = width * height;
        break;
    default:
        IO.println("想定外");
}

このコードはdefault節の場合、変数areaが初期化されないためコンパイルに失敗します。

しかし、変数shapeは列挙型であり、すべての定数のケースを記述しているので、defaultが実行されることはありません。とはいっても、コンパイラにはそれが分からないため、コンパイルエラーになってしまいます。

コンパイルエラーを解消するにはいくつかの方法がありますが、それぞれ一長一短です。

たとえば、変数areaの宣言時に初期値、たとえば0.0を代入しておく方法です。

この場合、switchのcaseで変数areaに処理結果を再代入します。

変数areaを再代入できるようにするということは、他の箇所でも再代入が可能ということになります。安易な変数への再代入はバグの原因になりがちで、できれば避けたいところです。

変数をfinalで定義することにより不用意な書き換えを防ぐことができますが、この方法では変数areaをfinalで宣言することもできません。

defaultで変数areaに値を代入するという方法もあります。

しかし、このコードでは意味のないdefaultで変数に値を代入するということは、やはり意味のないコードになってしまいます。

であれば、defaultを書かないという方法も可能です。

とはいうものの、安易にdefaultを記述しないのも問題です。defaultの書き忘れによるバグも発生しやすいので、できればdefaultは残しておきたいですね。

このように、switchで処理結果を残すには、いろいろと注意しなければいけない点が多くあるのです。

1.3 caseラベル

caseラベルに記述できるのは、long型以外の整数と、文字列、enumだけです。

if文の条件式の自由さは求めていませんが、もう少し多様な記述ができればと考えたことはないでしょうか。

最近、ドメイン駆動設計が一般的になってきており、値クラスのようにデータの種類を型で表すことも増えてきました。データの種類を型で表した場合、データの種類によって処理を分岐させるのであれば、caseラベルにもデータの型を使いたいところです。

もちろん、instanceof演算子とif文を組み合わせれば記述できます。しかし、if文だとどうしても記述が冗長になってしまいます。switchであれば簡潔に記述できるはずですが、従来のswitchではできませんでした。

これらの問題や制約を克服したのが、新しいswitchです。では、新しいswitchをどのように記述するのか、これから説明していきましょう。

2. 新しいswitchの書き方

新しいswitchは記法が大幅に変わりましたが、それ以外の変更もあります。また、これらの変更の中には、従来の記法と一緒に使用できるものもあります。

まとめて説明してしまうと複雑になってしまうので、変更点ごとに説明していきましょう。

  • caseの記法
  • 文から式へ
  • 型による分類
  • 網羅性のチェック

caseの記法の変更以外の変更点は従来の記法でも使用できます。

では、1つ1つ説明していきましょう。

2.1 caseの記法

switchにおけるcaseの記述が、ラムダ式のように->で記述するようになりました。

switch (式) {
    case 定数1 -> {
        // 式を評価した結果が定数1に一致した場合に行う処理
        ...
    }
    case 定数2 -> {
        // 式を評価した結果が定数2に一致した場合に行う処理
        ...
    }
   
        ...

    default -> {
        // 式を評価した結果がいずれの定数に一致しない場合に行う処理
    }
}

アロー(->) の後に波カッコのブロックで処理を記述します。

たとえば、はじめに示したint型の変数xの値が0と1、それ以外で出力を変更するサンプルは、次のようになります。

int x = ...;

switch (x) {
    case 0 -> {
        IO.println("ZERO");
    }
    case 1 -> {
        IO.println("ONE");
    }
    default -> {
        IO.println("Other");
    }
}

caseの処理をブロックで示すようになったので、break文は必要なくなりました。ブロックで記述することで、caseの処理の範囲が明確になっています。

従来の記法も引き続き使用できますが、アロー(->)での記法と従来の記法を混在させることはできません。

さらに、処理が1行の場合、波カッコを省略できます。この場合でもラムダ式とは異なり、文の最後のセミコロンは省略しないようにします。

上記の例も処理が1行なので、次のように波カッコを省略できます。

int x = ...;

switch (x) {
    case 0 -> IO.println("ZERO");
    case 1 -> IO.println("ONE");
    default -> IO.println("Other");
}

かなりスッキリして、コードの見通しがよくなりました。

caseラベルに定数をカンマ区切りで列挙することで、複数の値を使えるようになりました。

さきほどの、1, 2, 3, 4の値をとる変数に対し、1と3では”奇数”と表示し、2と4では”偶数”と表示させるサンプルは、次のようになります。

int x = ...;

switch (x) {
    case 1, 3 -> IO.println("奇数");
    case 2, 4 -> IO.println("偶数");
    default -> IO.println("想定外");
}

この記述であれば、複数の定数と一致する場合でも、フォールスルーとは異なり、意図が明確になりますね。

2.2 文から式へ

従来のswitchは値を返すことができないため、事前に変数を用意して初期化や、caseでの再代入が必要になりました。これは、問題点の節で説明したように、バグの原因にもなりかねません。

これに対し、新しいswitchでは値を返せるようになりました。

ここまで、あえてswitchとあいまいに記述していたのは、従来のswitchは値を返さない文だけであるのに対し、新しいswitchは値を返す式にもなるからです。従来はswitch文であり、現在はswitch式になったということです。

値を返すには、caseブロックでyeildを使用します。

たとえば、先ほどの面積を求めるサンプルをswitch式で記述すると、次のようになります。

// 面積
final double area = switch (polygon) {
    case TRIANGLE -> {
        yield width * height / 2.0;
    }
    case SQUARE -> {
        yield height * width;
    }
};

switch式で値を返すことにより、変数areaをfinalで定義できるようになりました。

final変数であれば、不用意に値を書き換えられることもないため、バグの原因にもなりにくくなりますね。

defaultを記述していないことについては、後ほど説明します。

注意しなくてはいけないのが、switch文では文末にセミコロンは不要でしたが、switch式で値を返す場合はセミコロンが必要なことです。

さらに、caseの波カッコを省略できる場合は、yeildも省略できます。

上記のサンプルも処理が1行で、波カッコが省略できるので、次に示すようにyeildも省略できます。

// 面積
final double area = switch (shape) {
    case TRIANGLE -> width * height / 2.0;
    case SQUARE -> height * width;
};

yieldは、アローを使用しない古い記述でも使用できます。

yeildは値を返すためにswitch式を抜けるので、yeildの後にbreakを記述する必要はありません。

// 面積
final double area = switch (shape) {
    case TRIANGLE:
        yeild width * height / 2.0;
    case SQUARE:
        yeild width * height;
};

switchを式として使うことにより、変数の初期化忘れや、再代入による不用意な値の書き換えを防ぐことができます。分岐した処理で何らかの値を返す場合は、積極的にswitch式を使っていきましょう。

2.3 型による分類

switchの問題のところで説明したように、caseラベルに記述できるのは、long型以外の整数と、文字列、enumだけです。

これに対し、新しいswitch式ではラベルに型が使えるようになりました。つまり、caseラベルにクラスやインタフェースが使えるようになったということです。

これは、型によるパターンマッチング、もしくは単にパターンマッチングと呼ばれている機能です。

caseラベルに型を使用する場合、型名の後にローカル変数名を記述します。このローカル変数には、switchの式を評価した結果が代入され、caseのブロック内で使えます。

パターンマッチングを使わない場合、switchの評価に使用する式の値を使用するには、型ごとにキャストが必要になります。しかし、パターンマッチングを使用する場合、自動的にキャストしてくれます。

switch (式) {
    case 型1 変数1 -> {
        // 式を評価した結果が型1に一致した場合に行う処理
        // 式を評価した結果は変数1に代入され、ブロック内で使用できる
        ...
    }
    case 型2 変数2 -> {
        // 式を評価した結果が型2に一致した場合に行う処理
        // 式を評価した結果は変数2に代入され、ブロック内で使用できる
        ...
    }
   
        ...

    default -> {
        // 式を評価した結果がいずれの型にも一致しない場合に行う処理
    }
}

たとえば、会員情報を扱うMemberインタフェースがあったとしましょう。それに対し、一般会員、シルバー会員、ゴールド会員を表すStardardMemberクラス、SilverMemberクラス、GoldMemberクラスを定義したとします。

この時に、会員のランクごとに異なる処理を行うには、次のように記述します。

Member member = new ...;

switch (member) {
    case StandardMember standard
            -> IO.println("Standard: " + standard.getName());
    case SilverMember silver
            -> IO.println("Silver: " + silver.getName());
    case GoldMember gold
            -> IO.println("Gold:  " + gold.getName());
    default -> IO.println("Other: " + member.getName());
}

もちろん、従来のcaseの記法でも型を使用できます。

上記のコードを従来の記法で書き直すと、次のようになります。

switch (member) {
    case StandardMember standard:
            IO.println("Standard: " + standard.getName());
            break;
    case SilverMember silver:
            IO.println("Silver: " + silver.getName());
            break;
    case GoldMember gold:
            IO.println("Gold:  " + gold.getName());
            break;
    default: IO.println("Other: " + member.getName());
}

2.3.1 nullを扱う

上記のコードではmemberがnullの場合、NullPointerException例外が発生してしまいます。

これを防ぐために、caseのラベルにnullが使えるようになりました。

switch (member) {
    case StandardMember standard
            -> IO.println("Standard: " + standard.getName());
    case SilverMember silver
            -> IO.println("Silver: " + silver.getName());
    case GoldMember gold
            -> IO.println("Gold:  " + gold.getName());
    case null -> IO.println("member is null");
    default -> IO.println("Other");
}

もちろん、switchの前にif文でnullチェックを行うことも可能です。しかし、switchでnullもcaseとして扱える方がコードが簡潔になりますね。

2.3.2 型に条件を追加する

型の判別に加えて、何らかの条件をcaseに記述することもできるようになりました。この条件はガード節として扱えます。

ガード節は型のラベルの後にwhenを用いて記述します。

たとえば、上記の会員によるswitchにおいて、一般会員だけ年齢で処理を分けてみましょう。年齢はMemberインタフェースのgetAge()メソッドで返すようにします。

これをガード節を使用して記述してみましょう。

switch (member) {
    case StandardMember young when young.getAge() >= 20
            -> IO.println("Young Standard: " + young.getName());
    case StandardMember standard
            -> IO.println("Adult Standard: " + standard.getName());
    case SilverMember silver
            -> IO.println("Silver: " + silver.getName());
    case GoldMember gold
            -> IO.println("Gold:  " + gold.getName());
    case null -> IO.println("member is null");
    default -> IO.println("Other: " + member.getName());
}

同じStandardMemberクラスに合致した場合でも、ガード節により処理を分けることができました。

2.3.3 レコードパターン

レコードクラスを使ってパターンマッチングする場合、レコードコンポーネントを直接ローカル変数に代入できます。これをレコードパターンと呼びます。

説明よりもコードを見ていただいた方が早いですね。

例として、立体を表すFigureインタフェースを定義してみましょう。そして、Figureインタフェースを実装したレコードクラスとして球 Sphere、直方体 Cuboid、立方体 Cubeを定義します。

interface Figure {}

record Sphere(double radius) implements Figure {}
record Cuboid(double width, double height, double depth) implements Figure {}
record Cube(double edge) implements Figure {}

これらのレコードクラスに対して、体積を求めるcalcVolumeメソッドを作ってみましょう。

double calcVolume(Figure shape) {
    return switch (shape) {
        case Sphere(double r) -> Math.PI * r * r * r * 4.0 / 3.0;
        case Cuboid(double w, double h, double d) -> w * h * d;
        case Cube(double e) -> e * e * e;
        default -> throw new IllegalArgumentException();
    };
}

レコードクラスのオブジェクトをローカル変数に代入するのではなく、Shpereレコードクラスであれば、レコードコンポーネントのradiusの値をローカル変数のrに代入しています。

レコードコンポーネントが複数ある場合、レコードコンポーネントを定義した並び順とローカル変数の並び順は同じになります。このため、Cuboidレコードクラスであれば、変数wにwidthレコードコンポーネント、変数hにheightレコードコンポーネント、変数dにdepthレコードコンポーネントの値が代入されます。

また、複数のレコードコンポーネントのうち一部だけ使用したいという場合でも、すべてのレコードコンポーネントに対応する変数を列挙する必要があります。

レコードクラスのアクセッサーメソッドを使わずにすむため、コードが簡潔に表記できますね。

2.3.4 パターンマッチングのローカル変数が不要の場合

次に示すコードのように、型の違いによって表示を変化させるだけでローカル変数を使わない場合があります。

switch (shape) {
    case Sphere sphere -> IO.println("球");
    case Cuboid cuboid -> IO.println("直方体");
    case Cube cube -> IO.println("立方体");
    default -> IO.println("その他");
};

このような場合、使用しないローカル変数を_ (アンダースコア)で置き換えることができます。

switch (shape) {
    case Sphere _ -> IO.println("球");
    case Cuboid _ -> IO.println("直方体");
    case Cube _ -> IO.println("立方体");
    default -> IO.println("その他");
};

上記のコードではすべてのローカル変数をアンダースコアで置き換えましたが、一部のローカル変数だけを置き換えることも可能です。

また、レコードパターンにおいても、使用していないレコードコンポーネントだけをアンダースコアで記述することができます。

2.4 網羅性のチェック

網羅性のチェックは言語仕様の変更ではありません。書き方が変わるということではなく、コンパイラーが強力になってバグが出にくくなったということです。また、不必要な記述も減らすことができます。

たとえば、「1.2 switchの処理結果の扱い」で使用した面積を求めるswitch文は本来はdefaultは必要ありません。そこで次のようにdefaultを省略して記述したとします。

double area = switch (shape) {
    case TRIANGLE -> width * height / 2.0;
    case SQUARE -> width * height;
};

この時に、SQUAREのcaseを書き忘れてしまったらどうなるでしょう。つまり、以下のようなコードです。

double area = switch (shape) {
    case TRIANGLE -> width * height / 2.0;
//    case SQUARE -> width * height;
};

これをコンパイルすると、コンパイルエラーが発生します。

> javac Main.java
Main.java:8: エラー: switch式がすべての可能な入力値をカバーしていません
double area = switch (shape) {
                       ^
エラー1個

つまり、switch式のcaseでとりうる値や型をチェックし、不足していればコンパイルエラーが発生するようになったのです。

これが網羅性のチェックということです。

同じように、先ほどの体積を求めるサンプルでFigureインタフェースをシールドインタフェースに変更した場合はどうなるでしょう。

sealed interface Figure 
        permits Shpere, Cuboid, Cube {}

Figureインタフェースをシールドインタフェースに変更することで、実装クラスをSphere、Cuboid、Cubeだけに制限できます。このことにより、switch式のcaseラベルに指定する型は限定できるため、defaultが不必要になります。

ここでも、caseラベルにCubeクラスを指定するのを忘れた場合を考えてみましょう。

double calcVolume(Figure shape) {
    return switch (shape) {
        case Sphere(double r) -> Math.PI * r * r * r * 4.0 / 3.0;
        case Cuboid(double w, double h, double d) -> w * h * d;
//        case Cube(double e) -> e * e * e;
    };
}

これをコンパイルすると、同様にコンパイルエラーが発生します。

> javac Main.java
Main.java:11: エラー: switch式がすべての可能な入力値をカバーしていません
    return switch (shape) {
           ^
エラー1個

ここで示したように、網羅性のチェックが行われるのはenumとシールドクラス/インタフェースに限定されています。

予防的にdefaultを書いている開発者の方も多いと思いますが、網羅性がチェックできるのであればdefaultも不要です。

ただし、値を返さないswitch文では、網羅性チェックが行われないことに注意が必要です。値を返さないのであれば、網羅していなくても問題ないということなのだと思います。

switch文では予防的にdefaultを書くのを忘れないようにしましょう。

3. まとめ

Java 11などの古いJavaを使われている方からしたら、現在のswitchはまるで違うもののように見えるかもしれません。

新しいswitchを使うことでプログラムの品質を向上させることができますが、既存のコードを書き換えることはなかなか難しいですね。

ラムダ式と異なり、switchは古い記法もサポートしているため、一気に新しい形式に書き換えるのではなく、徐々にリファクタリングすることができるはずです。

そして、switchの言語仕様が変わったことにより、設計の手法も変わってきています。

たとえば、今まではデザインパターンのテンプレートパターンで記述していた処理も、switch式で記述可能です。テンプレートパターンだと処理が複数のクラスに分散してしまいますが、switch式であればswitch式内に閉じることができ、コードの見通しもよくなります。

本エントリーで示した通りswitchは大きく変わりましたが、まだその変化は終わりではありません。

現在提案されている変更点は次の2つです。

  • パターンマッチングにプリミティブ型を使用できるようにする
  • caseラベルに例外を記述できるようにする

後者の例外をswitchで扱う方はまだまだ先の話ですが、前者のプリミティブ型を使用できるようにする変更はJava 27でプレビューとして導入されています。

このまま進めば、次のLTSであるJava 29には導入されそうです。

このプリミティブ型を使用できるようにする変更については、また改めて紹介する予定です。

参考 Java 26におけるswitchに関連する言語仕様(JLS: Java Language Specification)


カサレアルでは、Javaを学ぶ方に向けて「Javaプログラミング入門」や「Javaプログラミング基礎」などのコースを開催しています。

Javaに関するコースの詳細や開催日程に関しては以下のリンクをご覧ください。

バックエンド開発を学ぶ研修一覧
https://www.casareal.co.jp/ls/service/openseminar/search/backend

--------------------------
開発支援・技術研修のご要望・ご相談はこちらから
--------------------------
【この技術ブログを読んだエンジニアの皆様へ】
カサレアルブログをお読みいただき、ありがとうございます!

私たちは、常に新しい技術に挑戦し、ユーザーのニーズに応えるサービスを提供しています。
もし、当社の技術への情熱や、会社・チーム・社員の雰囲気に共感いただけたなら、
ぜひ私たちと一緒に働きませんか?
現在、株式会社カサレアルでは事業拡大に伴い、新たな仲間となるエンジニアを積極的に募集しています。

少しでも興味をお持ちいただけましたら、まずは弊社のことを知っていただけると嬉しいです。
▼採用サイト
https://www.casareal.co.jp/recruit/career
▼社員インタビュー
https://hrmos.co/pages/casareal/jobs/0000016
▼エンジニアの仲間になる! エントリーはこちらから
https://hrmos.co/pages/casareal/jobs

皆様のエントリーを心よりお待ちしています!

Byte Buddyで遊んでみる

コメントを残す

メールアドレスが公開されることはありません。 ※ が付いている欄は必須項目です

コメント ※

名前 ※

メール ※

サイト