こんにちは、こんばんは、kitakkunです。
パターンマッチングに使用できる sealed class と enum class は似て非なる存在ですが、適切な使い分け方をうまく言語化できていないと思ったので、記事にまとめてみます。
sealed と open の違い
Kotlinに初めて触れるタイミングで、sealed
と open
ってどう違うんだろうと疑問に思うことがあるかもしれません。
open class Base
class A : Base()
class B : Base()
sealed class Base
class A : Base()
class B : Base()
見た目上の違いは open
と sealed
のみですが、型マッチングを使うタイミングで両者に違いが現れます。
val instance: Base = ...
// BaseがsealedならOK、openならエラー
when (instance) {
is A -> {}
is B -> {}
}
上のコードを Base
が sealed
ならコンパイルが通りますが、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 使い分け
sealed
と enum
、どちらもパターンマッチングに便利な機能で、when
式での分岐を網羅的に行えるという点が共通しています。とはいえ、意外と使い分けが難しいと感じます。
そこで、言語機能の制限に着目して sealed
と enum
を使い分けすると以下のようになります。
sealed class の場合、各要素で異なるフィールドを持つことができ、かつ動的な値を持たせることが可能です。
sealed class A
// フィールドなし
data object B : A()
// フィールドあり
data class C(val dynamicValue: Int) : A()
一方 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」に関連する話になります。
こちらのプラグインでは、内部的に関数シンボルとのマッチングを行う独自機構が存在します。
現状簡単な文字列比較で実装されているのですが、厳密性が不足しており曖昧なため型で縛れないかと言うことを考えました。
そこで問題になったのが、
- どこまで厳密に型で縛るのか
- 区別には
sealed
、enum
、あるいは別の表現を使うか
の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つ型比較...(省略)
}
}
随分ロジックがわかりやすいかと思います。
リストの状態を使って表現した実装
null
や List.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
を使って実装するのが良さそうです。
まとめ
記事を書いていてだんだんと自分が何を書きたかったのかわからなくなってしまったのですが、ざっくりまとめます。
-
sealed
とenum
はパターンを表現するのに有効に使える機能である。 -
sealed
は同一パッケージ内に型の継承範囲を制限するための修飾子で、動的な値を持ったり要素固有の値を持てるようにしたenum
のような扱い方ができる。 - 純粋にパターンだけを区別したい場合や各要素で同一フィールドを持つことが明らかな場合は
enum
を使うのが良い。 - null許容性などを使ったりクラス自身の機能(
List.isEmpty()
など)を使ってパターンを区別するのも有効だが、ロジックが複雑になる場合はsealed
を使った分類を行うことも考えると良いかもしれない。
補足: sealed class
と sealed interface
本文中で sealed class
の代わりに sealed
と言う表現も使っていたのには、理由があります。
sealed class
の他に sealed interface
と言うのも存在するからです。
これらには大きな違いはなく、sealed interface
もwhen
で網羅できます。
(あるのはclass
とinterface
の違いくらいです)
sealed interface Base
data object A : Base
data class B(val str: String) : Base
val instance: Base = ...
when (instance) {
is A -> {}
is B -> {}
}
以上です。長くなりましたが、お読みいただきありがとうございました。
何か変なこと、気になる点ありましたらコメントいただけると幸いです。