10
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-07-11

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

sealed と open の違い

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

open class Base
class A : Base()
class B : Base()
sealed class Base
class A : Base()
class B : Base()

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

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

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

sealed とは何か

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

package com.example

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

class C : A() // NG ❌

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

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

package com.example

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

class C : A() // OK ✅

enum クラス

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

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

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

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

const val PATTERN_D = 3

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

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

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

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

enum class Pattern {
    A, B, C
}

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

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

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

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

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

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

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

sealed class の例
sealed class A

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

一方 enum class は値の型レベルの区別が主になります。

enum classの例
enum class MyEnum {
    A, B, C
}

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

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

sealed, enum, その他の方法?(長いので飛ばしても構いません)

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

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

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

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

以下は私個人で開発している「back-in-time-plugin」に関連する話になります。

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

そこで問題になったのが、

  • どこまで厳密に型で縛るのか
  • 区別にはsealedenum、あるいは別の表現を使うか

の2つです。

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

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

suspend fun <T> String.hoge(param1: T, vararg param2: Int) : String 
項目
修飾子 suspend
レシーバー型 String
名前 hoge
型引数 T
引数 param1(T), param2(可変長, Int)
戻り値型 String

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

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 か

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

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

1. sealed クラスを使う場合

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

  • Unspecified: 未指定(なんでもいいの意味)
  • Empty: 空引数
  • Specified: 指定(条件にマッチする必要がある)
sealed class ParameterMatcher {
    data object Unspecified : ParameterMatcher()
    data object Empty : ParameterMatcher()
    data class Specified(val parameters: List<TypeMatcher>) : ParameterMatcher()
}

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

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パターン区別可能になります。

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 を使った場合はこんな感じになりますね。

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つ型比較...(省略)
    }
}

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

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

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

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 を使って実装するのが良さそうです。

まとめ

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

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

補足: sealed classsealed interface

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

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

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

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

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

10
10
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
10
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?