【Kotlin】sealed class と enum class をどう使い分けるか

こんにちは、こんばんは、kitakkunです。
パターンマッチングに使用できる sealed class と enum class は似て非なる存在ですが、適切な使い分け方をうまく言語化できていないと思ったので、記事にまとめてみます。

# sealed と open の違い

Kotlinに初めて触れるタイミングで、`sealed` と `open` ってどう違うんだろうと疑問に思うことがあるかもしれません。

```kotlin
open class Base
class A : Base()
class B : Base()
```

```kotlin
sealed class Base
class A : Base()
class B : Base()
```

見た目上の違いは `open` と `sealed` のみですが、型マッチングを使うタイミングで両者に違いが現れます。

```kotlin
val instance: Base = ...
// BaseがsealedならOK、openならエラー
when (instance) {
    is A -> {}
    is B -> {}
}
```

上のコードを `Base` が `sealed` ならコンパイルが通りますが、`open` の場合は 「`else` ブロックを追加してね」と怒られます。なぜでしょうか。

# sealed とは何か

`sealed`修飾子は、継承可能な範囲を同一パッケージ内に制限します。これによって、コンパイルタイムで同じ型を持ちうるクラスが制限されるので、`when` 式で網羅的に型マッチングできるというわけです。

```kotlin
package com.example

sealed class A
class B : A() // OK ✅
```
```kotlin
package com.otherpackage

class C : A() // NG ❌
```

:::note info
コンパイラ的な視点で言うと、K2 Compiler の FIR解決フェーズ に `SEALED_CLASS_INHERITORS` というフェーズがあって、そこで継承クラスが全てインデックス化されています。
:::

逆に言うと `open` はどこで継承されているのかわからない、つまり`when` 式で型マッチングしても未知の型がある可能性を拭えないので `else` が必要になると言うわけです。こう考えると、だいぶ合理的ですね。

```kotlin
package com.example

open class A
class B : A() // OK ✅
```
```kotlin
package com.otherpackage

class C : A() // OK ✅
```
# enum クラス

複数のパターンを区別するときに、定数値を使うことがあるかもしれません。

```kotlin
const val PATTERN_A = 0
const val PATTERN_B = 1
const val PATTERN_C = 2

when (value) {
    PATTERN_A -> {}
    PATTERN_B -> {}
    PATTERN_C -> {}
    else -> {}
}
```

しかし、このようなロジックの作り方はバグを埋め込みやすいです。
新しくパターンが増えたとします。

```kotlin
const val PATTERN_D = 3
```

定数を追加しても、かつての `when` 式はコンパイルエラーになることはありません。

```kotlin
when (value) {
    PATTERN_A -> {}
    PATTERN_B -> {}
    PATTERN_C -> {}
    // PATTERN_Dが抜けているがエラーにならない
    else -> {}
}
```

もし複数箇所で同じ定数を使ったロジックが組まれていたら・・？ 変更漏れが出るリスクは高いでしょう。

そこで、enum（列挙型）の出番です。数値に特別な意味を持たせる代わりに、直接名前でパターンを定義します。これなら各パターンの関連性が明確になるほか、`else` を追加する必要はなく、`when` 式で網羅できていることを保証することができます※1。

```kotlin
enum class Pattern {
    A, B, C
}

val pattern: Pattern
when (pattern) {
    A -> {}
    B -> {}
    C -> {}
}
```

:::note warn
※1: とはいえ `when` 式の中に `else` があると数値で分岐していた場合と同様にコンパイルエラーにはならず漏れが出る可能性があるので注意が必要です。

```kotlin
val pattern: Pattern
// 元々以下のように書いていた場合
// PatternにDが増えてもelseに包含されてコンパイルエラーにはならない
when (pattern) {
    A -> {}
    B -> {}
    else -> {}
}
```
:::

# 言語機能上の制限に着目した sealed と enum 使い分け

`sealed` と `enum`、どちらもパターンマッチングに便利な機能で、`when` 式での分岐を網羅的に行えるという点が共通しています。とはいえ、意外と使い分けが難しいと感じます。

そこで、言語機能の制限に着目して `sealed` と　`enum` を使い分けすると以下のようになります。

```mermaid
graph TD
    A[動的な値を持たせたい？] -->|Yes| B[sealed]
    A -->|No| C[全ての要素が同じフィールドを持って良い？]
    C --> |Yes| D[enum]
    C --> |No| B
```

sealed class の場合、各要素で異なるフィールドを持つことができ、かつ動的な値を持たせることが可能です。

```kotlin:sealed class の例
sealed class A

// フィールドなし
data object B : A()
// フィールドあり
data class C(val dynamicValue: Int) : A()
```

一方 enum class は値の型レベルの区別が主になります。
```kotlin:enum classの例
enum class MyEnum {
    A, B, C
}
```

フィールドを持つこともできますが、全ての要素が同一のフィールドを持つしかありません。

```kotlin
enum class MyEnum(val str: String) {
    A("a"), B("b"), C("c")
}
```

# sealed, enum, その他の方法？（長いので飛ばしても構いません）

原則として、上で紹介した言語仕様上の制限に応じた使い分けで十分でしょう。

とはいえ、Kotlinは柔軟な言語ですので、sealedで作るか、enumで作るか、あるいは null を交えた状態表現を活用するかなど実装上迷うことは少なくありません。

今回は1つ、私が実際に開発に際して困った事例をもとに考えてみようと思います。

## 引数のマッチングパターンの表現方法

以下は私個人で開発している「[back-in-time-plugin](https://github.com/kitakkun/back-in-time-plugin)」に関連する話になります。

こちらのプラグインでは、内部的に**関数シンボルとのマッチングを行う独自機構**が存在します。
現状簡単な文字列比較で実装されているのですが、厳密性が不足しており曖昧なため型で縛れないかと言うことを考えました。

そこで問題になったのが、
- どこまで厳密に型で縛るのか
- 区別には`sealed`、`enum`、あるいは別の表現を使うか

の2つです。

### どこまで厳密に型で縛るのか

単に関数といっても、マッチングを行うためには実に様々な情報が必要です。
以下付けられそうなものをふんだんにつけた関数です。

```kotlin
suspend fun <T> String.hoge(param1: T, vararg param2: Int) : String 
```

項目 | 値
:--: | :--:
修飾子 | `suspend`
レシーバー型 | `String`
名前 | `hoge`
型引数 | `T`
引数 | `param1`(`T`), `param2`(可変長, `Int`)
戻り値型 | `String`

これらの情報を型レベルでギチギチに表現することを考えます。

```kotlin
data class FunctionMatcher(
    val name: String,
    val receiverType: TypeMatcher,
    val typeParameters: List<TypeMatcher>,
    val parameters: List<TypeMatcher>,
    val returnType: TypeMatcher,
    val modifiers: Set<Modifier>,
)

// 補足:
//   - 厳密には他にも考慮点ありそう
//   - TypeMatcherは型マッチングデータを定義するクラスです
//   - Modifierは関数につく修飾子を表現したenumとします
```

ここで、`typeParameters`(型引数)や`parameters`(仮引数)のマッチングルールに注目します。
引数のマッチングに「なんでも良い（未指定）」と言うパターンを加えたい場合、あなたならどうしますか？

- `sealed class` を作る
- `enum` を活用する
- `List.isEmpty`, `null` を使って表現する
- あるいは・・？

### 区別にはsealed、enum、あるいは別の表現を使うか

#### 0. sealed か、enum か

フローチャートを再掲します。

```mermaid
graph TD
    A[動的な値を持たせたい？] -->|Yes| B[sealed]
    A -->|No| C[全ての要素が同じフィールドを持って良い？]
    C --> |Yes| D[enum]
    C --> |No| B
```

動的な値（型マッチングのリスト）を持っているので、`enum` は向かないでしょう。
ですので、`sealed` を使います。

#### 1. sealed クラスを使う場合

`sealed` クラスを使って分類すると以下のようになるかと思います。

- `Unspecified`: 未指定（なんでもいいの意味）
- `Empty`: 空引数
- `Specified`: 指定（条件にマッチする必要がある）

```kotlin
sealed class ParameterMatcher {
    data object Unspecified : ParameterMatcher()
    data object Empty : ParameterMatcher()
    data class Specified(val parameters: List<TypeMatcher>) : ParameterMatcher()
}
```

改めて、`FunctionMatcher`は次のような実装となります。

```kotlin
data class FunctionMatcher(
    val name: String,
    val receiverType: TypeMatcher,
    val typeParameters: ParameterMatcher, // List<TypeMatcher>から変更
    val parameters: ParameterMatcher, // List<TypeMatcher>から変更
    val returnType: TypeMatcher,
    val modifiers: Set<Modifier>,
)
```

おお、良さげな感じ。

#### 2. リストの状態を使って表現する例

上のような例は sealed クラスを使わずとも表現できます。

- `Unspecified` -> `null`
- `Empty` -> `List.isEmpty()`
- `Specified` -> `List.isNotEmpty()`

つまり、`List<TypeMatcher>` を Nullable にするだけでも、3パターン区別可能になります。

```kotlin
data class FunctionMatcher(
    val name: String,
    val receiverType: TypeMatcher,
    val typeParameters: List<TypeMatcher>?, // List<TypeMatcher>から変更
    val parameters: List<TypeMatcher>?, // List<TypeMatcher>から変更
    val returnType: TypeMatcher,
    val modifiers: Set<Modifier>,
)
```

別の型実装に飛んでいかなくても直接どんなデータが入るかわかるのは魅力だが・・。

## 使用側から見た比較

上で作った2パターンを、使う側の視点で考えてみます。

### sealed クラスの実装

関数と実際にマッチングを行うと考えた時に、`sealed` を使った場合はこんな感じになりますね。

```kotlin
val targetFunction: IrSimpleFunction = ... // backend コンパイラ上の関数表現

val functionMatcher: FunctionMatcher = ...
when (functionMatcher.parameters) {
    is ParameterMatcher.Unspecified -> return true
    is ParameterMatcher.Empty -> {
        return targetFunction.valueParameters.isEmpty()
    }
    is ParameterMatcher.Specified -> {
        if (functionMatcher.parameters.parameters.size != targetFunction.valueParameters.size) {
            return false
        }
        // 1つ1つ型比較...（省略）
    }
}
```

随分ロジックがわかりやすいかと思います。

### リストの状態を使って表現した実装

`null` や `List.isEmpty()` とかで判別するようにしてみると以下のようになります。

```kotlin
val targetFunction: IrSimpleFunction = ... // backend コンパイラ上の関数表現

val functionMatcher: FunctionMatcher = ...
val parameters = functionMatcher.parameters
when {
    // 未指定
    parameters == null -> return true
    // 空
    parameters.isEmpty() -> {
        return targetFunction.valueParameters.isEmpty()
    }
    // 指定あり
    else -> {
        if (parameters.size != targetFunction.valueParameters.size) {
            return false
        }
        // 1つ1つ型比較...（省略）
    }
}
```

早期 return など他にも色々書きようはあるかと思いますが、意図がわかりにくいというのが第一印象です。

## 引数のマッチングパターンの表現方法の結論

結論ですが、`sealed` を使った実装は、定義側の実装が長くなってしまう一方で、ロジック側の実装はより読みやすくすることができる印象を受けました。

逆に、null許容性やクラス自体が保持する状態などを利用して管理しようとすると、定義部の実装が楽な一方で使用箇所における複雑性が増してしまいます。

今回の場合は、`sealed` を使って実装するのが良さそうです。


# まとめ

記事を書いていてだんだんと自分が何を書きたかったのかわからなくなってしまったのですが、ざっくりまとめます。

- `sealed` と `enum` はパターンを表現するのに有効に使える機能である。
- `sealed` は同一パッケージ内に型の継承範囲を制限するための修飾子で、動的な値を持ったり要素固有の値を持てるようにした `enum` のような扱い方ができる。
- 純粋にパターンだけを区別したい場合や各要素で同一フィールドを持つことが明らかな場合は `enum` を使うのが良い。
- null許容性などを使ったりクラス自身の機能（`List.isEmpty()`など）を使ってパターンを区別するのも有効だが、ロジックが複雑になる場合は `sealed` を使った分類を行うことも考えると良いかもしれない。


:::note info
補足: `sealed class` と `sealed interface`

本文中で `sealed class` の代わりに `sealed` と言う表現も使っていたのには、理由があります。

`sealed class` の他に `sealed interface` と言うのも存在するからです。
これらには大きな違いはなく、`sealed interface`も`when`で網羅できます。
（あるのは`class`と`interface`の違いくらいです）

```kotlin
sealed interface Base
data object A : Base
data class B(val str: String) : Base

val instance: Base = ...
when (instance) {
    is A -> {}
    is B -> {}
}
```

:::

以上です。長くなりましたが、お読みいただきありがとうございました。
何か変なこと、気になる点ありましたらコメントいただけると幸いです。