Sealed classes と Java 21 の switch パターンマッチング
1. 概要
あるデータ型が限定された種類のバリエーションしか持たない場合、例えばカード決済、銀行振込、電子ウォレットなどがあります。このような場合、従来は if (x instanceof ...) のチェーンで処理していました。このアプローチは冗長です。さらに悪いことに、処理漏れが発生しても気づきにくいです。
Java 21 では、この問題を 3 つの機能の組み合わせで解決します。sealed classes(Java 17 から正式化)、pattern matching for switch(JEP 441)、record patterns(JEP 440)です。このガイドを読めば、型の限定的な集合をモデル化し、コンパイラが全ケースをチェックする switch を書き、よくあるミスを避ける方法がわかります。
2. 従来のやり方の問題点
決済タイプごとに手数料を計算する必要があるとします。Java 21 より前は、コードは通常こんな感じでした:
public long fee(Object payment) {
if (payment instanceof CardPayment c) {
return c.amount() * 2 / 100;
} else if (payment instanceof BankTransfer b) {
return 5_500;
}
throw new IllegalArgumentException("対応していません: " + payment);
}
このコードには 3 つの問題があります:
EWalletの処理を忘れても、コードは普通にコンパイルされます。エラーが出るのは実行時だけです。- 最後の
throw分岐が必須ですが、ビジネスロジック的には実行されるべきではありません。 - 新しい決済タイプを追加するときは、このような
ifチェーンを手作業で探して修正する必要があります。
3. Sealed classes
3.1. Sealed interface の宣言
sealed class または sealed interface は、継承を許可する正確なクラスをリストアップします:
public sealed interface Payment permits CardPayment, BankTransfer, EWallet {
long amount();
}
public record CardPayment(String cardNumber, long amount) implements Payment {}
public record BankTransfer(String bankCode, long amount) implements Payment {}
public record EWallet(String provider, long amount) implements Payment {}
sealed キーワードと permits 句がコンパイラに伝えるのは、Payment は正確に 3 つの部分型を持つということです。別のクラス、例えば class Crypto implements Payment が継承しようとしたら、コンパイルエラーになります。
部分型が親クラスと同じファイルにある場合、permits を省略できます。コンパイラが自動的にリストを推測します。
3.2. 部分型に対するルール
sealed 型の直接の部分型は、次の 3 つの修飾子のいずれかを宣言する必要があります:
| 修飾子 | 意味 |
|---|---|
final |
これ以上継承されない |
sealed |
permits で制限を続ける |
non-sealed |
誰でも継承できる |
record はデフォルトで final なので、上の例では何も追加する必要がありません。通常のクラスの場合は明示する必要があります:
public sealed abstract class Notification permits EmailNotification, SmsNotification {}
public final class EmailNotification extends Notification {}
public non-sealed class SmsNotification extends Notification {}
// SmsNotification が non-sealed なので有効
public class ZaloNotification extends SmsNotification {}
ここで EmailNotification は完全にロックされています。反対に、SmsNotification は誰でも継承できます。結果として Notification に対して switch を行う場合、SmsNotification 分岐は ZaloNotification もカバーします。
3.3. パッケージとモジュールの制約
permits 内のクラスは、親クラスと同じモジュールに存在する必要があります。プロジェクトがモジュールを使わない場合(無名モジュール)、同じパッケージに存在する必要があります。これは意図的な設計です:親クラスの作者が階層全体をコントロールします。
4. Switch のパターンマッチング
4.1. Type pattern
Java 21 から、case ラベルは型とともに変数を持つパターンになります:
public class FeeCalculator {
public long fee(Payment payment) {
return switch (payment) {
case CardPayment c -> c.amount() * 2 / 100;
case BankTransfer b -> 5_500;
case EWallet w -> 0;
};
}
}
各 case は型をチェックすると同時に、既にキャストされた変数(c、b、w)をバインドします。変数はそのブロック内でのみスコープされます。コードに default や throw がありません。
4.2. 完全性チェック:コンパイラが全ケースを検査
Payment が sealed なので、コンパイラはちょうど 3 つの部分型を知っています。case EWallet w -> 0; を削除すると、コンパイルされません:
FeeCalculator.java:5: error: the switch expression does not cover all possible input values
return switch (payment) {
^
これが最大のメリットです。QrPayment を permits に追加すると、コードベース内のすべての Payment に対する switch がエラーになり、更新されるまで解決しません。コンパイラが ToDoリストになります。
注意: クラスが個別にコンパイルされて、実行時に
switchが知らない部分型が現れた場合、JVM はMatchExceptionをスローします。コンパイラがこの分岐を自動的に挿入します。
4.3. when による保護
when を使ってパターンに条件を追加できます:
public long fee(Payment payment) {
return switch (payment) {
case CardPayment c when c.amount() >= 10_000_000 -> c.amount() * 1 / 100;
case CardPayment c -> c.amount() * 2 / 100;
case BankTransfer b -> 5_500;
case EWallet w -> 0;
};
}
1000 万円以上のカード取引は 1% の手数料、それ以外は 2% の手数料です。保護された分岐は CardPayment 全体をカバーしたとは見なされません。したがって 2 番目の case CardPayment c は必須です。
サンプルトランザクションで試してみましょう:
public class Main {
public static void main(String[] args) {
var calculator = new FeeCalculator();
List<Payment> payments = List.of(
new CardPayment("4111-1111", 500_000),
new CardPayment("4111-2222", 20_000_000),
new BankTransfer("VCB", 1_000_000),
new EWallet("momo", 200_000));
payments.forEach(p -> System.out.println(p + " -> 手数料 " + calculator.fee(p)));
}
}
結果:
CardPayment[cardNumber=4111-1111, amount=500000] -> 手数料 10000
CardPayment[cardNumber=4111-2222, amount=20000000] -> 手数料 200000
BankTransfer[bankCode=VCB, amount=1000000] -> 手数料 5500
EWallet[provider=momo, amount=200000] -> 手数料 0
4.4. Case の順序と支配性
switch はパターンを上から順にマッチします。より一般的な分岐がより具体的な分岐の前にあると、後の分岐は到達しません。コンパイラはこのエラーを検出します:
return switch (payment) {
case CardPayment c -> c.amount() * 2 / 100;
case CardPayment c when c.amount() >= 10_000_000 -> c.amount() / 100; // エラー
case BankTransfer b -> 5_500;
case EWallet w -> 0;
};
error: このcaseラベルは前のcaseラベルに支配されています
覚えやすいルール:具体的な分岐(保護または部分型)を前に置き、広い分岐を後に置きます。
4.5. null の処理
デフォルトでは、switch が null に遭遇すると NullPointerException をスローします(古い動作と同じ)。null を明示的に処理したい場合は case null を追加します:
public String label(Payment payment) {
return switch (payment) {
case null -> "メソッドが選択されていません";
case CardPayment c -> "カード";
case BankTransfer b -> "振込";
case EWallet w -> "ウォレット " + w.provider();
};
}
switch に default がある場合は、case null, default -> にまとめることもできます。
5. Record patterns
Record patterns を使うと、case 内で record のコンポーネントを分解できます。アクセッサを呼ぶ必要がありません:
public String describe(Payment payment) {
return switch (payment) {
case CardPayment(String number, long amount) ->
"カード ****" + number.substring(number.length() - 4) + ": " + amount;
case BankTransfer(var bank, var amount) -> "振込 " + bank + ": " + amount;
case EWallet(var provider, var amount) -> "ウォレット " + provider + ": " + amount;
};
}
CardPayment(String number, long amount) は payment が CardPayment のときマッチし、同時に number と amount をバインドします。var を使うとコンパイラが型を推測します。new CardPayment("4111-1111", 500_000) の場合、結果は カード ****1111: 500000 です。
Record patterns の真の強さは、ネストできることにあります:
public sealed interface Transaction permits Charge, Refund {}
public record Charge(Payment payment) implements Transaction {}
public record Refund(Payment original, String reason) implements Transaction {}
public String audit(Transaction tx) {
return switch (tx) {
case Charge(CardPayment(var number, var amount)) when amount >= 10_000_000 ->
"要審査: カード " + number;
case Charge(Payment p) -> "回収 " + p.amount();
case Refund(var original, var reason) -> "返金 " + original.amount() + " (" + reason + ")";
};
}
最初の分岐は、大きな金額のカード決済を含む Charge にマッチします(1 行で)。コンパイラはネストされたパターンの完全性をチェック:Charge(Payment p) はすべての Charge をカバーし、Refund(...) はすべての Refund をカバーします。
注意: 無名パターン
_(例:case Refund(_, var reason))は Java 21 ではプレビュー機能であり、--enable-previewを有効にする必要があります。この機能は Java 22 から正式になります。Java 21 で留まっているプロジェクトは、varと通常の変数名を使ってください。
6. テスト
Java 21 と JUnit 5、AssertJ を構成する必要があります。Spring Boot 3.x を使っている場合、spring-boot-starter-test に両方が含まれています。pure Maven プロジェクトの場合:
<properties>
<maven.compiler.release>21</maven.compiler.release>
</properties>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.25.3</version>
<scope>test</scope>
</dependency>
</dependencies>
セクション 4.3 の FeeCalculator とセクション 5 の describe のテスト:
class FeeCalculatorTest {
private final FeeCalculator calculator = new FeeCalculator();
@Test
void cardPaymentBelowThreshold_charges2Percent() {
assertThat(calculator.fee(new CardPayment("4111-1111", 500_000))).isEqualTo(10_000);
}
@Test
void cardPaymentAboveThreshold_charges1Percent() {
assertThat(calculator.fee(new CardPayment("4111-2222", 20_000_000))).isEqualTo(200_000);
}
@Test
void bankTransferAndWallet_haveFixedFee() {
assertThat(calculator.fee(new BankTransfer("VCB", 1_000_000))).isEqualTo(5_500);
assertThat(calculator.fee(new EWallet("momo", 200_000))).isZero();
}
@Test
void describe_masksCardNumber() {
assertThat(calculator.describe(new CardPayment("4111-1111", 500_000)))
.isEqualTo("カード ****1111: 500000")
.doesNotContain("4111-");
}
}
テストは保護の境界(ちょうど 1000 万円以下と以上)に焦点を当てるべきです。そこが case の順序で最も間違いやすい場所だからです。完全性チェックはテストする必要はありません。コンパイラが面倒を見てくれます。
7. よくあるミスと非推奨な使い方
sealed 型の switch に default を追加する。 コードは相変わらずコンパイルしますが、主なメリットを失います。新しい部分型を追加すると、コンパイルエラーになる代わりに静かに default に落ちます。sealed 型では、すべての分岐をリストアップして default を省略してください。
when がある分岐が型全体をカバーしないことを忘れる。 すべての case CardPayment に保護がある場合、コンパイラは不足を報告します。保護なしの分岐、または より一般的な分岐が必ず必要です。
あらゆる場所で switch をポリモーフィズムの代わりに使う。 動作が本当にオブジェクト自身に属する場合、親クラスの abstract メソッドの方が依然としてより良い選択です。パターンマッチングは、ロジックが階層外部にある場合に適しています。例えば手数料計算、レンダリング、DTO へのマッピング。record に全部詰め込みたくない場合です。
注意: Sealed classes を使わない方がいい場合:
- ライブラリやプラグイン拡張点を書いていて、ユーザーが独自に部分型を追加する必要がある場合。
- クラスが遅延読み込みが必要な JPA エンティティの場合。Hibernate はエンティティを継承してプロキシを作るため、sealed または final がこの仕組みを妨害します。
- 部分型が複数のモジュールに散在している場合。Sealed は同じモジュール、またはモジュールを使わない場合は同じパッケージを要求します。
JSON のシリアライゼーション。 Jackson は、インターフェースが sealed だからという理由だけで、Payment を正しい部分型に deserialize する方法を自動的に知りません。型情報を設定する必要があります。例えば @JsonTypeInfo と @JsonSubTypes の組み合わせなど。
8. まとめ
Sealed classes を使うと、型が固定されたバリエーションの集合しか持たないことを明確に宣言できます。Switch のパターンマッチングと record patterns がそれを簡潔なコードに変え、コンパイラの完全性チェックも受けます。when ガードで条件を追加したり、case null で null を明示的に処理したり、ネストされたパターンでデータを掘り下げたりできます。
覚えておくべき 3 つの原則:sealed 型に対する switch では default を省略する、具体的な分岐を広い分岐の前に置く、sealed は本当にロックしたい階層にだけ使う。