LoginSignup
14
8

More than 1 year has passed since last update.

Java 17 の Sealed Classes の書き方

Last updated at Posted at 2021-09-25

Java 17 で Sealed Classes1 が正式版になりました。Pattern Matching for switch が Preview 版2のため積極的に利用する価値に乏しいせいか、詳しい記述方法の解説が見当たらなかったので、JEP 409: Sealed Classes で確認しました。この記事では JEP の内容を私なりにご紹介しますが、その前に簡単な使い方と、私の Sealed Classes への期待を述べたいと思います。

はじめに簡単な使い方

Sealed Classes を使う場合、同時に Records を使いたいことが多いと思います。そのためには sealed class ではなく sealed interface を使います。

以下はガンダム、ガンキャノン、ガンタンク以外の連邦軍MSは認めたくない場合の例で、各クラスは public なのでそれぞれ別のファイルに記述されているものとします。

sealed interface 連邦軍MS permits ガンダム, ガンキャノン, ガンタンク {
}

public record ガンダム(左腕 lArm, 右腕 rArm, 左足 lLeg, 右足 rLeg)
    implements 連邦軍MS {
}

public record ガンキャノン(キャノン lCannon, キャノン rCannon, 左腕 lArm, 右腕 rArm, 左足 lLeg, 右足 rLeg)
    implements 連邦軍MS {
}

public record ガンタンク(キャノン lCannon, キャノン rCannon, 左腕 lArm, 右腕 rArm, キャタピラー lCaterpillar, キャタピラー rCaterpillar)
    implements 連邦軍MS {
}

Sealed Classes はガンダムとガンタンクのような構造がまるで異なるものを同種のものとして扱いたいときに役立ちます。

Pattern Matching for switch が使えるようになったら

現在のところ、連邦軍MSの種類ごとの分岐は、以下のように書かなければなりません。

static void printCannons(連邦軍MS ms) {
    if (ms instanceof ガンダム g) {
        System.out.println();
    } else if (ms instanceof ガンキャノン c) {
        System.out.printf("%s, %s%n", c.lCannon(), c.rCannon());
    } else if (ms instanceof ガンタンク t){
        System.out.printf("%s, %s%n", t.lCannon(), t.rCannon());
    }
}

この方法だと、もし連邦軍MSの permits にジムキャノンが追加されたとしても、printCannons メソッドの書き換えを忘れたら、本来印字されるべきキャノンの情報が印字されないというバグを生んでしまいます。

ですが今後、Pattern Matching for switch が使えるようになったら、以下のように書くことができます。

static void printCannons(連邦軍MS ms) {
    switch (ms) {
        case ガンダム g -> System.out.println();
        case ガンキャノン c -> System.out.printf("%s, %s%n", c.lCannon(), c.rCannon());
        case ガンタンク t -> System.out.printf("%s, %s%n", t.lCannon(), t.rCannon());
    }
}

このように default 節のない switch を使えば、分岐でジムキャノンが漏れていないか、コンパイラーがチェックしてくれます。

ただし、この書き方でも null の case は漏れています。この点で Kotlin などと異なる点には注意が必要です。

私が Java 17 の Sealed Classes に期待すること

前述の例のように Pattern Matching for switch が使えるようになれば Sealed Classes が有用ですが、現状では Java で使う限り、サブクラスを制限したいという意図を他のプログラマーが理解できるという以上の利点はありません。

私が期待するのは、Kotlin で Sealed Classes が利用可能になることです。Java 17 で sealed クラスを書くと Kotlin でも sealed クラスと認識されて、Java の switch 式にあたる when 式で不要な else ブランチを書かなくて済んだら便利です。残念ながら Kotlin 側でも、次のメジャー バージョンではサポートされないよう3ですが、Java の次の LTS が出るよりは前に対応されるのではないかと期待しています。

Sealed Classes が目標としたこと

ここからは「JEP 409: Sealed Classes」の内容を紹介します。ただし、個人的な趣味により、例は可能な限りガンダムに差し替えました。

Sealed Classes の目標は以下の3つです。

  • クラスやインターフェースの作者が、どのコードがそれを実装できるかを制御できるようにする
  • スーパークラスの利用を制限する、アクセス修飾子よりも宣言的な方法を提供する
  • パターンの網羅解析の基盤を提供し、パターン マッチングの将来の方向性を支援する

動機

これまでも enum classes を用いて、限定された数のインスタンスしかないことを表現することはできましたが、限定された数のクラスしかないことを表現する良い方法がありませんでした。

interface 天体 { ... }
final class 惑星 implements 天体 { ... }
final class 恒星 implements 天体 { ... }
final class 彗星 implements 天体 { ... }

こんなふうに書いたとしても、自分たちの天体モデルにはこの3種類しかないことを示せていません。

ですが、サブクラスを制限するという考えは昔からあり、final class にしたり、コンストラクターを package-private にしたりしてきました。JDK では次の例があります。

package java.lang;

abstract class AbstractStringBuilder { ... }
public final class StringBuffer  extends AbstractStringBuilder { ... }
public final class StringBuilder extends AbstractStringBuilder { ... }

Package-private アプローチはコードの再利用が目標の場合には有用ですが、どんな代替があるかをモデリングすることが目標の場合には役に立ちません。スーパークラスにユーザーのコードがアクセスできないためです。

要するに、広汎にアクセス可能でも広汎に拡張可能ではないスーパークラスを実現できるべきです。

説明

Sealed class または interface が拡張または実装されるのは、それが permits したときだけです。

宣言に sealed 修飾子をつけることで、クラスは sealed になります。extends や implements 句の後、permits 句でそのクラスを拡張できるクラスを指定します。以下は連邦軍MSが3つのサブクラスを permits した例です。

public abstract sealed class 連邦軍MS
   permits ガンダム, ガンキャノン, ガンタンク { ... }

permits で指定されたクラスは、スーパークラスが名前付きモジュールなら同一モジュール、そうでなければ同一パッケージになくてはなりません。

sealed class とそれを拡張するクラスを同一ファイルに書く場合、permits 句は不要です。たとえば次のように。

abstract sealed class 連邦軍MS { ... 
    final class ガンダム extends 連邦軍MS { ... }
    final class ガンキャノン extends 連邦軍MS { ... }
    final class ガンタンク extends 連邦軍MS { ... }
}

permits されるクラスは正規名がなくてはならず、ないとコンパイル エラーになります。そのため、匿名クラスとローカル クラスは sealed class のサブタイプになることはできません。以下では2箇所でコンパイル エラーが発生します。

public abstract sealed class Sealed {
    private Sealed anonymous = new Sealed() {}; // エラー
    void someMethod() {
        class LocalClass extends Sealed {} // エラー
    }
}

Sealed class は permits したクラスに3つの制約を課します。

  1. Sealed class とサブクラスは同一モジュール (無名モジュールの場合は同一パッケージ) でなければならない。

  2. すべての permits されたサブクラスは直接 sealed class を拡張しなければならない。

  3. すべての permits されたサブクラスはスーパークラスの開始した sealing をどのように伝搬させるかを示す修飾子を用いなければならない。

    • final でこれ以上拡張されないようにする。(Record クラスは暗黙的に final。)
    • sealed で親クラスが指定した以上には拡張できるが、際限のない拡張はされないようにする。
    • non-sealed で際限のない拡張を許可する。
public abstract sealed class 連邦軍MS
    permits ガンダム, ガンキャノン, ガンタンク {}

final class ガンタンク extends 連邦軍MS {}

sealed class ガンキャノン extends 連邦軍MS
    permits ガンキャノンⅡ {}
final class ガンキャノンⅡ extends ガンキャノン {}

non-sealed class ガンダム extends 連邦軍MS {}
class ガンダムMkⅡ extends ガンダム {}
class Ζガンダム extends ガンダム {}
...
class ターンエーガンダム extends ガンダム {}

ガンダムは不特定のクラスに拡張される可能性がありますが、連邦軍MSがガンダムかガンキャノンかガンタンクかで分岐しているコードが網羅的でなくなることはありません。ガンダムを extends しないリックディアスを追加したくても、連邦軍MSの permits を変更せずに追加することはできません。

ちなみに、ガンダムは public non-sealed にすれば、別のパッケージで 武者ガンダム extends ガンダム のようにしても大丈夫です。

サブクラスが sealed や non-sealed の場合、そのサブクラスは抽象クラスでも大丈夫です。

クラスのアクセシビリティ

クラスのアクセス修飾子が揃っている必要はなく、サブクラスがスーパークラスよりも制限されたアクセスになっていても構いません。一部のサブクラスにアクセスできないコードもありえるため、Pattern Matching for switch で常に default を省略できるわけではありません。

Sealed interfaces

クラスと同じように、インターフェースも sealed にすることができます。(あれば)スーパーインターフェースを extends した後、実装クラスやサブインターフェースを permits 句で指定します。

sealed interface 連邦軍MS
    permits ガンダム, ガンキャノン, ガンタンク { ... }

final class ガンダム implements 連邦軍MS { ... }
final class ガンキャノン implements 連邦軍MS { ... }
final class ガンタンク implements 連邦軍MS { ... }

Sealing とレコード クラス

Sealed Classes はレコード クラスとよく協調します。レコード クラスは暗黙的に final なので、少しだけ簡潔に記述できます。上記の例では省略していたフィールドを書いている分、見た目は長くなっていますが、以下のようになります。

sealed interface 連邦軍MS
    permits ガンダム, ガンキャノン, ガンタンク { ... }

public record ガンダム(左腕 lArm, 右腕 rArm, 左足 lLeg, 右足 rLeg)
    implements 連邦軍MS { ... }

public record ガンキャノン(キャノン lCannon, キャノン rCannon, 左腕 lArm, 右腕 rArm, 左足 lLeg, 右足 rLeg)
    implements 連邦軍MS { ... }

public record ガンタンク(キャノン lCannon, キャノン rCannon, 左腕 lArm, 右腕 rArm, キャタピラー lCaterpillar, キャタピラー rCaterpillar)
    implements 連邦軍MS { ... }

Sealed classes とレコード クラスの組み合わせは代数的データ型とされることがあります。レコード クラスは直積型を表現するために使え、sealed classes は直和型を表現するために使えます。

Sealed classes と変換

キャストは値を特定の型に変換します。そして、instanceof は値がその型かどうかをテストします。Java はこうした式で使える型に関してとても寛容です。JEP の例を用いますが、たとえば以下のように。

interface I {}
class C {} // I を実装していない

void test (C c) {
    if (c instanceof I) 
        System.out.println("I だよ");
}

上記に記述した限りでは C が I を実装していることはありえませんが、このプログラムは合法です。もちろん、このプログラムが進化して以下のようになることがありえます。

...
class B extends C implements I {}

test(new B()); 
// "I だよ" と印字する

この型変換規則は開かれた拡張性という観念を表しています。Java の型システムは閉ざされた世界を想定していません。クラスやインターフェースはいつか拡張されるかもしれず、キャストは実行時のテストへとコンパイルされるため、柔軟です。

しかしその一方で、この変換規則は、クラスが確実に拡張されないケース、つまり final クラスの場合にも対処します。

interface I {}
final class C {}

void test (C c) {
    if (c instanceof I)     // コンパイル時エラー!
        System.out.println("I だよ");
}

test メソッドはコンパイルできません。コンパイラーが C のサブクラスがありえず、C は I を実装していないため、C の値が I を実装していることは決してありえないと判断できるためです。これはコンパイル時エラーです。

C が final でなく、sealed だったらどうでしょうか? 直接のサブクラスは明示的に列挙され、必ず同じモジュールにありますから、コンパイラーに同じようなコンパイル時エラーを出すことを期待することができます。次のコードを考えてみましょう。

interface I {}
sealed class C permits D {}
final class D extends C {}

void test (C c) {
    if (c instanceof I)     // コンパイル時エラー!
        System.out.println("I だよ");
}

C は I を実装していませんが、final でもありませんので、昔ながらの規則では変換は可能だと結論づけてしまうかもしれません。ですが、C は sealed で1つの permits された直接のサブクラスである D があります。Sealed 型の定義により、D は final、sealed、non-sealed のいずれかです。この例では、C のすべての直接のサブクラスが final で、I を実装していません。C のサブタイプが I を実装することはありえないので、このプログラムは却下されるべきです。

対照的に、前述の例と類似しているが sealed class の直接のサブクラスの一つが non-sealed だった場合を考えてみましょう。

interface I {}
sealed class C permits D, E {}
non-sealed class D extends C {}
final class E extends C {}

void test (C c) {
    if (c instanceof I) 
        System.out.println("I だよ");
}

non-sealed 型である D のサブタイプが I を実装していることはあり得るので、これは型的に正しいです。

結論として、sealed classes をサポートすることは、参照変換の定義を、コンパイル時にどの変換が不可能か決定するために sealed の階層構造をたどるように変更することになります。

JDK での sealed classes

Sealed classes が JDK でどう使えるかの例が、JVM エンティティの記述子をモデルしている java.lang.constant パッケージです。

package java.lang.constant;

public sealed interface ConstantDesc
    permits String, Integer, Float, Long, Double,
            ClassDesc, MethodTypeDesc, DynamicConstantDesc { ... }

// ClassDesc は JDK のクラスだけにサブクラス化させるように設計されている
public sealed interface ClassDesc extends ConstantDesc
    permits PrimitiveClassDescImpl, ReferenceClassDescImpl { ... }
final class PrimitiveClassDescImpl implements ClassDesc { ... }
final class ReferenceClassDescImpl implements ClassDesc { ... } 

// MethodTypeDesc は JDK のクラスだけにサブクラス化させるように設計されている
public sealed interface MethodTypeDesc extends ConstantDesc
    permits MethodTypeDescImpl { ... }
final class MethodTypeDescImpl implements MethodTypeDesc { ... }

// DynamicConstantDesc はユーザーのコードにサブクラス化させるように設計されている
public non-sealed abstract class DynamicConstantDesc implements ConstantDesc { ... }

Sealed classes とパターン マッチング

Sealed classes の非常に大きな恩恵は、switch をパターン マッチングで拡張するよう提案している JEP 406 で実現されます。if-else の連鎖で sealed class のインスタンスを調べるのではなく、ユーザーのコードはパターンで強化された switch を使えるようになります。

例えば、以下の sealed 階層構造とメソッドがあったとします。

public abstract sealed class Shape
    permits Circle, Rectangle, Square { ... }

Shape rotate(Shape shape, double angle) {
        if (shape instanceof Circle) return shape;
        else if (shape instanceof Rectangle) return shape;
        else if (shape instanceof Square) return shape;
        else throw new IncompatibleClassChangeError();
}

Java コンパイラーは instanceof によるテストがすべての Shape のサブクラスをカバーしていることを保証できません。最後の else 節は実際には到達不能ですが、それをコンパイラーが検証することはできません。更に重要なことには、もし instanceof Rectangle のテストを忘れていても、コンパイラーはエラーを発しません。

対照的に、JEP 406 の pattern matching for switch では、コンパイラーは Shape のすべての permit されたサブクラスがカバーされていることを確認できますので、default 節や他の total pattern (Object o とのマッチングなど) は不要です。さらに、コンパイラーはこれら3種類のどのクラスとのマッチングが漏れていても、エラー メッセージを発します。

Shape rotate(Shape shape, double angle) {
    return switch (shape) {   // pattern matching switch
        case Circle c    -> c; 
        case Rectangle r -> shape.rotate(angle);
        case Square s    -> shape.rotate(angle);
        // default 不要!
    }
}

Java の文法

Class 宣言の文法は以下のように改定されます。

NormalClassDeclaration:
  {ClassModifier} class TypeIdentifier [TypeParameters]
    [Superclass] [Superinterfaces] [PermittedSubclasses] ClassBody

ClassModifier:
  Annotation public protected private
  abstract static sealed final non-sealed strictfp
  (のうち1つ)

PermittedSubclasses:
  permits ClassTypeList

ClassTypeList:
  ClassType {, ClassType}

Sealed classes への JVM のサポート

Java Virtual Machine は sealed classes を実行時に認識し、認められていないサブクラスやサブインターフェースによる拡張を阻止します。

sealed はクラス修飾子ですが、ClassFile 構造に ACC_SEALED フラグはありません。かわりに、sealed class のクラス ファイルは PermittedSubclasses 属性を持ち、暗黙的に sealed 修飾子があることを示すとともに明示的に permits されたサブクラスを指定します。

PermittedSubclasses_attribute {
    u2 attribute_name_index;
    u4 attribute_length;
    u2 number_of_classes;
    u2 classes[number_of_classes];
}

permits されたサブクラスのリストは必須です。permits されたサブクラスがコンパイラーによって推論された場合でも、推論されたサブクラスが明示的に PermittedSubclasses 属性に含まれます。

permits されたサブクラスのクラス ファイルに新しい属性はありません。

JVM が PermittedSubclasses 属性を持つスーパークラスやスーパーインターフェースを拡張したクラスを定義しようとした場合、定義されるクラスはその属性によって名前が示されていなければなりません。さもなくば、IncompatibleClassChangeError が投げられます。

Reflection API

java.lang.Class に次のメソッドを追加します。

  • Class<?>[] getPermittedSubclasses()
  • boolean isSealed()

getPermittedSubclasses メソッドは、クラスが sealed だった場合、そのクラスが permits したサブクラスの java.lang.Class オブジェクトの配列を返します。sealed でなければ空の配列が返ります。

isSealed メソッドはそのクラスまたはインターフェースが sealed かどうかを返します。(isEnum のように。)

今後の課題

特に API を書く場合の一般的なパターンとして、public な型としてインタフェースを定義し、それを1つの private クラスで実装するというものがあります。Sealed public interface と1つの permits された private な実装を使うことで、これはより正確に表現できるようになります。したがって、型は広くアクセス可能で、実装はそうではなく、決して拡張されないことになります。

public sealed interface Foo permits MyFooImpl { } 
private final class MyFooImpl implements Foo { }

このアプローチのぎこちないところは、Foo オブジェクトを受け取る実装メソッドが明示的なキャストを必要とすることです。

void m(Foo f) { 
    MyFooImpl mfi = (MyFooImpl) f;
    ...
}

我々はこのキャストが常に成功することを知っているので、このキャストは不要なように思われます。ですが、このキャストには、MyFooImpl が Foo の唯一の実装であるという暗黙の意味論的仮定があります。著者にはこの直観を捉えてコンパイル時にチェックできるようにする方法はありません。もし、いずれ、Foo が追加の実装を permits するとしたら、このキャストは型的に正しいままで、実行時に失敗するものになるでしょう。別の言葉で言えば、意味論的仮定が崩れても、コンパイラーはその事実についてのエラーを開発者に伝えられないのです。

sealed 階層構造の精度があるならば、開発者にこのような意味論的仮定を表現できる手段を提供し、コンパイラーがそれをチェックできるようにすることには価値があるでしょう。これは代入のコンテクストでの sealed スーパータイプをそのサブタイプに変換するような参照変換の新形式を追加することで達成できるかもしれません。

MyFooImpl mfi = f; // MyFooImple が Foo の唯一の permits された
                   // サブクラスだとコンパイラーにわかるため認められる。
                   // (安全のため生成されたキャストが追加されるかもしれない。)

あるいは、新形式のキャストを提供することもできるかもしれません。

MyFooImpl mfi = (total MyFooImpl) f;

いずれの場合も、インターフェース Foo が別の実装を permits するように変更されたら、再コンパイル時にエラーが起きることになるでしょう。

JEP の内容はここまでです。

日本語での読み方について

javac のエラー メッセージでは「シール・クラス」となっています。これは「シールド」が盾を連想させるためだと思われます。

この記事ではその表記は採用せず英語で通しました。理由は、シールはステッカーを連想させるためシールドと同じ問題を抱えていること、Kotlin の sealed class を私は「シールド クラス」と呼んでいることです。

参考

14
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
8