Kotlin で Either が欲しくなったときに考えること

Kotlinは表現力が豊かな言語で標準ライブラリも充実しており、モダンな機能を多数備えています。現在も活発に開発が進んでいて他の言語から様々な概念を取り入れることも頻繁にあります。そんなKotlinですが、他の言語にあってKotlinにはないものも少なからずあります。今回はEitherを例にあげ、KotlinでEitherを表現する方法について考えてみます。

異なる2つの型のいずれかの型を返り値とする関数を作りたい

はじめに、次のような2つの異なる型のいずれかを返す関数を考えてみます。

data class Foo(val name: String)
data class Bar(val description: String)

fun getSomething() = if (flag) {
  Foo("foo")
} else {
  Bar("bar")
}

この getSomething 関数は flag の値によって Foo 型か Bar 型のいずれかの型のオブジェクトを返します。

一見すると正しくコンパイルできなさそうにも見えますが、次のように関数の定義を明示してみると、getSomething 関数が問題なくコンパイル可能なことがわかります。Kotlin ではコンパイラが関数の返り値の型を推論できる場合、返り値の型の宣言を省略して書けます。 getSomething 関数の場合は暗黙的に Any 型を返すと解釈されていることになります。

data class Foo(val name: String)
data class Bar(val description: String)

fun getSomething(): Any = if (flag) {
  Foo("foo")
} else {
  Bar("bar")
}

Any 型はあらゆる型の共通の親であるため、この定義では getSomething 関数はどんな型のオブジェクトでも返すことが可能ということになってしまい、getSomething 関数を呼び出すときには返り値の型を都度チェックしなければならなくなります。また関数の返り値が Foo 型や Bar 型以外にもなり得る定義となるため、返り値の型チェックの際には必ず Foo 型や Bar 型以外の場合も想定した分岐が必要になります。次の例では when で返り値の型チェックをパターンマッチで実装しています。

val something = getSomething()
when (something) {
  is Foo -> { /* something が Foo だったとき */ }
  is Bar -> { /* something が Bar だったとき */ }
  else -> error("unexpected value type!!!")
}

Foo 型と Bar 型それぞれに共通のインタフェースを実装させたり、共通の抽象クラスを継承させることも可能ですが、getSomething 関数の返り値の型が Any 型から共通のインタフェースや抽象クラスの型になるだけで返り値の型チェックが必要なことには変わりありませんし、型チェックの条件分岐に else が必要なことも変わりません。

Either を使って必ずいずれかの型になることを表す

Either 型とは2つの型のうちどちらかの型の値を持っていることを示す型です。

getSomething 関数においては返り値の型として Either 型を使うことで、返り値が必ず Foo 型か Bar 型のいずれかの型となることを示せます。返り値の型がどの型になるか限定できるため、getSomething 関数を呼び出すときにも Foo 型の場合と Bar 型の場合の2つのパターンを想定すれば網羅的に処理を分岐できます。

ただし、Kotlin においては Either 型は言語仕様にも標準ライブラリにも存在しないため、次の例はあくまで「このように書けたら嬉しいな」という想像のもとで記述しています。

// もしKotlinにEitherがあればこのように記述できるはず

fun getSomething(): Either<Foo, Bar> = // ...

val something = getSomething()

// 関数型プログラミングっぽく分岐する
// Foo の場合か Bar の場合の2つの関数の参照を渡すだけで漏れなく処理を分岐できる
something.fold(
  { foo: Foo ->
    // Foo型だったとき
  },
  { bar: Bar ->
    // Bar型だったとき
  },
)

// 手続き的に書いてみると、Either は必ず Left か Right のどれかなので
// else が不要となり、漏れなく処理を分岐できる
when (something) {
  is Either.Left -> { /* something が Foo 型のオブジェクトを保持しているとき */ }
  is Either.Right -> { /* something が Bar 型のオブジェクトを保持しているとき */ }
}

2023年3月現在、Either 型は [arrow-kt](https://arrow-kt.io/) というサードパーティのライブラリが実装しているので、arrow-kt を導入して Either 型を使うという手段で getSomething 関数を改善できます。

ただ、arrow-kt の導入には注意すべき点もあります。

それは Either 型を使いたいという理由だけで arrow-kt を導入するのはライブラリが巨大すぎるという点です。もともと arrow-kt は Kotlin において関数型プログラミングを可能にするためのライブラリであり、Either 以外にもたくさんの便利な型や関数が定義してあります。このため、ライブラリのデータサイズも大きくなり、結果として自身の成果物のサイズも大きくなります。関数型プログラミングをしたいならば問題ありませんが、今回のように異なる2つの型のいずれかの型を返り値とする関数を作りたい目的では arrow-kt は重厚長大になってしまいます。

sealed classes で Either を表現する

sealed classes とは Kotlin における特別な抽象クラス(あるいはインタフェース)で、その具象クラスや実装クラスの定義は必ず sealed classes の定義と同じファイル内にあることを制約としているクラスです。

普通の抽象クラスやインタフェースと異なり、sealed classes では抽象クラスの子クラスまたはインタフェースの実装クラスがすべてコンパイル時にわかるため、型チェックのパターンマッチの網羅性をコンパイラが保証してくれるようになります。先程までみてきた getSomething 関数の返り値の型チェックにおいては Foo 型か Bar 型のどちらかを想定したパターンマッチの記述だけでよくなります。次に sealed classes を使った例を示します。

// Something 型を定義した同一のファイル内にしか Something 型の子クラスの定義は存在しない
sealed class Something

data class Foo(val name: String) : Something()
data class Bar(val description: String) : Something()

// Something型の返り値は必ず Foo 型か Bar 型のどちらかになることがコンパイル時点でわかる
fun getSomething(): Something = // ...

// コンパイラが something の型のパターンを分かっているので else は不要
when (val something = getSomething()) {
  is Foo -> { /* something が Foo だったとき */ }
  is Bar -> { /* something が Bar だったとき */ }
}

ちなみに sealed classes の性質は列挙型(Enum)と似ていますが、列挙型は列挙ごとのインスタンスが必ず1つに限られる(シングルトン)一方、sealed classes の具象クラスのインスタンスは複数作成可能なところが列挙型と異なります。設計上シングルトンでも問題ないなら列挙型で定義できますが、シングルトンオブジェクトに状態を持たせるような構造を作るとマルチスレッドでのシングルトンオブジェクトの扱いが途端に難しくなるため、sealed classes と data class を組み合わせて不変オブジェクトとして定義するほうが扱い方が簡素化できます。

これで異なる2つの型のいずれかの型を返り値とする関数が Either 型を導入せずに定義できました。

sealed classes の性質をうまく使うことで、その関数を使うときに考慮すべきケースを Either 型を使った場合と同じく限定できるようになりました。sealed classes ならコンパイラが問題をエラーとして指摘してくれるので、プログラムを実行して動作確認をするよりも早い時点で条件分岐の網羅性が確認できる点が大きな利点です。

また余談ではありますが、 Either 型では値の取りうる型は必ず2つですが、sealed classes であれば 2 つ以上の型のパターンを作れます。

よくある議論

kotlin.Result ではだめなのか

関数の返り値として例外型または何らかの型を返すことを示す型として kotlin.Result 型があります。この型は関数で実行した処理の結果が正常であるか異常であるかを示すために使います。この型をあえて Either 型で書き表すとすると、Either<Throwable, String> と書き表せます。ちなみに、Either 型において正常時の型を右側の型パラメータにするのは、右を意味する英単語が Right であり同じ言葉で「正しい」という意味も持っているため、慣習的に正常時の型を右側の型パラメータにしています。

// 異常時は Throwable, 正常時は String を返す関数
// Either<Throwable, String> のパターンに特化した型として Result<String> と書ける
fun getSomething(): Result<String>
fun getSomething(): Either<Throwable, String>

kotlin.Result 型も異なる2つの型のいずれかの型を返り値とする関数を定義する上で有用な型ですが、特に正常な値と異常な値のいずれかを保持する用途に限られるため、getSomething 関数のように純粋に異なる2つの型のいずれかの型を返り値とする関数の型としては向かないこともあります。

Optional は使えないのか

kotlin.Result 型と似たような性質の型として Optional 型があります。

Optional 型は値があるかないかを示す型です。Kotlin には言語仕様として nullable type という仕様があり、nullable type でも値があるかないかを示せますが、Optional 型においては値がないことを示すオブジェクトを定義している(デザインパターンで言う Null Object)ため、null 安全性を確保したまま値がない場合の処理を記述できます。

Scala での Optional 型のようなコードを Kotlin で書いてみるとすると、次のようなコードが書けそうです。

sealed class Optional<T>
data class Some<T>(val value: T) : Optional<T>()
class None<T> : Optional<T>()

fun getSomething(): Optional<String> = // ...

when (val something = getSomething()) {
  is Some<String> -> {
    // 値があるとき: somethingはSome型にスマートキャストされvalueにアクセスできる
  }
  is None<String> -> {
    // 値がないとき
  }
}

上記の Optional はかなり抽象化した実装ですが、次の例のように sealed classes を使ってより具体的なケースに落とし込んでみると、nullable type を使わないほうが getSomething 関数の呼び出し後に考慮すべきことが減って null 安全なコードが書けます。

// === getSomething の返り値を nullable-type としたとき
sealed class Something {
  abstract fun execute()
}
data class Foo(val name: String) : Something() {
  override fun execute() { println(name) }
}

fun getSomething(): Something? = if (flag) {
  Foo("foo")
} else {
  null
}

val something = getSomething()
// null safe な扱いをしなければならない
something?.execute()

// === getSomething の返り値を non-null としたとき
sealed class Something {
  abstract fun execute()
}
data class Foo(val name: String) : Something() {
  override fun execute() { println(name) }
}
object Unknown : Something() {
  override fun execute() = Unit
}

fun getSomething(): Something = if (flag) {
  Foo("foo")
} else {
  Unknown
}

val something = getSomething()
// null になりえないので null のときの考慮はしなくてよい
something.execute()

kotlin.Result 型と同様に、 Optional型も異なる2つの型のいずれかの型を返り値とする関数を定義する上で有用な型です。特に値があるかないかを示したり、意味のある型と意味のない型を表すときに、null 安全性も確保したいと思うと有用なパターンです。

それでも Either が欲しくなるときは無いのか

例えば、次の例のようにライブラリ内部に定義してある型のどれかを返す関数を定義するような場面では sealed classes は使えないので、Either 型が欲しくなります。

// 別のライブラリにある定義
// このコードは自分では編集できないので sealed classes が使えない
data class Foo(
  // ...
)

data class Bar(
  // ...
)

// 自分のプロジェクトでの関数定義
// Either を使って Foo か Bar のいずれかを返す
fun getSomething(): Either<Foo, Bar> = // ...

ただこの場合でも、ライブラリのクラスをラップした別の実装を自分で用意すれば sealed classes のパターンに落とし込めます。ライブラリをラップした実装を作ることで、ライブラリのバージョンアップによる破壊的変更の影響箇所を限定できるようにもなります。

// 別のライブラリにある定義を自前でラップするコード
sealed class LibraryValueClass

data class WrappedFoo(
  val foo: Foo,
) : LibraryValueClass()

data class WrappedBar(
  val bar: Bar,
) : LibraryValueClass()

//.この方法なら sealed classes を使える
fun getSomething(): LibraryValueClass = // ...

おわりに

Either 型に代表されるようないわゆる union type の導入は Kotlin でも長く議論されているトピックです(関連 YouTrack)。言語仕様として union type を導入しようと思うと様々なケースを考慮したり、Kotlin の言語設計との兼ね合いがあったりして難しさも多いようです。そのため、しばらく union type の導入は見送られてきています。

今回は異なる2つの型のいずれかの型を返り値とする関数を作ることを例に、そのものズバリな型である Either 型の紹介とそれに代わる実現方法を解説しました。

もともとは「異なる2つの型のいずれかの型」を表現したいという要求から Either 型が使えないかと悩んだことがスタート地点でした。arrows-kt などのライブラリの導入でかんたんに Either 型を利用可能になりますが、ライブラリ導入の down side が大きいことがライブラリ導入の壁でした。すこし視点を変え Kotlin の言語仕様である sealed class でも「異なる2つの型のいずれかの型」が表せることに気付くと、コンパイラによって型チェックの網羅性が保証できたり、応用パターンとして Null Object パターンも実装できたり、より多くのパターンに対応でき得られる恩恵も多いことにも気付きました。

ギフトモールのAndroidアプリでは特に UI の状態を sealed classes で表現している箇所が多数あります。API の呼び出し前、呼び出し中、呼び出し後の状態をそれぞれ sealed classes を用いた型として表すことで、1 つのプロパティが今どの型なのかに対応して UI を出し分ける処理が記述できます。必ず 3 つの型のうち 1 つの型になるため、3 つの型以外のケースを考慮せずに済みます。このような実装は値の重ね合わせとして他のプロジェクトでも利用されています。いずれより詳しく、Jetpack Compose と組み合わせたときの事例を紹介する記事も書いてみようと思います。

今回のテーマはpotatotips #81の5分LTとして発表したものをまとめたものです。当日の発表資料は次のスライドをご覧ください。

https://speakerdeck.com/keithyokoma/either-in-kotlin


ギフトモールでは、一緒に働く仲間を募集しています!

まずは一度話してみるだけなどのカジュアル面談も歓迎ですので、少しでも興味を持った方はお気軽に連絡ください!

ギフトモールCulture Deck

speakerdeck.com

募集職種一覧

open.talentio.com