はじめに
ArrowはKotlinの関数型プログラミングのためのライブラリです。 ScalaやHaskellといったプログラミング言語で見られる関数型プログラミングで見られるデータ型(Either等)や、操作といった抽象をKotlinに持ち込んで、もっと純粋な関数型プログラミングしようぜ!というモチベーションから生まれたようです。
この記事では、その中でも使う機会が多いであろうエラーハンドリングについて、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
従来の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など、様々な機能を提供しています。ぜひ気になった機能を触ってみてください。
今回記事中に登場したソースコードは以下のリポジトリで公開しています。
References
Working with typed errors | Arrow
Exceptions | Kotlin Documentation
Kotlinにおける型の世界と エラーハンドリング / Type World and Error Handling in Kotlin - Speaker Deck