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 は本当にロックしたい階層にだけ使う。

javajava-21sealed-classespattern-matchingswitch