雑に描いた餅

餅は餅屋、わたしは技術屋、と言いたかった。

Arrowでfunctionalなエラーハンドリングを始める

はじめに

ArrowはKotlinの関数型プログラミングのためのライブラリです。 ScalaHaskellといったプログラミング言語で見られる関数型プログラミングで見られるデータ型(Either等)や、操作といった抽象をKotlinに持ち込んで、もっと純粋な関数型プログラミングしようぜ!というモチベーションから生まれたようです。

arrow-kt.io github.com

この記事では、その中でも使う機会が多いであろうエラーハンドリングについて、Arrowが提供するEither型を例として取り上げてみます。

動作環境

$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=22.04
DISTRIB_CODENAME=jammy
DISTRIB_DESCRIPTION="Ubuntu 22.04.3 LTS"

$ java --version
openjdk 17.0.7 2023-04-18
OpenJDK Runtime Environment Temurin-17.0.7+7 (build 17.0.7+7)
OpenJDK 64-Bit Server VM Temurin-17.0.7+7 (build 17.0.7+7, mixed mode, sharing)

$ /opt/idea-IC-231.9011.34/bin/idea.sh --version
CompileCommand: exclude com/intellij/openapi/vfs/impl/FilePartNodeRoot.trieDescend bool exclude = true
IntelliJ IDEA 2023.2.2 (Community Edition)
Build #IC-232.9921.47

また、pom.xmlには以下の依存性を追加しています。

    <dependencies>
        <dependency>
            <groupId>org.jetbrains.kotlin</groupId>
            <artifactId>kotlin-stdlib</artifactId>
            <version>1.9.0</version>
        </dependency>
        <dependency>
            <groupId>io.arrow-kt</groupId>
            <artifactId>arrow-core</artifactId>
            <version>1.2.0</version>
        </dependency>
    </dependencies>

Kotlinにおけるエラーハンドリング

そもそも、Kotlinではどのような方法でエラーハンドリングが実現されているのでしょうか。

try-catch文

まず思いつくのが、Javaと同様に、try-catch文を書くパターンです。

data class Denominator(val value: Int)
data class Numerator(val value: Int)

fun Denominator.divideWithTryCatch(numerator: Numerator): Int {
    try {
        return this.value / numerator.value
    } catch (e: ArithmeticException) {
        // some error handling logic
        // e.g.) log("You cannot pass 0 as numerator!")
        throw e
    }
}

ラフではありますが、渡されたnumeratorで割り算をした結果を返すInt型の拡張関数を例として考えます。

この関数は、numeratorを0として呼び出された場合に正しく動作しません。数学上、0で割り算を行うことはできないからです。そのような場合を想定して、上のコードでは、計算時に例外が発生した際にthrowされるArithmeticExceptionをcatchして、ハンドリングを行っています。

このようなハンドリングが物足りないと感じる点を一つ挙げると、「取り扱うドメイン内での失敗にあたる場合」「プログラムを実行する上で発生した例外にあたる場合」もまとめて、Exception型を継承したクラスで表現しなければならない部分にあると思います。Arrow内では、前者をlogical failure(ロジック上の失敗)、後者を(real)exceptions(例外)として区別しています。

さらにそもそもの話にはなりますが、Javaの世界にある検査例外は、Kotlinには存在しません。そのため、関数のシグネチャ上からどのような例外がthrowされるか判断することができませんし、検査例外がハンドルされていなくても、Javaのようにコンパイルエラーで気付くことができません*1

runCatchingとResult型

KotlinにはrunCatchingという関数が用意されています。これは渡されたblock式を実行して、結果をKotlinのResult型として返すものです。

data class Denominator(val value: Int)
data class Numerator(val value: Int)

fun Denominator.divideWithRunCatching(numerator: Numerator): Int {
    return runCatching { this.value / numerator.value } // Result<Int>
        .fold(
            // on success
            { n -> n },
            // on failure
            { e ->
                // some error handling logic
                throw e
            }
        )
}

先程実装したものと同じような拡張関数を作ってみました。

KotlinのResult型にはfold関数が拡張関数として用意されています。これはonSuccess, onFailure2つの関数をブロックとして渡すことができ、前者は成功時にResultに対して型Tを引数に取って何らかの型Rを返し、後者はThrowableを引数に取り同様に型Rを返すものです。

従来のtry-catchと比べると、メソッドチェーンを作ったり、より関数型に近い表現を行うことができます。しかし、現在KotlinのResult型では、失敗時にはThrowableを継承した型のみしか利用できない上、Resultというシグネチャ上では、成功時の型のみしかわからず、失敗時にどのような例外が返されるかは依然としてわからないままです。

Arrowでエラーハンドリングしてみよう

サンプルを書き直してみる

ではArrowを使うとどのようなエラーハンドリングを実現できるのでしょうか。Arrowが提供しているEither<E,A>型を使って先程のサンプルを書き直してみます。

import arrow.core.Either
import arrow.core.raise.either
import arrow.core.raise.ensure

data class Denominator(val value: Int)
data class Numerator(val value: Int)

object DividedByZero

fun Denominator.divideWithArrow(numerator: Numerator): Either<DividedByZero, Int> = either {
    ensure(numerator.value != 0) { DividedByZero }
    value / numerator.value
}

eitherのブロックは2つの引数を取ります。1つは失敗時、もう1つは成功時の型を要求しています。また、ensureはArrowの関数で、Predicate(真偽値を返す関数)と、遅延するEitherの失敗時の型(ここではDividedByZero)を引数として取ります。つまりpredicateで示す条件に合致しないときは、失敗時の型を返します。

このように、Either型を利用して、簡単に成功時と失敗時の型を関数のシグネチャとして示せるようになりました。失敗時の型も任意の型で表現できるようになったので、ドメイン上の失敗と、プログラム上の例外も区別しやすくなっています。

返されたEither型をハンドルする際には、whenを使って網羅的にカバーすることができます。

fun example() {
    val answer = Denominator(10).divideWithArrow(Numerator(0))
    when (answer) {
        is Either.Left -> {
            // some handling logic here!
            answer.value // DividedByZero
        }
        is Either.Right -> {
            answer.value // Int
        }
    }
}

Eitherの実践

ここまでであれば、sealed interfaceを利用して自前で実装することもできるレベルかと思います。ここからは、Eitherを利用してもっと色々とやってみましょう。

catchOrThrowを使って例外をより具体的にする

新しくファイルが作られた後に、そのファイルパスの中身を読み取る処理を考えてみましょう。

ファイル読み取り時の失敗はIOException、そのサブクラスによって表現されますが、ファイルが存在しなかった場合のみは「ファイルが作られていない状態」として定義できるとしましょう。

object FileNotCreated
fun readCreatedFile(filePath: String): Either<FileNotCreated, String> {
    return Either.catchOrThrow<IOException, String> {
        File(filePath).readBytes().toString(Charsets.UTF_8)
    }.mapLeft { e ->
        if (e is FileNotFoundException) FileNotCreated
        else throw e
    }
}

上のコードでは、FileNotFoundExceptionが発生したときにはFileが作られていなかったことを型で表現し、それ以外の例外の場合にはそのままrethrowするような実装をすることができます。

バリデーションの重ねがけをしていく

ユーザーを登録する際に、複数のバリデーション(重複チェックや無効な文字列が含まれていない等)をかけるケースを考えてみましょう。

登録に失敗するような問題が発生したら、UserRegisterProblemを実装したクラスを、そうでなければ登録されたユーザーを返したいとします。

sealed interface UserRegisterProblem {
    data class MailAddressAlreadyExists(val mailAddress: String): UserRegisterProblem
    data class InvalidMailAddress(val invalidMailAddress: String): UserRegisterProblem
    data class InvalidUserIdCharacter(val invalidUserId: String): UserRegisterProblem
}

data class UserRegisterRequest(val id: String, val mailAddress: String)
data class RegisteredUser(val id: String, val mailAddress: String)

fun UserRegisterRequest.invoke(): Either<UserRegisterProblem, RegisteredUser> = either {
    ensure(mailAddress.exists()) { UserRegisterProblem.MailAddressAlreadyExists(mailAddress) }
    ensure(mailAddress.isInvalid()) { UserRegisterProblem.InvalidMailAddress(mailAddress) }
    ensure(id.containsInvalidCharacter()) { UserRegisterProblem.InvalidUserIdCharacter(id) }
    RegisteredUser(id, mailAddress)
}

上のように、eitherブロックでは、複数のensure句を受け付けることができます。これによって重ねがけができるようになりました。

recoverで例外時のハンドリング処理を書く

Either型に生えている拡張関数では、失敗時のハンドリング処理を書くことができます。これは、引数にEitherの失敗時の型を取る関数を渡すものです。

これによって、業務上失敗が発生しても後続のフォールバック処理を進めたい、という場合に、正しくかつ簡潔にフォールバック処理を記述することができます。また、内部で渡された失敗の型を、よりドメインに即した型に変換して返すことも可能です。

object MoreDomainDetailedError : Error
fun handleUsingRecovery(denominator: Denominator, numerator: Numerator): Either<Error, Int> {
    return denominator.divideWithArrow(numerator)
        .recover { _: Error -> raise(MoreDomainDetailedError) }
}

さいごに

Arrowはこの他にもLensesやConcurrencyなど、様々な機能を提供しています。ぜひ気になった機能を触ってみてください。

今回記事中に登場したソースコードは以下のリポジトリで公開しています。

github.com


References

Working with typed errors | Arrow

Exceptions | Kotlin Documentation

Kotlinにおける型の世界と エラーハンドリング / Type World and Error Handling in Kotlin - Speaker Deck

*1:Kotlinの公式ドキュメントでは、Bruce Eckelのコメントを引用しつつ、Javaのような他の言語からの呼び出し時には、@Throwsアノテーションをつけてthrowされる例外を明記するように記載しています。こうすることで、Kotlinのコード内で検査例外をthrowしても、Javaのコード上から問題なく呼び出せるようです。