OSS: kotlin-result v2の実装がsealed classをやめてvalue classを使うようになっていたので読んでみる
はじめに
kotlin-resultは Rust、Elm、HaskellのResult型にインスパイアされたようなインターフェースを持つライブラリです。
ちょうど2年前のアドベントカレンダーではこのkotlin-resultの実装を読んで、Kotlinの理解を深めていくという記事を書きました。
実は今年の5月にv2.0.0がリリースされて内部の実装が大きく変わったので、このタイミングでまた内部実装を読んでいこうとおもいます。
kotlin-resultはシンプルでコンパクトな実装をしていて、OSSのコードに慣れていない方も読みやすいライブラリです。この記事で興味が出ればぜひ読んでみてください。
v2の主な変更点はタイトル通りResult型の実装でsealed classを使わずにvalue class(正式名はinline value classes)を使うようになったことです。2年前から色々変更点はあるかと思いますが、今回はここに絞って解説します。
v1までのkotlin-result
kotlin-resultは成功したときの値か、失敗したときの値かのどちらかを持つResult型を提供しています。
このResult型は例外とは違って返り値にエラー情報を明示することで可読性を上げたり、使用者にエラーハンドリングを強制させることができます。
v1まではこのResult型はsealed classを使って実装されていました。
実装を単純化すると以下のような実装になります。sealed classによりResultの型は常にOk型かErr型かの2択になります。
public sealed class Result<out V, out E> {
public abstract val value: V
public abstract val error: E
}
public class Ok<out V> internal constructor(
override val value: V,
) : Result<V, Nothing>()
public class Err<out E> internal constructor(
override val error: E,
) : Result<Nothing, E>()
Kotlinにおいていわゆる代数的データ型と呼ばれるような型(選択肢のように使えて、それぞれの型は別のデータ構造を持つ型)はsealed節を用いて実装するのが一般的です。
現にほぼ同じようなふるまいをもつArrow.ktのEither型はsealed classを用いて実装されています。
v1のkotlin-resultのResultもArrow.ktのEitherもsealed節を用いているのでコンパイル上、型判定が網羅的にできます。
val result: Result<Int, String> = Ok(1)
val doubledResult: Int = when (result) {
is Ok -> result.value * 2
is Err -> throw Exception(result.error)
// Ok型かErr型かに確定するのでelse節を書く必要がない
}
v2以降のkotlin-result
v1から大きく変わり、v2ではこのsealed classの実装ではなく内部的にはinline value classesをつかうようになりました。
変更点やコードの差分は全てこのPRにまとまっています。
これはOk型やErr型のクラスでラップすることによるオーバーヘッドを避けるためだそうです。
value classはコンパイル後にクラスでラップせず中身の値を直接扱うように最適化します。
詳しいデコンパイルの結果などは作者のこちらのページを見るとわかるかと思います。
Calls to Ok and Err do not create a new instance of the Ok/Err objects - instead these are top-level functions that return a type of Result. This achieves code that produces zero object allocations when on the "happy path", i.e. anything that returns an Ok(value).
詳しく実装を見ていきます。
@JvmInline
public value class Result<out V, out E> internal constructor(
private val inlineValue: Any?,
) {
@Suppress("UNCHECKED_CAST")
public val value: V
get() = inlineValue as V
@Suppress("UNCHECKED_CAST")
public val error: E
get() = (inlineValue as Failure<E>).error
public val isOk: Boolean
get() = inlineValue !is Failure<*>
public val isErr: Boolean
get() = inlineValue is Failure<*>
}
Result型はこのような実装になっています。まずsealed classではなくなっています。value classは継承することができないので、このResult型自体がインスタンス生成可能なクラスになっています。ただvalue classなので実際はResultクラスのインスタンスは生成されず、中身のinlineValueの値がそのまま使用されます。v2ではOk型やErr型も存在せず、コンストラクタのみ存在しているようです。
こうなるともう型による判定は行えず、以下のようにelseでマッチするしかなくなってしまします。
val result: Result<Int, String> = Ok(1)
// NG: もうOk型とErr型が存在しないためコンパイルエラー
when (result) {
is Ok -> println(result.value * 2)
is Err -> throw Exception(result.error)
}
代わりにmapBothという関数があり、これを使うとwhen式のようなことができます。
val result: Result<Int, String> = Ok(1)
// OK
val doubledResult: Int = result.mapBoth(
{ value -> value * 2 },
{ error -> throw Exception(error) },
)
ちなみに isOk という実装があり、これは以下のような実装をしています。
public value class Result<out V, out E> internal constructor(
private val inlineValue: Any?,
) {
public val isOk: Boolean
get() = inlineValue !is Failure<*>
}
この急に出てきたFailure型は何かというと内部的にエラーをラップしているprivateなクラスです。
private class Failure<out E>(
val error: E,
)
なぜこのようなことをしているかを解説します。Result型の内部の値はinlineValue: Any?しかないので成功か失敗かを判定するには型のチェックをするしかありません。しかしジェネリクスの型情報は実行時に消去されるためチェックができないため、Failureという型を作って型判定しています。
要するにこのFailure型は型判定のための型であるといえます。
逆にいうと失敗値に関してはFailure型で包んでしまっているためオーバーヘッドが発生しています。この辺りのオーバーヘッドについての議論はkotlin-resultの作者の方も認知しており、成功値に関してはオーバーヘッドがないが、失敗値に関しては依然としてオーバーヘッドがあるとのことです。
This Failure class is an internal implementation detail and not exposed to consumers. As a call to Err is usually a terminal state, occurring at the end of a chain, the allocation of a new object is unlikely to cause a lot of GC pressure unless a function that produces an Err is called in a tight loop.
sealed interface + value classでよくない?
色々コード読んでて、まだ自分の中で腑に落ちてないことがあります。それは sealed interface + value classの実装でよくないか?ということです。
sealed interface Result<out V, out E>
@JvmInline
value class Ok<out V>(val value: V) : Result<V, Nothing>
@JvmInline
value class Err<out E>(val error: E) : Result<Nothing, E>
val result: Result<Int, String> = Ok(1)
val doubledResult: Int = when (result) {
is Ok -> result.value * 2
is Err -> throw Exception(result.error)
}
たしかにvalue classはsealed classを継承することができないのでsealed classをsealed interfaceに変える必要があります。しかし実装を見ていてsealed interfaceで動かなくなるようなものがなく、かつ具象クラスであるOkとErr型がvalue classであるならばインスタンス化に関するオーバーヘッドもないように思えます。また、上述の通りv2の実装では失敗時にFailure型で包んでしまっているオーバーヘッドがあるためその点については優れているように思えます。
sealed classの実装をsealed interfaceに変えるのは互換性がなくなってしまうという点もありますが、value classへの変更に比べるとそんな差はないのではとも感じます。
最適化の結果が微妙に変わってもしかしたら遅いのではないかと思って下記のように自作ResultにmapやmapErrorをはやして、一連の処理を1000万回実行してみたところ以下のような結果になりました。
(誤差あると思うので1000万回の処理をそれぞれ100回試して平均とりました。)
パフォーマンステスト(雑なので誰かに怒られそう)
sealed interface + value classバージョンをResult2にリネームしてmap, mapError, andThenを実装
sealed interface Result2<out V, out E>
inline fun <V, E, U> Result2<V, E>.map(transform: (V) -> U): Result2<U, E> = when (this) {
is Ok2 -> Ok2(transform(value))
is Err2 -> this
}
inline fun <V, E, F> Result2<V, E>.mapError(transform: (E) -> F): Result2<V, F> = when (this) {
is Ok2 -> this
is Err2 -> Err2(transform(error))
}
inline fun <V, E, U> Result2<V, E>.andThen(transform: (V) -> Result2<U, E>): Result2<U, E> {
return when (this) {
is Ok2 -> transform(this.value)
is Err2 -> this
}
}
@JvmInline
value class Ok2<out V> constructor( val value: V) : Result2<V, Nothing>
@JvmInline
value class Err2<out E>(val error: E) : Result2<Nothing, E>
class PerformanceTest: FunSpec({
test("performance test") {
// kotlin-result
val time1 = measureAverageTimeOf100 {
(0 until max).map {
val ok: Result<Int, String> = Ok(it)
.map { it + 1 }
.mapError { "1_$it" }
.andThen { Ok(it + 1) }
ok
}
}
// sealed interface + value class
val time2 = measureAverageTimeOf100 {
(0 until max).map {
val ok: Result2<Int, String> = Ok2(it)
.map { it + 1 }
.mapError { "1_$it" }
.andThen { Ok2(it + 1) }
ok
}
}
println("kotlin-result time" + time1)
println("sealed interface + value class time" + time2)
}
})
val max = 10_000_000
fun measureAverageTimeOf100(f: () -> Unit): Duration {
val count = 100
val times = (0 until count).map {
measureTime(f)
}
return times.reduce { acc, measureDuration -> acc + measureDuration } / count
}
kotlin-result time255.785166ms
sealed interface + value class time209.397330ms
失敗値もvalue classにしているので若干sealed interface + value classの方が速いようです。
もしかしたらsealed interfaceにすることでJavaからの呼び出しに使いづらくなるかなとも思ったんですが、それも特にありませんでした。
(そもそもkotlin-resultは拡張関数でメソッドを実装しているのであまりJavaフレンドリーに書かれていない。)
public class JavaPlayground {
public static void main(String[] args) {
@NotNull Result2<Integer, String> ok = Ok2.Companion.of(1);
Result2Kt.map(ok, (value) -> {
System.out.println(value);
return value;
});
}
}
有識者の方、もし理由を知っていたら教えて欲しいです!
ということで今回のkotlin-result v2の実装を読んでみようの記事はここで終わらせていただきたいと思います。最後の自分の疑問になにかしらの情報や意見をお持ちの方はXかこの記事のコメントまでご連絡いただけると嬉しいです!良いクリスマスを!
Discussion
Result
そのものがvalue class
でなければバイトコード上でのインライン化はされない =Ok
やErr
だけvalue class
化しても意味がないということかと。これに関しては↓みたいなコードで確認すると分かりやすいです(
Intellij
のデコンパイルは何故か通らず、しょうがないのでリフレクションしてます、、、)。sealed interface
+value class
だと、インライン化されていない[${Resultを定義したパッケージ名}.Result<?, ?> result]
が表示されます。一方、紹介されている実装ではインライン化された
[java.lang.Object result]
が表示されます。ありがとうございます。親自体がvalu claasになっていないと最適化は走らなないのですね。
今はすぐに検証できないですが、自分のコードデコンパイルして確かめてみます。
確かに子クラスがvalue classだったとしてもコンパイルするときは親の情報しかないので最適化は走らないということですね。もちろんOk型で引数を取れば最適化は走ると思いますが。勉強になりました。ありがとうございます。