Java の sealed クラスと網羅的なパターンマッチング
Java sealed classes and exhaustive pattern matching (neilmadden.blog)
要約
Java 17 で導入された sealed クラスは、インターフェースや基底クラスのサブタイプを明示的にリストアップできる機能です。これにより、switch 式などで網羅性チェックが可能になり、コンパイル時にすべてのケースが処理されているかを確認できます。しかし、sealed 型が公開 API で使用される場合、新しいサブタイプの追加が後方互換性のない変更となり、コンパイル時または実行時エラーを引き起こす可能性があるため、注意が必要です。
全文翻訳
Javaのsealedクラスと網羅的なパターンマッチング
Java 17はsealedクラスを導入しました。これにより、インターフェースまたは基底クラスの許可されたサブタイプを明示的にリスト化できます。例えば、sealedインターフェースとレコードを使用した簡単な例を以下に示します(明示的なリストが与えられない場合、内部クラスは許可されるサブタイプに暗黙的に追加されます)。
public sealed interface SealedType {
record TypeA() implements SealedType {}
record TypeB() implements SealedType {}
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
これを単純なMainクラスで使用できます。
void main(String[] args) {
var val = SealedType.of(args[0]);
System.out.println(switch (val) {
case SealedType.TypeA() -> "A";
case SealedType.TypeB() -> "B";
});
}
さて、これはそれほどエキサイティングではありません。しかし、ここで注目すべき点は、メインメソッドのswitch式にdefault句を追加する必要がなかったことです。これは、sealedクラス(およびenum)が網羅性チェックを可能にするためです。コンパイラは可能なケースを正確に把握しているため、すべてを網羅しているかどうかを確認できます。すべてを網羅していれば、default句は不要です。1つを忘れて(かつdefault句がない場合)、コンパイル時エラーが発生します。
これは、ある型のすべての使用がすべてのケースを網羅していることを保証したい場合に非常に便利ですが、新しい種類の破壊的変更をもたらします。例えば、sealedクラス/インターフェースに新しいサブタイプを追加すると、そのコードのコンシューマーが壊れる可能性があります。例として、例に新しいTypeCケースを追加すると、欠落しているケースのためにメインメソッドのコンパイルが失敗します。したがって、APIでsealed型を公開する場合、新しいサブタイプの追加は、メジャーバージョンの更新を必要とする破壊的変更となります(SemVerに従っている場合)。
コンパイル時エラーか、ランタイムエラーか?
Javaはコンシューマー(この例ではmain)をコンパイルする際に、非網羅的なswitchに対してコンパイル時エラーを生成しますが、コンシューマーがsealed型が変更されたときに再コンパイルされない場合は、そうすることはできません。例えば、SealedTypeを別のケースで拡張したとしましょう。
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();
};
}
}
SealedType.javaのみを再コンパイルし、Mainを再コンパイルしない場合、新しいケースをトリガーするとランタイム例外が発生します。
ここで、新しいMatchExceptionがスローされています。Javadocは、個別のコンパイルにおけるこの潜在的な問題、およびパターンにおけるnullのいくつかのコーナーケースにも言及しています。したがって、sealedクラスを使用することで、新しいケースが追加されたときにすべてのコンシューマーを更新することを静的に保証できると期待していたとしても、すべてを再コンパイルしない限り、それは真実ではありません。
結論
私にとっての結論は、sealed型はコンポーネントの実装内で最も役立ち、他のコンポーネント(例: ライブラリ)にコンポーネントが提供するパブリックAPIで公開される場合にはあまり役立たないということです。内部使用では、通常すべてをまとめて再コンパイルするため、網羅性チェックの優れたプロパティと、より高いコンパイル時安全性の保証が得られます。しかし、モジュールの境界を越えて使用する場合、しばしばランタイムでのみ検出可能な、コードを壊す新しい方法を導入するだけかもしれません。
(私はこれらの微妙な点を、PEMエンコードされた暗号オブジェクトのプレビューサポートをレビューしているときに発見しました。これは、sealedインターフェースをパブリックAPIに組み込み、クライアントにその型に対するパターンマッチングを推奨するというまさにこの間違いを犯しています。もし彼らが新しいケースを追加しようとすれば、破損の非常に高い確率を予測します)。