[Java17] 新機能 Sealed Classes の詳細

# この記事について

Java15からpreview機能としてテストされ，Java17で正式に導入された sealed class について紹介します．クラスやインターフェースを継承・実装できるものを制限できます．

以下のように，既存の要素を変更するものではありません．

+ `friend` のような新しいアクセス制御を提供するものではない
+ `final` のはたらきを変更するものでは決してない

## 出展

この記事は基本的に [JEP 409](https://openjdk.java.net/jeps/409) に基づきます．また，各所で[Java言語仕様](https://docs.oracle.com/javase/specs/jls/se17/html/index.html)を参照しています．


# Sealedクラスの宣言

クラスに `sealed` 修飾子をつけるとSealedクラスになります．さらに，`extends`節や`implements`節の後に`permits`節を付けて，自身を継承可能なクラスを明示できます．

次の例では，`Shape` を継承できるのは `Circle` `Rectangle` `Square` の3つです．

```java
package com.example.geometry;

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


## `permits` 節の制約

`permits` に指定するクラス名は正準名(canonical name)のみ使用可能です．従って匿名クラス[^anonymus]やローカルクラスは指定できません．

[^anonymus]: 匿名クラス宣言時に，スーパークラスとして`sealed`クラスを指定するとコンパイルエラーになります．( [JLS 15.9.1](https://docs.oracle.com/javase/specs/jls/se17/html/jls-15.html#jls-15.9.1) )

`permits` 指定されたクラス(以下では「許可されたサブクラス」と呼びます)はスーパークラスの「近く」にある必要があります．具体的には，

+ 同じモジュール内に存在すること
+ 名前なしモジュールの場合は，さらに同じパッケージに所属すること

が要求されます．

例えば，同じ名前付きモジュール内であれば，次のように別パッケージのクラスも指定できます．

```java
package com.example.geometry;

public abstract sealed class Shape
    permits com.example.polar.Circle,
            com.example.quad.Rectangle,
            com.example.quad.simple.Square { ... }
```

### 暗黙的な `permits`

`permits` 節を省略することで，同じファイル内にあるクラス(内部クラスも含む)を自動的に[^autoPermits]許可対象とすることができます．

[^autoPermits]: 厳密には，`sealed`クラス`C`が`permits`節を持たない場合，同じCompilation Unitで宣言されているクラスのうち`C`を直接継承しているものだけが，`C`の直接のサブクラスとして認められます．( [JLS 8.1.6](https://docs.oracle.com/javase/specs/jls/se17/html/jls-8.html#jls-8.1.6) )

例えば，次のような書き方も可能です．

```java:Root.java
abstract sealed class Root { ...
    final class A extends Root { ... }
    final class B extends Root { ... }
    final class C extends Root { ... }
}
```

暗黙的に許可されるサブクラスが1つも存在しない場合，コンパイルエラーになります．[^noPermits]

[^noPermits]: [JLS 8.1.6](https://docs.oracle.com/javase/specs/jls/se17/html/jls-8.html#jls-8.1.6) に記載． `permits` 節には1つ以上のクラスを指定しなければならないことと合わせて，許可対象が0個(つまり`final`と同等)のSealedクラスを作ることはできません．

## サブクラスにかかる制約

許可されたサブクラスは，元の `sealed`クラスを直接継承する必要があります．また，下記のようなスーパークラスの sealing を伝搬するかどうかを示す修飾子を必ずつけなければいけません．具体的には，

+ それ以上の継承を許さない場合は従来通りの `final`
+ さらにサブクラスを制限する場合には，スーパークラスと同様の `sealed`
+ さらなるサブクラスに制限を付けない場合には，`non-sealed` [^nonsealed] [^hyphenated]

の修飾子のどれか1つだけを付与します．

[^nonsealed]: `non-sealed` はSealedクラス・Sealedインターフェースの許可されたサブクラス等にのみ付与可能です．他のクラス・インターフェースに `non-sealed` を付けると，コンパイルエラーになります．( [JLS 8.1.1.2](https://docs.oracle.com/javase/specs/jls/se17/html/jls-8.html#jls-8.1.1.2) )

[^hyphenated]: JDK [Issue 8223002](https://bugs.openjdk.java.net/browse/JDK-8223002) で，ハイフン入りキーワード(hyphenated keyword)の導入について議論されています．`non-sealed`はこの最初の実現例になりました．

例えば，先ほどの例を拡張して

```java
package com.example.geometry;

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

public final class Circle extends Shape { ... }

public sealed class Rectangle extends Shape 
    permits TransparentRectangle, FilledRectangle { ... }
public final class TransparentRectangle extends Rectangle { ... }
public final class FilledRectangle extends Rectangle { ... }

public final class Square extends Shape { ... }

public non-sealed class WeirdShape extends Shape { ... }
```

この中で WeirdShape だけは，別モジュールのクラスが継承できます．

`sealed` クラスと `non-sealed` クラスは抽象クラスにすることができます．従来通り `final abstract` なクラスは作れません．

## アクセス権の制約

`permits` 節でクラス名を使用するので，許可されたサブクラスとSealedスーパークラスは互いにアクセス可能である必要があります．しかし同じアクセスレベルを持つ必要はありません．

例えば`switch`パターンマッチングが導入されれば，アクセスレベルが低いサブクラスがある場合，別モジュール等からの使用時は `default` を省略できないことになります．

# Sealedインターフェース

クラスと同様にインターフェースにも `sealed` 修飾子を付与できます．実装クラス及びサブインターフェースも，同様に `permits` 節で指定できます．

例えば，次のようになります．

```java
package com.example.expression;

public sealed interface Expr
    permits ConstantExpr, PlusExpr, TimesExpr, NegExpr { ... }

public final class ConstantExpr implements Expr { ... }
public final class PlusExpr     implements Expr { ... }
public final class TimesExpr    implements Expr { ... }
public final class NegExpr      implements Expr { ... }
```

アノテーションインターフェースに `sealed` は指定できません．( [JLS 9.6](https://docs.oracle.com/javase/specs/jls/se17/html/jls-9.html#jls-9.6) )

`sealed` インターフェースは関数型インターフェースとはみなされません．( [JLS 9.8](https://docs.oracle.com/javase/specs/jls/se17/html/jls-9.html#jls-9.8) )

# レコード型との関連

Java16 で導入された `record` クラスは暗黙に `final` なので，`sealed`インターフェースを実装できます．
例えば，次のように簡単に書けます．

```java
package com.example.expression;

public sealed interface Expr
    permits ConstantExpr, PlusExpr, TimesExpr, NegExpr { ... }

public record ConstantExpr(int i)       implements Expr { ... }
public record PlusExpr(Expr a, Expr b)  implements Expr { ... }
public record TimesExpr(Expr a, Expr b) implements Expr { ... }
public record NegExpr(Expr e)           implements Expr { ... }
```

この組み合わせによっていわゆる「代数的データ型」が実現できます．`record` クラスが直積型，`sealed`クラスが直和型の表現になっています．

# 列挙型との関連 [^enumChange]

[^enumChange]: 列挙型の変更については JEPに記載が無いため，主に [JLS 8.9](https://docs.oracle.com/javase/specs/jls/se17/html/jls-8.html#jls-8.9) の情報です．

以前から，`enum` には `abstract`, `final` の修飾子はつけられなかったのですが，今回追加された `sealed`, `non-sealed` も列挙型に明示的に指定することはできません．

また，従来の暗黙的な `final` としての扱いは次のように変更されました．

+ クラスボディを持つ列挙定数がない場合は，暗黙的に `final` (従来通り)
+ クラスボディを持つ列挙定数が1つ以上ある場合は，暗黙的に `sealed`
    + 許可されるサブクラスは，対応して暗黙に宣言される匿名クラス
+ クラスボディを持つ列挙定数に対応する匿名クラスは，暗黙に `final` (従来通り)

このように，列挙型は暗黙に `final` あるいは `sealed` なので，他のsealedインターフェースを実装できます． ( [JLS 8.1.1.2](https://docs.oracle.com/javase/specs/jls/se17/html/jls-8.html#jls-8.1.1.2) )


# 型変換との関連

以前から，`final`クラスの制約により絶対に成り立たない `instanceof` 式はコンパイルエラーになっていました．例えば，

```java
interface I {}
final class C {}

void test (C c) {
    if (c instanceof I)     // コンパイルエラー！
        System.out.println("It's an I");
}
```

というコードは，「`C`またはそのサブクラスが`I`を実装する」ことは起こりえないので，コンパイルエラーになります[^alwaysTrueCase]．

[^alwaysTrueCase]: 逆に，必ず成り立つ`instanceof`は，後方互換性の都合でパターンマッチングの場合にのみ禁止されています．これについては[別の記事](https://qiita.com/ReiTsukikazu/items/bfac1d3bbac60915b73e)でも紹介しています．

これと同じように，`sealed` クラス及びそのすべてのサブクラスが(子孫に `non-sealed` がなくて)インターフェース `I` を実装しないことが明らかであるとき[^narrowingConversion]は，`instanceof I` はコンパイルエラーになります．例えば，

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

void test (C c) {
    if (c instanceof I)     // コンパイルエラー！
        System.out.println("It's an I");
}
```

というコードは，`C`及びそのサブクラスが`I`を実装することはあり得ないのでコンパイルエラーになります．この例でもし `D` が `non-sealed` であれば，エラーにはなりません．

[^narrowingConversion]: これを実現するために参照のナローイング変換の定義が変更され，2つのクラス・インターフェースが「互いに素(disjoint)である」という概念が導入されています．( [JLS 5.1.6.1](https://docs.oracle.com/javase/specs/jls/se17/html/jls-5.html#jls-5.1.6.1) )


# パターンマッチングとの関連

Sealedクラスは，Java17時点ではpreview機能として提供されている`switch`パターンマッチングと組み合わせることでとても有用です．現状で利用可能なif-else文では，例えば

```java
class Shaper permits Circle, Rectangle, Square { ... }

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

のようになり，すべての可能性を網羅しているにも関わらず最後のelse節を省略することはできません．しかし，`switch`パターンマッチングの導入後は

```java
// preview機能: Java17 では --enable-preview が必要
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);
        // no default needed!
    }
}
```

のように書ける見込みです．

# その他の詳細情報

## キーワードの追加

`sealed`, `non-sealed`, `permits` が文脈依存キーワード(制限付き識別子)に追加されました．`sealed`や`permits`という名前のクラス等の宣言はコンパイルエラーになります．



## 文法の変更

+ クラス修飾子 `sealed` `non-sealed` の追加
+ クラス宣言に `permits` 節を追加


## JVMの変更

+ `sealed` クラスは修飾子ではなく新属性 `PermittedSubclasses` で表現され，すべての許可されたサブクラスを明示的に列挙します
+ 許可されたサブクラスの class ファイルには変更はありません

## APIの変更

`java.lang.Class` に次のメソッドが追加されました．

+ `Class<?>[] getPermittedSubclasses()`
+ `boolean isSealed()`


以上です．