キーポイント
- The release of Java SE 15 in Sept 2020 will introduce "sealed classes" (JEP 360) as a preview feature.
- A sealed class is a class or interface which restricts which other classes or interfaces may extend it.
- Sealed classes, like enums, capture alternatives in domain models, allowing programmers and compilers to reason about exhaustiveness.
- Sealed classes are also useful for creating secure hierarchies by decoupling accessibility from extensibility, allowing library developers to expose interfaces while still controlling all the implementations.
- Sealed classes work together with records and pattern matching to support a more data-centric form of programming.
Java SE 15(2020年9月)では Sealed クラスをプレビュー機能として導入します。シールにより、クラスやインタフェースは、許可されたサブタイプに対してより多くの制御が可能になります。これは、一般的なドメインモデリングや、より安全なプラットフォームライブラリの構築に役立ちます。
クラスまたはインタフェースはsealed
と宣言されている場合があります。これは特定のクラスやインタフェースの集合だけがそれを直接拡張できることを意味します。
sealed interface Shape
permits Circle, Rectangle { ... }
これは Shape
と呼ばれる Sealed インタフェースを宣言します。permits
リストは、Circle
と Rectangle
だけが Shape
を実装できることを意味しています。(場合によっては、コンパイラが permits 句を推論できるかもしれません)。 Shape
を拡張しようとする他のクラスやインタフェースは、コンパイルエラーを受けます(または、Shape
をスーパータイプとして宣言しているラベル外のクラスファイルを生成しようとした場合は、実行時エラーとなります)。
私たちは、final
クラスを介して拡張を制限するという概念をすでに知っています。シールは final 化の一般化と考えることができます。許可されるサブタイプのセットを制限すると、2つの利点があります。スーパータイプの作者はすべての実装を制御できるので、可能な実装についてより良い推論ができ、コンパイラは網羅性についてより良い推論ができます。(スイッチ文やキャスト変換など)。 Sealed クラスは Records との相性も抜群です。
直和型と直積型
上記のインタフェース宣言では、Shape
は Circle
でも Rectangle
でもよいという宣言をしていますが、それ以外は何もありません。別の言い方をすれば、すべての Shape
の集合は、すべての Circle
の集合にすべての Rectangles
の集合を加えたものになります。このため、Sealed クラスは、直和型と呼ばれることが多いです。なぜなら、それらの値の集合は他の型の一定のリストの値の集合の和であるためです。直和型と Sealed クラスは新しいことではありません。例えば、ScalaはSealed クラスがあり、Haskell や ML には直和型を定義するためのプリミティブがあります(たまにタグ付き共用型や判別共用体と呼ばれることもあります)。
直和型は、直積型と並んでよく見られます。最近Javaに導入された Records は積型の一形態です。そのように呼ばれる理由は、その状態空間がコンポーネントのデカルト積(のサブセット)であるからです。(複雑に聞こえるかもしれませんが、直積型はタプル、Records は名目上のタプルと考えてください)。 それでは、Records を使って Shape
のサブタイプの宣言を終わらせてみましょう。
sealed interface Shape
permits Circle, Rectangle {
record Circle(Point center, int radius) implements Shape { }
record Rectangle(Point lowerLeft, Point upperRight) implements Shape { }
}
ここでは、直和型と直積型がどのように結びついているかを見てみましょう。「円は中心と半径で定義される」、「長方形は2点で定義される」、そして最後に「形は円か長方形のどちらかです」と言えるようになりました。なぜなら、このようにベース型とその実装を共同で宣言することが一般的になると予想されるからです。すべてのサブタイプが同じコンパイル単位で宣言されている場合は、permits
節を省略して、そのコンパイル単位で宣言されたサブタイプの集合であると推論します。
sealed interface Shape {
record Circle(Point center, int radius) implements Shape { }
record Rectangle(Point lowerLeft, Point upperRight) implements Shape { }
}
待って、これってカプセル化に違反してませんか?
歴史的に、オブジェクト指向のモデリングは、抽象型の実装の集合を隠すことを奨励してきました。私たちは、「Shape
のサブタイプは何が考えられるか」という質問をしないようにしてきました。同様に、特定の実装クラスへのダウンキャストは「コードの臭い」だと言われてきました。では、なぜ突然、これらの長年の原則に反するように見える言語機能を追加するのでしょうか?(Records についても同じ質問をできます。クラス表現とそのAPIの間に特定の関係を強制することはカプセル化に違反しているのではないでしょうか?)
答えは、もちろん"場合による"です。 抽象的なサービスをモデル化する場合、クライアントが抽象型を介してサービスとのみ対話することは、プラスのメリットとなります。これは、結合度を減らし、システムを進化させるための柔軟性を最大化するためです。しかし、そのドメインの特性がすでによく知られている特定のドメインをモデル化する場合、カプセル化は我々に提供するものが少ないかもしれません。Records で見たように、XY 座標や RGB カラーのような平凡なものをモデル化する場合、データをモデル化するためにオブジェクトの完全な汎用性を使用すると、価値の低い作業が多くなり、さらに悪いことになります。それはしばしば実際に起こっていることを難読化できます。このような場合、カプセル化には、その利点が正当化されないコストがあります。データとしてモデル化するデータはシンプルで直接的です。
Sealed クラスにも同じ議論が適用されます。よく理解された安定したドメインをモデル化する場合、"どんな形があるかは言わない"カプセル化は、必ずしも我々が不透明な抽象化から得たいと思っているような利点を与えるとは限りません。そして、実際には単純なドメインであるものとクライアントが動作するのが難しくなるかもしれません。
これは、カプセル化が間違いだということを意味するのではありません。それは単に、コストと利益のバランスが崩れている場合があるということを意味しているだけです。それが役に立つときと邪魔になるときを判断するために使うことができるということです。実装を公開するか非公開にするかを選択する際には、カプセル化のメリットとコストを明確にしなければなりません。それは実装を進化させるための柔軟性を買っているのか、それとも既に他に明白な何かの邪魔をする情報破壊的な障壁に過ぎないかもしれません。多くの場合、カプセル化の利点は実質的なものです。しかし、よく理解されているドメインをモデル化した単純な階層の場合には、そのようなことはありません。失敗の心配の無い抽象表現を宣言するオーバーヘッドが利益を上回ることがあります。
Shape
のような型が、そのインタフェースだけでなく、それを実装するクラスに委ねる場合、私たちは、"あなたは Circle ですか "と尋ねることやCircle
にキャストすることについてより良い気分になれます。Shape
は、その既知のサブタイプの一つとして Circle
を挙げています。Records がより透明性の高い種類のクラスであるように、直和はより透明性の高い種類のポリモーフィズムです。これが、直和と直積が頻繁に一緒に見られる理由です。どちらも透明性と抽象性の間で似たようなトレードオフを表現しています。一方が意味を持つならば、もう一方も同様に意味を持つ可能性があります。(直積の和は、代数的データ型のように呼ばれることが多いです。)
Exhaustiveness
Shape
のような Sealed クラスは、可能なサブタイプの網羅的なリストに委ねています。この情報がなければできなかった方法で、プログラマやコンパイラが形状を推論するのに役立ちます。(他のツールでもこの情報を利用できます。Javadoc ツールは、Sealed クラスのために生成されたドキュメントのページで許可されたサブタイプをリストアップします)。
Java SE 14 では、パターンマッチングの限定的な形式が導入されていますが、これは将来的に拡張される予定です。最初のバージョンでは、instanceof
で型パターンを使用できるようになりました。
if (shape instanceof Circle c) {
// compiler has already cast shape to Circle for us, and bound it to c
System.out.printf("Circle of radius %d%n", c.radius());
}
それはswitch
で型パターンを使うまでが小さな飛躍です。(これはJava SE 15 ではサポートされていませんが、近日中に予定されています)。 ここまで来たら、次のように、case
ラベルが型パターンであるスイッチ式を使って、図形の面積を計算します。1:
float area = switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> Math.abs((r.upperRight().y() - r.lowerLeft().y())
* (r.upperRight().x() - r.lowerLeft().x()));
// no default needed!
}
ここでのシールの貢献は default
句を必要としなかったということです。なぜならば、コンパイラは Shape
の宣言から Circle
と Rectangle
がすべての Shape をカバーしていることを知っていたからです。そのため、上記の switch
では default
句は到達しないということを知っていました。(コンパイラはまだスイッチ式の中にデフォルト節を静かに挿入しています。念のため、Shape
の許可されたサブタイプはコンパイル時と実行時の間に変更されます。しかし、プログラマがこのデフォルト句を「念のため」に書けと主張する必要はありません)。 これは、別の exhaustiveness のソースをどう扱うかに似ています。既知の定数をすべてカバーする列挙型
のスイッチ式もデフォルト節を必要としません。 (そして、この場合は省略するのが一般的です。これは、case を忘れたことを警告してくれる可能性が高いからです)。
Shape
のような階層構造は、クライアントに選択の余地を与えます。それは、抽象的なインタフェースを介してShapeを完全に扱えるということです。しかし、彼らはまた、抽象化を「展開」することができ、それが意味のあるときには、より鋭い型を介して相互作用できます。パターンマッチングのような言語機能は、この種の展開をより快適に読み書きできます。
代数的データ型の例
「直積の和」パターンは強力なものになる可能性があります。それが適切であるためには、サブタイプのリストが変更される可能性が極めて低くなければなりません。そして、クライアントがサブタイプを直接識別することがより簡単に、より有用になることを期待しています。
一定のサブタイプに委ねることは密結合の一つです。さらにそれはクライアントがそれらのサブタイプを直接使用することを奨励することでもあります。すべてのことが等しくなるように、私たちは将来的に物事を変更するための柔軟性を最大限に高めるために、設計に緩い結合度を使用することを推奨しています。 しかし、このような緩い結合度にもコストがかかります。「不透明」と「透明」の両方の抽象化を言語化することで、状況に応じて適切なツールを選択できます。
直積の和(当時これがオプションだった場合)を使用する可能性がある場所の一つは、java.util.concurrent.Future
のAPIにあります。Future
は、イニシエータと同時に実行されるかもしれない計算を表します。Future
で表される計算は、まだ開始されていない可能性があり、始まっているが、まだ完成していないかもしれない、既に終わっていて正常に完了しているか例外が発生しているかもしれません、タイムアウトしているかもしれない、または中断によってキャンセルされているかもしれません。Future
の get()
メソッドは、これらの可能性をすべて反映しています。
interface Future<V> {
...
V get(long timeout, TimeUnit unit)
throws InterruptedException, ExecutionException, TimeoutException;
}
まだ計算が終了していない場合、get()
は、完了モードのいずれかが発生するまでブロックします。そして、成功した場合は計算結果を返します。例外をスローして計算が終了した場合,この例外は ExecutionException
でラップされます。計算がタイムアウトしたり中断されたりした場合は、別の種類の例外がスローされます。この API はかなり精度が高いです。しかし、使うのがめんどくさいです。なぜなら、通常のパス ( get()
が値を返す)と多数の失敗パスといった複数の制御パスがあるからです。それぞれを catch
ブロックで処理しなければなりません。
try {
V v = future.get();
// handle normal completion
}
catch (TimeoutException e) {
// handle timeout
}
catch (InterruptedException e) {
// handle cancelation
}
catch (ExecutionException e) {
Throwable cause = e.getCause();
// handle task failure
}
Java 5 で Future
が導入されたときに、Sealed クラス、Records、パターンマッチングがあれば、以下のように戻り値の型を定義していた可能性があります。
sealed interface AsyncReturn<V> {
record Success<V>(V result) implements AsyncReturn<V> { }
record Failure<V>(Throwable cause) implements AsyncReturn<V> { }
record Timeout<V>() implements AsyncReturn<V> { }
record Interrupted<V>() implements AsyncReturn<V> { }
}
...
interface Future<V> {
AsyncReturn<V> get();
}
ここでは、非同期の結果は成功(戻り値を含む)、失敗(例外を含む)、タイムアウト、キャンセルのいずれかであると言っています。これは、可能性のある結果の一部を戻り値で記述し、他の一部を例外で記述するのではなく、より画一的に記述したものです。クライアントはすべてのケースに対応しなければなりません。それはタスクが落ちても実際は仕方がないです。しかし、私たちは一様に(よりコンパクトに)処理できます。1:
AsyncResult<V> r = future.get();
switch (r) {
case Success(var result): ...
case Failure(Throwable cause): ...
case Timeout(), Interrupted(): ...
}
直積の和は一般化された列挙型
直積の和を考えるのに良い方法は、列挙型の一般化であるということです。enum 宣言は、定数インスタンスの網羅的なセットを持つ型を宣言します。
enum Planet { MERCURY, VENUS, EARTH, ... }
惑星の質量や半径など、各定数にデータを関連付けることが可能です。
enum Planet {
MERCURY (3.303e+23, 2.4397e6),
VENUS (4.869e+24, 6.0518e6),
EARTH (5.976e+24, 6.37814e6),
...
}
少し一般化すると、Sealed クラスは Sealed クラスのインスタンスの一定のリストではなく、インスタンスの種類の一定のJava では常にリストを列挙します。例えば、この Sealed インタフェースは、様々な種類の天体と、それぞれの種類に関連するデータを一覧します。
sealed interface Celestial {
record Planet(String name, double mass, double radius)
implements Celestial {}
record Star(String name, double mass, double temperature)
implements Celestial {}
record Comet(String name, double period, LocalDateTime lastSeen)
implements Celestial {}
}
enum 定数を網羅的に切り替えることができるように、様々な種類の天体を網羅的に切り替えることができるようになります。1:
switch (celestial) {
case Planet(String name, double mass, double radius): ...
case Star(String name, double mass, double temp): ...
case Comet(String name, double period, LocalDateTime lastSeen): ...
}
このパターンの例はどこにでもあります。UI システムのイベント、サービス指向システムのリターンコード、プロトコルのメッセージなどです。
より安全な階層
ここまでは、Sealed クラスがドメインモデルに代替案を組み込むのに便利な場合の話をしてきました。Sealed クラスには、別の全く異なるアプリケーションがあります。それは安全な階層です。
通常、Java ではクラスを final
にマークすることで「このクラスは拡張できません」と言えます。言語における final
の存在は、クラスについての基本的な事実を認めています。クラスは拡張できるように設計されていることもあれば、そうでないこともあります。そして、私たちは両方のモードに対応していきたいと考えています。確かに 効果的な Java は、私たちが「拡張のための設計と文書化し、そうでなければそれを禁止すること」を推奨しています。これは素晴らしいアドバイスです。そして、言語の力を借りれば、もっと頻繁に得られるかもしれません。
残念ながら、この言語は2つの点で私たちを助けることができません。クラスのデフォルトはfinalではなく、拡張可能なものになっています。そして、 final
のメカニズムは、拡張を制約するか、実装技術として多相性を使うかの選択を作者に迫るという点で、実際にはかなり弱いものです。この緊張感を代償にしている良い例が String
です。文字列が不変であることは、プラットフォームのセキュリティにとって非常に重要です。そのため、String
は公に拡張することができません。ですが、サブタイプが複数あると実装がかなり便利になります。(これを回避するためのコストは相当なものです。 Compact strings は大きなフットプリントを実現しました。また、Latin-1
の文字のみで構成される文字列に特別な扱いを与えることで、パフォーマンスを向上させることができます。しかし、String
が final
なものではなく、Sealed
クラスであったならば、これを行うのははるかに簡単で安上がりだったでしょう)。
これは、パッケージ・プライベートのコンストラクタを使うことや、すべての実装を同じパッケージに入れることで、Sealed クラス(インタフェースではない)の効果をシミュレートするためのよく知られたトリックです。これは助かります。とはいえ、拡張する意味のない public abstract クラスを公開するのはいささか違和感があります。ライブラリの作者は、不透明な抽象を公開するためにインタフェースを使用することを好むでしょう。抽象クラスはモデリングツールではなく、実装の補助であることが意図されていました。(Effective Java の「抽象クラスよりインタフェースを優先する」を参照)。
Sealed インタフェースを使えば、ライブラリの作者は、実装技術としてポリモーフィズムを使うか、制御されない拡張を許可するか、抽象化された部分をインタフェースとして公開するかのいずれかを選択する必要がなくなります。 3つとも持つことができます。このような状況では、作者は実装クラスをアクセス可能にすることを選択できます。しかし、より可能性が高いのは、実装クラスがカプセル化されたままであることです。
Sealed クラスは、ライブラリの作者がアクセシビリティを拡張性から切り離すことを可能にします。この柔軟性があるのはいいことですが、いつ使いますか?確かに、List
のようなインタフェースはシールしたくないでしょう。ユーザが新しい種類の List
を作成することは、完全に合理的であり、望ましいことです。シールにはコスト(ユーザは新しい実装を作成することができない) とメリット (実装はすべての実装についてグローバルに推論できます)があります。シールは、コストよりもメリットの方が大きい場合のためにとっておくべきです。
細則
Sealed
修飾子は、クラスやインタフェースに適用できます。明示的に final
修飾子で宣言されたクラスであっても、暗黙的に final なクラス ( enum や record クラスなど) であっても、すでに final なクラスをシールしようとするとエラーになります。
Sealed クラスには permits
リストがあり、これが唯一許可されている直接のサブタイプです。これらは、Sealed クラスがコンパイルされた時点で利用可能でなければなりません。それは、実際にはSealed クラスのサブタイプであり、Sealed クラスとして同じモジュール内になければなりません。(または、名前のないモジュールの場合は同じパッケージ内に)。 この要件は、事実上、それらが Sealed クラスと共同で維持されなければならないことを意味しており、このような密結合のための合理的な要件です。
許可されたサブタイプがすべて Sealed クラスと同じコンパイル単位で宣言されている場合、permits
句は省略されてもよく、同じコンパイル単位ですべてのサブタイプが宣言されていると推論されます。Sealed クラスは、ラムダ式の関数インタフェースとして、あるいは匿名クラスのベースとなる型として使用することはできません。
Sealed クラスのサブタイプは、その拡張性についてより明確にしなければなりません。Sealed クラスのサブタイプは、Sealed
か、final
なものか、明示的に Sealed
でないことを示さなければなりません。(レコードや列挙型は暗黙のうちに final
ものであるため、明示的にそのように記載する必要はありません) クラスやインタフェースが Sealed
のスーパータイプを直接持っていない場合、Sealed
ではないとしてマークするのはエラーです。
これは、既存の final
クラスを Sealed
するためのバイナリとソースの互換性のある変更です。すでにすべての実装を制御しきれないため、final でないクラスをシールすることは、バイナリでもソースでも互換性がありません。Sealed クラスに新たに許可されたサブタイプを追加することはバイナリ互換性がありますが、ソース互換性はありません(これは switch
式の網羅性を壊す可能性があります)。
ラップアップ
Sealed クラスには複数の用途があります。ドメインモデルの中に選択肢の網羅的な集合を取り込むことが意味のある場合、ドメインモデル化の技術として有用です。また、アクセシビリティを拡張性から切り離すことが望ましい場合、実装技術としても有用です。Sealed 型は Records を自然に補完するものであり、それらが一緒になって代数的データ型として知られる共通のパターンを形成するからです。また、Java にも間もなく登場するパターンマッチングにも自然に適合します。
脚注
1この例では、スイッチ式のフォームを使用しています。これは、Java 言語ではまだサポートされていない case ラベルとしてのパターンを使用しています。半年間のリリーススケジュールでは、機能を共同設計しながらも、独立して提供できます。近い将来、switch が case ラベルとしてパターンを使用できるようになることを十分に期待しています。
著者について
Brian Goetz氏はオラクルのJava言語アーキテクトであり、JSR-335 (Java プログラミング言語のための Lambda 式)の仕様をリードしていました。 ベストセラー『Java Concurrency in Practice』の著者であり、Jimmy Carter氏が大統領だった頃からプログラミングに魅了されてきました。