🐥

OSS: kotlin-result v2の実装がsealed classをやめてvalue classを使うようになっていたので読んでみる

2024/12/13に公開3

OSS: kotlin-result v2の実装がsealed classをやめてvalue classを使うようになっていたので読む

はじめに

kotlin-resultは Rust、Elm、HaskellのResult型にインスパイアされたようなインターフェースを持つライブラリです。
https://github.com/michaelbull/kotlin-result

ちょうど2年前のアドベントカレンダーではこのkotlin-resultの実装を読んで、Kotlinの理解を深めていくという記事を書きました。

https://zenn.dev/loglass/articles/05fc3cb3c2ed4e

実は今年の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を使って実装されていました。

https://github.com/michaelbull/kotlin-result/blob/3cde5ccdcb378e083f028acc8a03a2568e0b1eee/kotlin-result/src/commonMain/kotlin/com/github/michaelbull/result/Result.kt#L44

実装を単純化すると以下のような実装になります。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を用いて実装されています。

https://github.com/arrow-kt/arrow/blob/65f817c8f1df4e9e9edc4045d88d67d46e3ff83a/arrow-libs/core/arrow-core/src/commonMain/kotlin/arrow/core/Either.kt#L485

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をつかうようになりました。

https://github.com/michaelbull/kotlin-result/blob/5623765ba13c6c1b9e033f8e33ab6a098811bb81/kotlin-result/src/commonMain/kotlin/com/github/michaelbull/result/Result.kt#L50

変更点やコードの差分は全てこのPRにまとまっています。
https://github.com/michaelbull/kotlin-result/pull/99

これは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).

https://github.com/michaelbull/kotlin-result/wiki/Overhead

詳しく実装を見ていきます。

@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型も存在せず、コンストラクタのみ存在しているようです。

https://github.com/michaelbull/kotlin-result/blob/aa0c0ac53ac89fadc157d29a41ca29edf8bf39e2/kotlin-result/src/commonMain/kotlin/com/github/michaelbull/result/Result.kt#L9

https://github.com/michaelbull/kotlin-result/blob/aa0c0ac53ac89fadc157d29a41ca29edf8bf39e2/kotlin-result/src/commonMain/kotlin/com/github/michaelbull/result/Result.kt#L17

こうなるともう型による判定は行えず、以下のように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なクラスです。

https://github.com/michaelbull/kotlin-result/blob/5623765ba13c6c1b9e033f8e33ab6a098811bb81/kotlin-result/src/commonMain/kotlin/com/github/michaelbull/result/Result.kt#L90

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.

https://github.com/michaelbull/kotlin-result/wiki/Overhead

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かこの記事のコメントまでご連絡いただけると嬉しいです!良いクリスマスを!

https://x.com/Yuiiitoto

株式会社ログラス テックブログ

Discussion

wrongwrongwrongwrong

sealed interface + value classでよくない?

Resultそのものがvalue classでなければバイトコード上でのインライン化はされない = OkErrだけvalue class化しても意味がないということかと。
これに関しては↓みたいなコードで確認すると分かりやすいです(Intellijのデコンパイルは何故か通らず、しょうがないのでリフレクションしてます、、、)。

import kotlin.reflect.jvm.javaMethod

fun foo(result: Result<*, *>) {}

fun main() {
    ::foo.javaMethod!!.parameters.asList().let { println(it) }
}

sealed interface + value classだと、インライン化されていない[${Resultを定義したパッケージ名}.Result<?, ?> result]が表示されます。
一方、紹介されている実装ではインライン化された[java.lang.Object result]が表示されます。

Yuito SatoYuito Sato

ありがとうございます。親自体がvalu claasになっていないと最適化は走らなないのですね。
今はすぐに検証できないですが、自分のコードデコンパイルして確かめてみます。

Yuito SatoYuito Sato

確かに子クラスがvalue classだったとしてもコンパイルするときは親の情報しかないので最適化は走らないということですね。もちろんOk型で引数を取れば最適化は走ると思いますが。勉強になりました。ありがとうございます。