BT

最新技術を追い求めるデベロッパのための情報コミュニティ

寄稿

Topics

地域を選ぶ

InfoQ ホームページ アーティクル Java 注目の機能:Sealed クラス

Java 注目の機能:Sealed クラス

キーポイント

  • 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.

原文(投稿日:2020/06/17)へのリンク

プレビュー機能

Javaプラットフォームの世界的展開と高い互換性を考えると、言語機能の設計ミスのコストは非常に高くなります。言語の設計ミスの文脈では、互換性への責任は、その機能を削除したり、大幅に変更したりすることが非常に困難であることを意味するだけはありません。既存の機能は、将来の機能ができることを制約します。今日の輝かしい新機能は明日の互換性の制約です。

言語機能の究極の証明の場は、実際に使用されていることです。実際のコードベースで実際に試した開発者からのフィードバックは、機能が意図した通りに動作していることを確認するために不可欠です。Java が複数年に渡ってリリースサイクルを持っていた頃は、実験やフィードバックのための時間が十分にありました。新しい急速なリリースの流れの下で実験とフィードバックのための十分な時間を確保するために、新しい言語機能は、プラットフォームの一部であるプレビューの1回または複数回の繰り返しを経ることになります。しかし、個別に認可を得なければならず、まだ恒久的ではありません。開発者からのフィードバックに基づいて調整する必要がある場合には、そのようにします。これは、ミッションクリティカルなコードを壊すことなく実現できます。

Java SE 15(2020年9月)では Sealed クラスプレビュー機能として導入します。シールにより、クラスやインタフェースは、許可されたサブタイプに対してより多くの制御が可能になります。これは、一般的なドメインモデリングや、より安全なプラットフォームライブラリの構築に役立ちます。

クラスまたはインタフェースはsealedと宣言されている場合があります。これは特定のクラスやインタフェースの集合だけがそれを直接拡張できることを意味します。


sealed interface Shape
    permits Circle, Rectangle { ... }

これは Shape と呼ばれる Sealed インタフェースを宣言します。permits リストは、CircleRectangle だけが Shape を実装できることを意味しています。(場合によっては、コンパイラが permits 句を推論できるかもしれません)。 Shape を拡張しようとする他のクラスやインタフェースは、コンパイルエラーを受けます(または、Shape をスーパータイプとして宣言しているラベル外のクラスファイルを生成しようとした場合は、実行時エラーとなります)。

私たちは、final クラスを介して拡張を制限するという概念をすでに知っています。シールは final 化の一般化と考えることができます。許可されるサブタイプのセットを制限すると、2つの利点があります。スーパータイプの作者はすべての実装を制御できるので、可能な実装についてより良い推論ができ、コンパイラは網羅性についてより良い推論ができます。(スイッチ文やキャスト変換など)。 Sealed クラスは Records との相性も抜群です。

直和型と直積型

上記のインタフェース宣言では、ShapeCircle でも Rectangle でもよいという宣言をしていますが、それ以外は何もありません。別の言い方をすれば、すべての Shape の集合は、すべての Circle の集合にすべての Rectangles の集合を加えたものになります。このため、Sealed クラスは、直和型と呼ばれることが多いです。なぜなら、それらの値の集合は他の型の一定のリストの値の集合の和であるためです。直和型と Sealed クラスは新しいことではありません。例えば、ScalaはSealed クラスがあり、HaskellML には直和型を定義するためのプリミティブがあります(たまにタグ付き共用型判別共用体と呼ばれることもあります)。

直和型は、直積型と並んでよく見られます。最近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 の宣言から CircleRectangle がすべての Shape をカバーしていることを知っていたからです。そのため、上記の switch では default 句は到達しないということを知っていました。(コンパイラはまだスイッチ式の中にデフォルト節を静かに挿入しています。念のため、Shape の許可されたサブタイプはコンパイル時と実行時の間に変更されます。しかし、プログラマがこのデフォルト句を「念のため」に書けと主張する必要はありません)。 これは、別の exhaustiveness のソースをどう扱うかに似ています。既知の定数をすべてカバーする列挙型のスイッチ式もデフォルト節を必要としません。 (そして、この場合は省略するのが一般的です。これは、case を忘れたことを警告してくれる可能性が高いからです)。

Shape のような階層構造は、クライアントに選択の余地を与えます。それは、抽象的なインタフェースを介してShapeを完全に扱えるということです。しかし、彼らはまた、抽象化を「展開」することができ、それが意味のあるときには、より鋭い型を介して相互作用できます。パターンマッチングのような言語機能は、この種の展開をより快適に読み書きできます。

代数的データ型の例

「直積の和」パターンは強力なものになる可能性があります。それが適切であるためには、サブタイプのリストが変更される可能性が極めて低くなければなりません。そして、クライアントがサブタイプを直接識別することがより簡単に、より有用になることを期待しています。

一定のサブタイプに委ねることは密結合の一つです。さらにそれはクライアントがそれらのサブタイプを直接使用することを奨励することでもあります。すべてのことが等しくなるように、私たちは将来的に物事を変更するための柔軟性を最大限に高めるために、設計に緩い結合度を使用することを推奨しています。 しかし、このような緩い結合度にもコストがかかります。「不透明」と「透明」の両方の抽象化を言語化することで、状況に応じて適切なツールを選択できます。

直積の和(当時これがオプションだった場合)を使用する可能性がある場所の一つは、java.util.concurrent.Future のAPIにあります。Future は、イニシエータと同時に実行されるかもしれない計算を表します。Future で表される計算は、まだ開始されていない可能性があり、始まっているが、まだ完成していないかもしれない、既に終わっていて正常に完了しているか例外が発生しているかもしれません、タイムアウトしているかもしれない、または中断によってキャンセルされているかもしれません。Futureget() メソッドは、これらの可能性をすべて反映しています。

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 の文字のみで構成される文字列に特別な扱いを与えることで、パフォーマンスを向上させることができます。しかし、Stringfinal なものではなく、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氏が大統領だった頃からプログラミングに魅了されてきました。

この記事に星をつける

おすすめ度
スタイル

BT