状態によって変わる振る舞いを、状態ごとのオブジェクトへ分ける設計がStateです。一方、状態ごとの処理を1か所の switch に集められるなら、状態を sealed interface で閉じることで、対応し忘れた分岐をコンパイルエラーにできます。
題材は、注文状態に応じた表示色の割り当てです。Cancelled を追加したとき、開いた状態型への分岐は誤った色を返す一方、sealed と switch の組み合わせはコンパイルで止まります。
1. Stateは状態ごとの振る舞いをオブジェクトへ分けるパターン
Stateは、オブジェクトの状態によって変わる処理を、状態ごとのオブジェクトへ分けるデザインパターンです。注文を例にすると、「キャンセルできるか」「次にどの状態へ進めるか」といった振る舞いを、支払い待ち・支払い済み・発送済みの各状態へ持たせる設計です。
古典的なStateは次の3つの役割で構成します。
- Context: 現在のStateを保持し、処理を委ねる。注文全体がこれに当たる
- State: 状態ごとに変わる操作を共通の型として定義する
- ConcreteState: 支払い済みや発送済みなど、各状態の処理と遷移を実装する
この記事の掲載コードは、古典StateのContextに当たる注文オブジェクトまでは実装しません。古典形にするなら、OrderState を保持する Order がContext、Paid などがConcreteStateです。以降では、状態型の開き方と、表示色のような横断処理の置き場所を比べます。
目的は、Context に大きな if や switch を置かず、状態固有の処理を各Stateへ閉じ込めることです。したがって、実装できる型を制限する sealed interface と switch は、Stateを新しい構文で書いただけのものではありません。状態オブジェクトに置いていた処理を、全状態を見渡せる1か所へ集め直す別の設計です。
この記事では、表示色のような状態をまたぐ小さな処理なら中央へ集められるかを検討します。状態が遷移規則や複数の振る舞いを持つ場合は、Stateの実装クラスを残します。
2. sealedとswitchが向く条件
State の書き方を決めるのは、状態の一覧と処理の置き場所です。
| 条件 | 向いている書き方 |
|---|---|
| 状態の種類を同じモジュールで管理し、処理ごとに全状態を見渡したい | sealed interface と switch |
| 状態ごとに複数の振る舞いや遷移規則がある | 状態ごとの実装クラス |
| 別モジュールが状態を追加する | 開いたインタフェースと実装クラス |
sealed は実装できる型を制限する機能です。その閉じた一覧を switch が使い、全状態を処理したかをコンパイラが判断します。
3. Stateを使わない分岐は新しい状態を見落とす
Windows のターミナルから WSL2 に入り、作業ディレクトリを作成してください。
wsl
mkdir -p ~/projects/java-state-pattern-demo
cd ~/projects/java-state-pattern-demo
code .
StateBranch.java を作成してください。Cancelled は型として存在するものの、print の分岐では未対応です。
public class StateBranch {
public static void main(String[] args) {
print(new Paid());
print(new Cancelled());
}
static void print(OrderState state) {
String color;
if (state instanceof Paid) {
color = "green";
} else if (state instanceof Shipped) {
color = "blue";
} else {
color = "gray";
}
System.out.println(state.label() + ": " + color);
}
}
interface OrderState {
String label();
}
final class Paid implements OrderState {
public String label() { return "paid"; }
}
final class Shipped implements OrderState {
public String label() { return "shipped"; }
}
final class Cancelled implements OrderState {
public String label() { return "cancelled"; }
}
docker run --rm -v "$(pwd):/app" -w /app eclipse-temurin:25-jdk java StateBranch.java
paid: green
cancelled: gray
コンパイルも実行も成功です。しかし Cancelled は最後の else に入り、意図していない gray になりました。例外を投げる else に変えても、漏れに気づけるのは該当経路を実行したときだけです。
4. sealedで状態を閉じ、switchに数えさせる
次は StateSealed.java を作成します。
public class StateSealed {
public static void main(String[] args) {
print(new Paid());
print(new Shipped());
}
static void print(OrderState state) {
String color = switch (state) {
case Paid paid -> "green";
case Shipped shipped -> "blue";
};
System.out.println(state.label() + ": " + color);
}
}
sealed interface OrderState permits Paid, Shipped {
String label();
}
record Paid() implements OrderState {
public String label() { return "paid"; }
}
record Shipped() implements OrderState {
public String label() { return "shipped"; }
}
docker run --rm -v "$(pwd):/app" -w /app eclipse-temurin:25-jdk java StateSealed.java
paid: green
shipped: blue
permits Paid, Shipped が状態の一覧に当たります。switch に default がなくてもコンパイルできるのは、2種類をすべて処理しているためです。
sealed は JDK 17、型パターンを使う switch は JDK 21 で正式導入されました。したがって、この記事のコードは JDK 21 以降が対象です。仕様の背景は JEP 409: Sealed Classes と JEP 441: Pattern Matching for switch を参照してください。
5. 状態を足すと未対応のswitchでコンパイルが止まる
Cancelled を一覧へ追加し、switch は直さない StateAdded.java を作成してください。
public class StateAdded {
public static void main(String[] args) {
print(new Cancelled());
}
static void print(OrderState state) {
String color = switch (state) {
case Paid paid -> "green";
case Shipped shipped -> "blue";
};
System.out.println(state.label() + ": " + color);
}
}
sealed interface OrderState permits Paid, Shipped, Cancelled {
String label();
}
record Paid() implements OrderState {
public String label() { return "paid"; }
}
record Shipped() implements OrderState {
public String label() { return "shipped"; }
}
record Cancelled() implements OrderState {
public String label() { return "cancelled"; }
}
docker run --rm -v "$(pwd):/app" -w /app eclipse-temurin:25-jdk java StateAdded.java
StateAdded.java:7: error: the switch expression does not cover all possible input values
String color = switch (state) {
^
1 error
error: compilation failed
Cancelled を permits に追加した時点で、未対応の switch はコンパイルエラーになりました。case Cancelled cancelled -> "red"; を足すまで実行ファイルは作られない仕組みです。
この検査を使うなら、残りをまとめる default や case OrderState other は置かないこと。どちらも新しい状態を受け止めてしまうためです。また、null は permits に並ぶ状態ではなく、網羅性検査の対象外です。入力され得るなら、状態を生成する境界で拒否するか、case null として明示してください。
6. 状態の実装クラスを残す条件
sealed と switch が強いのは、表示色、表示文言、権限判定のように「1つの処理から全状態を見たい」場合です。状態を追加すると、関連するすべての switch が更新箇所として現れる構造です。
次の要件には、状態オブジェクトへ振る舞いを置く設計が適します。
- 状態ごとに
pay()、ship()、cancel()など複数の操作がある - 許可する遷移を各状態自身が管理する
- 状態がサービスや設定値を保持する
- 状態を別モジュールやプラグインから増やす
閉じた状態一覧に対する横断的な処理なら sealed と switch、状態ごとの豊かな振る舞いなら実装クラスを選びます。選択基準は、変更時に直す場所とコードの構造が合っているかどうかです。
前の記事は、実装クラスを減らせる境界を扱った Javaのデザインパターン「Strategy」はラムダでどこまで置き換えられるか です。次の記事では、同じ網羅性を Visitor と比較します: Javaのデザインパターン「Visitor」の二重ディスパッチはいつ外せるか。
7. 片付けと、対処
残るものは、作業ディレクトリと Docker イメージです。コンテナには --rm を付けているため、停止後のコンテナは残りません。
削除する前に対象を確認します。
ls -d ~/projects/java-state-pattern-demo
docker image ls eclipse-temurin
表示されたディレクトリがこの記事の作業用であり、JDK 25 のイメージをほかの作業で使わない場合だけ削除してください。続けて同シリーズの記事を試す場合、イメージの削除は不要です。
cd ~
rm -rf ~/projects/java-state-pattern-demo
docker image rm eclipse-temurin:25-jdk
the switch expression does not cover all possible input values が出たら、permits に追加した型に対応する case を足します。default で隠すと、次の状態追加でも同じ検査が働かなくなります。