
2026/10/18 23:47
Java シールドクラスと網羅的パターンマッチング
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
Java 17 の密封クラスは、開発者がインターフェースやクラスのサブタイプを明示的に制限することを可能にし、switch 式でのコンパイル時完全性チェックを有効にし、すべてのケースがカバーされている場合に default 節の必要性を排除します。しかし、この機能は一貫したビルドを前提としています:エクスポートされた密封インターフェースに新しいサブタイプを追加し、依存する消費者コードを再コンパイルせずにいる場合、コンパイラは欠落したケースを検知できず、代わりにランタイムで MatchException をスローします。内側のレコードは密封制約を暗黙的に満たすことができますが、この動作と分離コンパイルのリスクについては、公式 Javadoc で十分に文書化されています。
この制限は Semantic Versioning と整合しています:エクスポートされた密封インターフェースに新しいサブタイプを追加することは、メジャーバージャーンプッシュを必要とする不兼容変更(breaking change)です。したがって、組織は予測不能なランタイム失敗が発生する可能性がある(例:暗号化オブジェクトのハンドリングなど)パブリック API に密封インターフェースを組み込むことは避けるべきです。むしろ、密封型は内部コンポーネント内で使用するのが最適であり、コードベース全体を一緒に再ビルドすることで一貫性を保ち、高価なランタイムクラッシュを防ぐことができます。
本文
Java 17 の sealed
クラス:強固な完全性チェックと破壊的変更のリスク
sealedJava 17 で導入された シーラードクラス(Sealed Classes/Interfaces) は、インターフェースや基底クラスが許容するサブタイプを明示的に宣言できる機能です。これにより、代数的データ型(ADT) を Java で表現することが可能になります。
セラードの基本構造と実装例
シーラードインターフェースを使用する簡易な例を示します。
record などの内包されたクラスは、明示的なリストに記述されていない場合、暗黙的に許容されるサブタイプとして扱われます。
public sealed interface SealedType { record TypeA() implements SealedType {} record TypeB() implements SealedType {} // 生成メソッド(Pattern Matching for Switch を利用) static SealedType of(String type) { return switch (type) { case "A" -> new TypeA(); case "B" -> new TypeB(); default -> throw new IllegalArgumentException(); }; } }
この構造は、Haskell や ML のデータ型宣言と類似しています。
data SealedType = TypeA | TypeB
完全性チェックによる安全性保証
シーラード型を用いたコードでは、パターンマッチングの 完全性をコンパイラーが自動チェック します。
利点:デフォルトケース不要
switch 式や case 文で全てのサブタイプを網羅している場合、default 句を追加する必要がありません。
void main(String[] args) { var val = SealedType.of(args[0]); System.out.println(switch (val) { case SealedType.TypeA() -> "A"; case SealedType.TypeB() -> "B"; // default 句不要:網羅されているため }); }
欠点:破壊的変更のリスク
シーラードクラスの仕様変更は、利用側への破壊的変更となります。新しいサブタイプを追加すると、既存の利用コードで以下の問題が発生します。
- ケース欠落によるエラー: 新しい型(例:
)が追加されても、呼び出し元でケース処理を更新していない場合、コンパイルエラーや実行時例外が生じます。TypeC - メジャーバージョンアップ必須: API 公開の観点からは、新しいサブタイプを追加するのはSemVer に従う限りメジャーバージョナップであり許されない変更です。
コンパイル時間エラーか実行時エラーか?
Java の型システムは「利用側」を再コンパイルしていない場合、シーラード型の仕様変更を検出できません。
シナリオ:利用側の再コンパイル漏れ
シーラード側(
SealedType.java)のみを更新し、利用側(Main.java)の再コンパイルを行わない場合、以下のようになります。
// SealedType.java の更新後(TypeC 追加済み) public sealed interface SealedType { record TypeA() implements SealedType {} record TypeB() implements SealedType {} record TypeC() implements SealedType {} static SealedType of(String type) { return switch (type) { case "A" -> new TypeA(); case "B" -> new TypeB(); case "C" -> new TypeC(); // 新規追加 default -> throw new IllegalArgumentException(); }; } }
この状態で、更新前のコードを持つ
Main クラスを実行すると:
- 結果: 特定のケースがトリガーされた際、実行時例外(MatchException など) がスローされます。
- 原因: コンパイラが利用側のソースを確認していないため、完全性チェックが行われません。
この独立したコンパイルによる問題点は Javadoc でも注意喚起されており、特にパターンマッチングにおける
null の扱いのような特殊ケースにも留意が必要です。静的更新を期待しても、全コードの再コンパイルが前提条件となります。
結論:内部実装と公開 API を区別すべき
著者の見解では、シーラード型の用途は以下のようになります。
✅ 推奨:コンポーネント内部での利用
- メリット: モジュール境界内で全てのコードが一緒に再コンパイルされるため、完全性チェックによる高水準なコンパイル時間安全性が得られる。
- 適切さ: 実装詳細や内部ロジックとして使用する場合に最も有益。
❌ 非推奨:公開 API(ライブラリ)としての曝露
- デメリット: モジュール境界をまたぐ場合、新しいサブタイプを追加するだけで既存クライアントが壊れうる。多くのケースで実行時に初めて問題が発覚する。
- 教訓: PEM エンコードされた暗号対象のプレビューサポートレビュー時、公開 API にシーラードインターフェースを埋め込み、クライアントにパターンマッチ推奨した事例があり、将来的なケース追加時に重大な不具合が発生する可能性が高かった。
まとめ: シーラード型は「信頼できる内部実装」には強力ですが、「互換性を重視する公開 API」には適していません。