↔️

Kotlin に Either を導入するためにライブラリの Arrow と kotlin-result を使った実装を比較

2023/08/18に公開

はじめに

2023 年 8 月現在、Kotlin のコードに Either の概念を導入したいと思った場合、下記の 2 つのライブラリが主な選択肢に挙がると思います。

ここでは、それぞれのライブラリを使用して簡単なコードを書くことで使用感を比較します。
この記事では Either 自体についてや、Either を導入したいモチベーションなどについては言及せず、ライブラリの使用感を比較することを目的とします。

共通して使用する実装

下記のようなコードでそれぞれのライブラリを使用したコードを呼び出して結果を出力します。

fun main() {
    listOf(
        listOf("Alice", "1980-01-01"),
        listOf("Bob", "1975-01-01")
    )
        .also {
            it
                .map { 人物_Arrow版(名前_Arrow版(it[0]), 生年月日_Arrow版(it[1])) }
                .let { 人物一覧_Arrow版(it) }
                .let(::println)
        }
        .also {
            it
                .map { 人物_kotlin_result版(名前_kotlin_result版(it[0]), 生年月日_kotlin_result版(it[1])) }
                .let { 人物一覧_kotlin_result版(it) }
                .let(::println)
        }

    listOf(
        listOf("", "1980/01/01"),
        listOf("😀", "1975.01.01")
    )
        .also {
            it
                .map { 人物_Arrow版(名前_Arrow版(it[0]), 生年月日_Arrow版(it[1])) }
                .let { 人物一覧_Arrow版(it) }
                .let(::println)
        }
        .also {
            it
                .map { 人物_kotlin_result版(名前_kotlin_result版(it[0]), 生年月日_kotlin_result版(it[1])) }
                .let { 人物一覧_kotlin_result版(it) }
                .let(::println)
        }
}

簡単な条件判定のために下記のような処理を共通で使用します。

import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException

fun isValidDateFormat(date: String): Boolean {
    val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")

    return try {
        LocalDate.parse(date, formatter)
        true
    } catch (e: DateTimeParseException) {
        false
    }
}

fun isLatinAlphabet(input: String): Boolean {
    return input.matches(Regex("^[a-zA-Z]+$"))
}

出力結果は下記のようになります。

Either.Right(name: 名前_Arrow版(value=Alice), birthdate: 生年月日_Arrow版(value=1980-01-01), name: 名前_Arrow版(value=Bob), birthdate: 生年月日_Arrow版(value=1975-01-01))
Ok(name: 名前_kotlin_result版(value=Alice), birthdate: 生年月日_kotlin_result版(value=1980-01-01), name: 名前_kotlin_result版(value=Bob), birthdate: 生年月日_kotlin_result版(value=1975-01-01))
Either.Left(1 行目: 名前が不正です, 1 行目: 生年月日が不正です, 2 行目: 名前が不正です, 2 行目: 生年月日が不正です)
Err(1 行目: 名前が不正です, 1 行目: 生年月日が不正です, 2 行目: 名前が不正です, 2 行目: 生年月日が不正です)

Arrow を使用した実装

import arrow.core.Either
import arrow.core.NonEmptyList
import arrow.core.flatten
import arrow.core.left
import arrow.core.mapOrAccumulate
import arrow.core.raise.either
import arrow.core.raise.zipOrAccumulate
import arrow.core.right

class 人物一覧_Arrow版 private constructor(
    val personList: List<人物_Arrow版>
) {
    override fun toString() = personList.joinToString { it.toString() }

    companion object {
        operator fun invoke(
            personList: List<Either<NonEmptyList<String>, 人物_Arrow版>>
        ): Either<String, 人物一覧_Arrow版> =
            personList
                .mapOrAccumulate { it.bind() }
                .fold(
                    {
                        it
                            .mapIndexed { index, errors -> errors.map { "${index + 1} 行目: $it" } }
                            .flatten()
                            .joinToString()
                            .left()
                    },
                    { 人物一覧_Arrow版(it).right() }
                )
    }
}

class 人物_Arrow版 private constructor(
    val name: 名前_Arrow版,
    val birthdate: 生年月日_Arrow版,
) {
    override fun toString() = "name: $name, birthdate: $birthdate"

    companion object {
        operator fun invoke(
            name: Either<String, 名前_Arrow版>,
            birthdate: Either<String, 生年月日_Arrow版>
        ): Either<NonEmptyList<String>, 人物_Arrow版> = either {
            zipOrAccumulate(
                { name.bind() },
                { birthdate.bind() }
            ) { validName, validBirthdate -> 人物_Arrow版(validName, validBirthdate) }
        }
    }
}

@JvmInline
value class 名前_Arrow版 private constructor(private val value: String) {
    companion object {
        operator fun invoke(value: String): Either<String, 名前_Arrow版> =
            if (value.isNotEmpty()) {
                if (isLatinAlphabet(value)) {
                    名前_Arrow版(value).right()
                } else {
                    "名前が不正です".left()
                }
            } else {
                "名前が不正です".left()
            }
    }
}

@JvmInline
value class 生年月日_Arrow版 private constructor(private val value: String) {
    companion object {
        operator fun invoke(value: String): Either<String, 生年月日_Arrow版> =
            if (isValidDateFormat(value)) {
                生年月日_Arrow版(value).right()
            } else {
                "生年月日が不正です".left()
            }
    }
}

kotlin-result を使用した実装

import com.github.michaelbull.result.Err
import com.github.michaelbull.result.Ok
import com.github.michaelbull.result.Result
import com.github.michaelbull.result.get
import com.github.michaelbull.result.getAllErrors
import com.github.michaelbull.result.getError

class 人物一覧_kotlin_result版 private constructor(
    val personList: List<人物_kotlin_result版>
) {
    override fun toString() = personList.joinToString { it.toString() }

    companion object {
        operator fun invoke(
            personList: List<Result<人物_kotlin_result版, List<String>>>
        ): Result<人物一覧_kotlin_result版, String> =
            when (val errors = personList.mapNotNull { it.getError() }) {
                emptyList<String>() -> Ok(人物一覧_kotlin_result版(personList.map { it.get().let(::checkNotNull) }))
                else -> Err(
                    errors
                        .mapIndexed { index, errors -> errors.map { "${index + 1} 行目: $it" } }
                        .flatten()
                        .joinToString()
                )
            }
    }
}

class 人物_kotlin_result版 private constructor(
    val name: 名前_kotlin_result版,
    val birthdate: 生年月日_kotlin_result版,
) {
    override fun toString() = "name: $name, birthdate: $birthdate"

    companion object {
        operator fun invoke(
            name: Result<名前_kotlin_result版, String>,
            birthdate: Result<生年月日_kotlin_result版, String>
        ): Result<人物_kotlin_result版, List<String>> =
            when (val errors = getAllErrors(
                name,
                birthdate
            )) {
                emptyList<String>() -> Ok(
                    人物_kotlin_result版(
                        name.get().let(::checkNotNull),
                        birthdate.get().let(::checkNotNull)
                    )
                )

                else -> Err(errors)
            }
    }
}

@JvmInline
value class 名前_kotlin_result版 private constructor(private val value: String) {
    companion object {
        operator fun invoke(value: String): Result<名前_kotlin_result版, String> =
            if (value.isNotEmpty()) {
                if (isLatinAlphabet(value)) {
                    Ok(名前_kotlin_result版(value))
                } else {
                    Err("名前が不正です")
                }
            } else {
                Err("名前が不正です")
            }
    }
}

@JvmInline
value class 生年月日_kotlin_result版 private constructor(private val value: String) {
    companion object {
        operator fun invoke(value: String): Result<生年月日_kotlin_result版, String> =
            if (isValidDateFormat(value)) {
                Ok(生年月日_kotlin_result版(value))
            } else {
                Err("生年月日が不正です")
            }
    }
}

それぞれのコードを比較した所感

当たり前の感想ですが、Arrow の方は関数型プログラミングっぽい感じになり、kotlin-result の方は Rust の Result を持ってきた感じになって、ライブラリの思想がそれぞれ出ているなと思いました。
Arrow の方がいろいろなものが用意されていて若干スッキリ書けるなと思いましたが、Arrow を入れると関数型プログラミングの方向に寄っていくので、実際に導入する場合はチームの合意が大事だろうなと思いました。
kotlin-result は、今の Kotlin に Either だけ欲しい、という場合にちょうどいい選択肢かも知れないと思いました。
たまたま Kotlin の勉強会に参加した際に Either について雑談する機会があり、そこで kotlin-result について「Either 自体は便利な概念で、ライブラリが機能を持っていないことで限定的に導入できる、便利な関数は足りないけど 10 行くらい手で書けば対応できるので問題ない」という話を聞いて納得感があったのですが、実際に書いてみて正にそんな感じだなと思いました。

おわりに

実際に簡単なコードを書いて比較してみました。
小さいコードを書くことで基本的な使い方から学ぶことができてよかったです。

ROUTE06 Tech Blog

Discussion