【具体例記載】Arrow-ktを用いたKotlinのエラー処理のステップを公開
この記事では、関数型プログラミングライブラリのArrow-ktを用いたKotlin
のエラー処理について紹介します。
基本から応用まで、コードサンプルを用いて解説していきます。
Kotlinとは:
元々Java仮想マシン(JVM)で動くように設計された静的プログラミング言語で、 LLVMテクノロジを使用してJavaScriptおよびネイティブバイナリにコンパイルされます。
最新の簡潔で安全な構文を備えており、オブジェクト指向プログラミング(OOP)と関数型プログラミング(FP)の両方をサポートしています。
Arrow-ktとは:
Kotlinライブラリを介したインターフェイスと抽象化の共通言語を提供する関数型プログラミングライブラリです。
Option、 Either、Validatedなどの最も一般的なデータ型が含まれ、ユーザーは traverse や computation blocks などの「機能演算子」を使用して純粋なFPアプリケーションやライブラリを簡単に作成することができます。
目次
Kotlinのエラー処理の事前準備
root project の build.gradle.kts ファイルにて mavenCentral を追加する:
allprojects {
repositories {
mavenCentral()
}
}
project の build.gradle.kts ファイルにdependencyを追加する:
val arrow_version = "1.0.1"
dependencies {
implementation("io.arrow-kt:arrow-core:$arrow_version")
}
KotlinにおけるPurefunction(純粋関数)とExceptions (例外)
Purefunction(純粋関数)
関数型プログラミングにおける pure function は2つの特性を持つ関数です。
- 送るパラメータにより返値は一意です(入力が同じ場合、出力は必ず同じになる)
- 副作用(side effect)が発生しない
Side effectとは:
主な用途ではない関数を実行したときに発生する影響を指します。
例えば戻り値に加えて、環境の変更、環境変数の変更、HTTP要求などのI / O操作の実行、コンソールへのデータの印刷、ファイルの読み取りと書き込みなどの相互作用等が挙げられます。
Kotlinにおいては、 Unit を返す関数によってSide effectが発生します。
Unitの戻り値が「値を返さない」ことを意味するため、Side effectのみを発生させることになります。
Kotlinの純粋関数の例としては、数学の関数が挙げられます: sin, cos, max, …
純粋関数のメリットは関数の結合、テスト、デバグや並列化などがしやすくなる点です。したがって、関数型プログラミングではできるだけ多くの純粋関数を使用し、純粋部分と不純な部分(PureとImpure)を分離するようにします。
Exceptions(例外処理)
Kotlin では Java JavaScript, C++などと同様に、異常が起こった際に例外を throw し、その例外を catch して処理する事ができます。
try {} catch(e){} finally {}を使用することは、 命令型プログラミング の一般的なエラー処理です。
しかし、例外をキャッチすることは副作用にあたるため、例外をスローしてキャッチすることで関数が純粋でなくなってしまいます。
例えば、別の関数から2つの例外ex1とex2をキャッチして結果を計算する関数の場合、その結果はステートメントの実行順序に依存するため、同じシステムでも実行の度に結果が異なるかもしれません。
Partial function(部分関数)
また、例外をスローする場合、その関数は部分関数( Partial function)になります。
これは一部の入力値に対して(無限ループ等の例外処理が発生するなどして)値を返さないことがあるためです。(逆に、全ての入力に対して対応する値が1つに定まる関数のことを全域関数と呼びます)
例えば: 下記の findUserById は部分関数です。
@JvmInline
value class UserId(val value: String)
@JvmInline
value class Username(val value: String)
@JvmInline
value class PostId(val value: String)
data class User(
val id: UserId,
val username: Username,
val postIds: List<PostId>
)
class UserException(message: String?, cause: Throwable?) : Exception(message, cause)
/**
* @return an [User] if found or `null` otherwise.
* @throws UserException if there is any error (eg. database error,
connection error, ...)
*/
suspend fun findUserById(id: UserId): User? = TODO()
findUserByIdを全域関数にするために、UserExceptionをスローする代わりに、それを値として返し、findUserByIdの戻りタイプをUserResultに変更することができます。
sealed interface UserResult {
data class Success(val user: User?) : UserResult
data class Failure(val error: UserException) : UserResult
}
suspend fun findUserById(id: UserId): UserResult = TODO()
例外処理の課題
例外処理にはいくつかの課題があることが分かっています。
例外処理は呼び出された場所に戻ることによってプログラムフローを中断するため、C /C++と同様にGOTOステートメントと考えることができます。
しかし例外処理には一貫性がありません。
特にマルチスレッドプログラミングでは関数をtry … catchしますが、キャッチできない別のThreadで例外がスローされます。
次に、Exceptionの乱用が挙げられます。
必要以上にキャッチしたり、 VirtualMachineError、 OutOfMemoryError、などのシステムからの例外をキャッチしているケースです。
try {
doExceptionalStuff() //throws IllegalArgumentException
} catch (e: Throwable) {
// too broad, `Throwable` matches a set of fatal exceptions and errors
// a user may be unable to recover from:
/*
VirtualMachineError
OutOfMemoryError
ThreadDeath
LinkageError
InterruptedException
ControlThrowable
NotImplementedError
*/
}
最後に、(ドキュメントかソースコードを読まない限り)どの例外がスローされるかわからないという課題が挙げられます。
その関数で発生する可能性のあるエラーをシグネチャに明示する必要があります。
以上のことから、エラー処理には一緒に構成でき、有効な結果またはエラーを表す型が必要であると言えます。
これらのタイプは、 タグ付き共用体(英文)と呼ばれ、Kotlinでは sealed class / sealed interface/enumclassを介して実装されます。
それではkotlin.Result(バージョン1.3以降のKotlinSdtlibで提供)とarrow.core.Either(Arrow-ktライブラリで提供)を見ていきましょう。
kotlin.Result を使ったエラー処理
Result <T>をタイプとして使用して、タイプ Tでの成功、またはThrowableでのエラーのいずれかを表すことができます。 (Result<T> = T | Throwable)
Resultの値を生成するには、次のような組み込み関数を使用できます。
- Result.success
- Result.failure
- runCatching (try {} catch {}と同様ですが、 Resultを返します)
suspend fun findUserByIdFromDb(id: String): UserDb? = TODO()
fun UserDb.toUser(): User = TODO()
suspend fun findUserById(id: UserId): Result<User?> = runCatching { findUserByIdFromDb(id.value)?.toUser() }
isSuccessとisFailureの2つのプロパティを使用して、Resultが成功値であるかどうかを確認できます。
onSuccessおよびonFailure関数を介してResult の各パターンに対してアクションを実行します。
val userResult: Result<User?> = findUserById(UserId("#id"))
userResult.isSuccess
userResult.isFailure
userResult.onSuccess { u: User? -> println(u) }
userResult.onFailure { e: Throwable -> println(e) }
Result内の値を取得できるようにするために、getOr__関数を使用します。 Resultが失敗値を表す場合は、exceptionOrNullを使用して内部のThrowable値にアクセスします。
さらに、どちらの場合も簡単に処理できるfold関数があります。
val userResult: Result<User?> = findUserById(UserId("#id"))
// Access value
userResult.getOrNull()
userResult.getOrThrow()
userResult.getOrDefault(defaultValue)
userResult.getOrElse { e: Throwable -> defaultValue(e) }
// Access Throwable
userResult.exceptionOrNull()
fun handleUser(u: User?) {}
fun handleError(e: Throwable) {
when (e) {
is UserException -> {
// handle UserException
}
else -> {
// handle other cases
}
}
}
userResult.fold(
onSuccess = { handleUser(it) },
onFailure = { handleError(it) }
)
ただし、 Resultの真の力は、その操作を連鎖させることにあります。
たとえば、 Userのプロパティにアクセスする場合は以下のように記述します。
val userResult: Result<User?> = findUserById(UserId("#id"))
val usernameNullableResult: Result<Username?> = userResult.map { it?.username }
map関数へのラムダを呼び出した際にスローされる例外は、外にスローされることに注意してください。 その例外をキャッチしてResult値に変換する場合は、mapCatchingをmapとcatchingの両方に使用します。
val usernameResult: Result<Username> = userResult.map {
checkNotNull(it?.username) { "user is null!" }
}
次に、相互に依存するResultを連鎖させます。
// (UserId) -> Result<User?>
suspend fun findUserById(id: UserId): Result<User?> = TODO()
// User -> List<Post>
suspend fun getPostsByUser(user: User): Result<List<Post>> = TODO()
// List<Post> -> Result<Unit>
suspend fun doSomethingWithPosts(posts: List<Post>): Result<Unit> = TODO()
flatMap関数(mapとflatten)が生成されます。
// Map and flatten
inline fun <T, R> Result<T>.flatMap(transform: (T) -> Result<R>): Result<R> = mapCatching { transform(it).getOrThrow() }
// or
inline fun <T, R> Result<T>.flatMap(transform: (T) -> Result<R>): Result<R> = map(transform).flatten()
inline fun <T> Result<Result<T>>.flatten(): Result<T> = getOrElse { Result.failure(it) }
flatMapを使用することで、Resultを連鎖させることができます。
val unitResult: Result<Unit> = findUserById(UserId("#id"))
.flatMap { user: User? -> runCatching { checkNotNull(user) { "user is null!" } } }
.flatMap { getPostsByUser(it) }
.flatMap { doSomethingWithPosts(it) }
Arrow-ktライブラリは、結果の連鎖を処理でき、ネストされたflatMapの乱用を回避するresult {…}ブロックも提供しています。
result {…}ブロックでbind()関数を使用して、Result<T>からTの値を取得します。 左辺を含むEitherでbindが呼び出された場合、以下の result {…}ブロック内の処理は無視されます。(短絡評価の仕組み)
import arrow.core.*
val unitResult: Result<Unit> = result { /*this: ResultEffect*/
val userNullable: User? = findUserById(UserId("#id")).bind()
val user: User = checkNotNull(userNullable) { "user is null!" }
val posts: List<Post> = getPostsByUser(user).bind()
doSomethingWithPosts(posts).bind()
}
別のケースでは、エラーの回復やレポートを含む複雑なエラー処理戦略が必要になります。
たとえば、リモートサーバーからデータをフェッチします。エラーが発生した場合は、キャッシュからデータを取得します。 recoverとrecoverCatchingの2つの関数を使用できます。
class MyData(...)
fun getFromRemote(): MyData = TODO()
fun getFromCache(): MyData = TODO()
val result: Result<MyData> = runCatching { getFromRemote() }
.recoverCatching { e: Throwable ->
logger.error(e, "getFromRemote")
getFromCache()
}
Resultの利用は悪くないアプローチですが、エラーが常にThrowableである点が問題となります。
ドキュメントを読むか、そのソースコードを読む必要があります。もう1つの問題は、runCatchingとsuspend関数を併用すると、kotlinx.coroutines.CancellationExceptionを含む全てのThrowableをキャッチしてしまうことです。
CancellationException は特別な例外であり、cooperative cancellation を確実にするためにコルーチンによって使用されます( issues 1814 Kotlin/kotlinx.coroutines参照).
そこで、resultの欠点を克服するarrow.core.Eitherの利用をおすすめします。
arrow.core.Either を使ったエラー処理
表現するタイプとしてEither<L、R>を使用できます。
これはLeft(value:L)の値または Right(value:R)の値を返します。(Either <L、R> = L | R)
public sealed class Either<out A, out B> {
public data class Left<out A> constructor(val value: A) : Either<A, Nothing>()
public data class Right<out B> constructor(val value: B) : Either<Nothing, B>()
}
Leftはエラー値、予期しない値を表し、Rightは成功した値、望ましい値を表します。 一般に、 Either <L、R>はResult <T>に似ており、 Result <T>はエラー値のタイプではなく、成功値のタイプにのみ焦点を当てています。
もしくはResult<R> ~= Either<Throwable, R>を検討することもできます。Eitherはright-biasedです。つまり、 map、 filter、 flatMapなどのように右辺を処理し、左辺は無視されます(処理なしで返される)。
Either値を生成するには、次のような既存の関数を使用できます。
- Left constructor 使用例: val e: Either<Int, Nothing> = Left(1)
- Right constructor 使用例: val e: Either<Nothing, Int> = Right(1)
- left extension function 使用例: val e: Either<Int, Nothing> = 1.left().
- right extension function 使用例: val e: Either<Nothing, Int> = 1.right().
- Either.catch関数は例外をキャッチしますが、kotlinx.coroutines.CancellationException、 VirtualMachineError、 OutOfMemoryError、のような致命的な例外を無視します。
この他にも多くのメソッドがarrow.core.Either.Companionによって提供されます。
suspend fun findUserByIdFromDb(id: String): UserDb? = TODO()
fun UserDb.toUser(): User = TODO()
fun Throwable.toUserException(): UserException = TODO()
suspend fun findUserById(id: UserId): Either<UserException, User?>
Either
.catch { findUserByIdFromDb(id.value)?.toUser() } // Either<Throwable, User?>
.mapLeft { it.toUserException() } // Either<UserException, User?>
isLeft()と isLeft()の2つの関数を使用して、EitherがLeft であるかRightであるかということを確認できます。
Eitherは、tap(ResultのonSucessに類似)と tapLeft(ResultのonFailureに類似)の2つの関数も提供します。
val result: Either<UserException, User?> = findUserById(UserId("#id"))
result.isLeft()
result.isRight()
result.tap { u: User? -> println(u) }
result.tapLeft { e: UserException -> println(e) }
Resultと同様に、 getOrElse、 orNull、 getOrHandleの関数を使用して、Rightである場合はRightが含まれる値を取得します。
より便利な関数には、 fold、 bimap、 mapError、 filter、..があります。
val result: Either<UserException, User?> = findUserById(UserId("#id"))
// Access value
result.getOrElse { defaultValue }
result.orNull()
result.getOrHandle { e: UserException -> defaultValue(e) }
fun handleUser(u: User?) {}
fun handleError(e: UserException) {
// handle UserException
}
result.fold(
ifRight = { handleUser(it) },
ifLeft = { handleError(it) }
)
Resultを使用する場合の例と同様に、複数のEither値を連鎖させる必要もあります。
// (UserId) -> Either<UserException, User?>
suspend fun findUserById(id: UserId): Either<UserException, User?> = TODO()
// User -> Either<UserException, List<Post>>
suspend fun getPostsByUser(user: User): Either<UserException, List<Post>> = TODO()
// List<Post> -> Either<UserException, Unit>
suspend fun doSomethingWithPosts(posts: List<Post>): Either<UserException, Unit> = TODO()
Arrow-ktライブラリには、flatMap関数と either { … }ブロックがすでに用意されており、Eitherを簡単に連鎖させることができます。
either {…}ブロックで、 bind()関数を使用して、 Either <L、R>からRの値を取得します。 左辺を含むEitherでbindが呼び出された場合、以下の result {…}ブロック内の処理は無視されます。(短絡評価の仕組み)
import arrow.core.*
class UserNotFoundException() : UserException("User is null", null)
val result: Either<UserException, Unit> = findUserById(UserId("#id"))
.flatMap { user: User? ->
if (user == null) UserNotFoundException().left()
else user.right()
}
.flatMap { getPostsByUser(it) }
.flatMap { doSomethingWithPosts(it) }
// or either block
val result: Either<UserException, Unit> = either { /*this: EitherEffect*/
val userNullable: User? = findUserById(UserId("#id")).bind()
val user: User = ensureNotNull(userNullable) { UserNotFoundException()
}
val posts: List<Post> = getPostsByUser(user).bind()
doSomethingWithPosts(posts).bind()
}
最後に、エラーを回復する方法です。
ResultのrecoverとrecoverCatchingと同様に、2つの関数handleErrorとhandleErrorWithを使用できます(flatMapと同じですが、こちらは左辺を使います)。
class MyData(...)
suspend fun getFromRemote(): MyData = TODO()
suspend fun getFromCache(): MyData = TODO()
val result: Either<Throwable, MyData> =
Either
.catch { getFromRemote() }
.handleErrorWith { e: Throwable ->
Either.catch {
logger.error(e, "getFromRemote")
getFromCache()
}
}
結論
エラー処理に役立ち、副作用を減らすタイプである ResultとEitherについて解説しました。 Eitherは、関数のシグネチャを確認するだけで、発生する可能性のあるエラーも指定します。
また、 Eitherはキャンセルメカニズムを失うことなくsuspend関数をサポートし、Arrow-ktも[Fxモジュール]を使用すると、asyncおよびconcurrentプログラムを作成するときにKotlinコルーチンを簡単に使用できます。
最後まで読んでいただきありがとうございました。
この記事がお役にたてれば幸いです。
Enlytについて
株式会社Enlytはベトナムに開発拠点SupremeTechを持ち、これまで50以上の開発プロジェクトを行ってきました。ベトナムと日本のグローバルなチームで、数多くのプロジェクトを成功に導いてきました。
Enlytのオフショア開発は、アジャイル・スクラム開発を採用しています。コミュニケーションの透明化を意識してそれぞれの役割で責任の範囲を明確化しています。クライアントも含めたワンチームとして、フラットな関係で開発を進めることができます。
お客様の納得のいくまで、共に開発させていただき、アイデアを最高のかたちにサービス化いたします。
オフショア開発についてのお悩みやご相談がございましたら、下記ボタンより気軽にお問合せください!