例外だけに頼らない Kotlin のエラーハンドリング


はじめに

Java ではある処理がエラーになる可能性がある場合、チェック例外を使って呼び出し側にエラーハンドリングをさせることを強制できます。

一方、Kotlin にはチェック例外の仕組みがないため、チェック例外によるエラーハンドリングを強制できません。

そのためチェック例外を使った Java のコードを Kotlin に移植すると、エラーハンドリングがコンパイラ任せではなくコーディングルールによる対処になってしまいます。

実際に例外を使用した Kotlin のコードレビューをしたことがありましたが、コードレビューの負荷が増したと実感しました。

例えば次のような特定の入力に応じて例外を発生させる関数があるとして、その関数の呼び出しで適切に try-catch しているかはレビューやテストでしか確認できません。


class ParseException(message: String?) : Exception(message)

fun toInt(input:String):Int {
if(input.matches("^[+-]?[0-9]+$".toRegex())) {
return Integer.parseInt(input)
} else {
throw ParseException("$input is not Integer string")
}
}

fun somethingCode() {
// try catch を強制できず、忘れることがある
try {
println(toInt("ABC"))
} catch (e:ParseException) {
e.printStackTrace()
}
}

実際に私はチェック例外が無い言語でエラーハンドリングの扱いに困ったことがあります。

C# もチェック例外が無い言語の一つです。

C# のとあるライブラリでは API の呼び出し結果のエラーを例外で返すのですが、どんな例外を返すかがドキュメントにしか書いてないのでとても困りました。

それでもチェック例外が最近の言語で採用されないのは try-catch の使いにくさにあるのだと思います。

また、最近のトレンドでいえば非同期処理と例外との相性の悪さがあると思います。

非同期処理で発生するエラーを補足しようとして try-catch を書いたけど、補足できなかったという経験は誰にでもあると思います。

最近のプログラミング言語では例外ではなく、戻り値に失敗の内容を含めるものがあります。

Scala では Either, Rust では Result, Golang では正常値と失敗値の 2 値を返すという感じです。

この方式であれば戻り値でエラーの有無の判定が強制できますし、非同期処理の失敗にも対処ができます。

Kotlin でエラーハンドリングを例外だけに頼るのは危険ですし、できれば Scala の Either 相当の仕組みが欲しいなと思い、色々調べたところ Kotlin には例外以外のエラーハンドリングの手段があることがわかりました。

例外以外の 3 つの方法について紹介します。


Result を使う

Kotlin 1.3 から、 kotlin.Result 型が追加されました。

Result 型導入に関する提案は次の場所にあります。

https://github.com/Kotlin/KEEP/blob/master/proposals/stdlib/result.md

提案を見たところ Either のように 成功値または失敗値を持ち map などのコンビネータで変換ができるような型です。

ただし通常の型と異なり コンストラクタで Result 値を生成したり、 Result 型を関数の戻り値にはできません。

変数や引数、Result 値のコレクションとしては定義できるようです。

そのため、次のように try-catch を置き換えるような使い方を想定しているようです。

fun stringToInt(input:String) {

// runCatching ブロック内で例外が起きると失敗値、そうでないならブロックの戻り値が成功値になる
val result:Result<Int> = runCatching {
Integer.parseInt(input)
}

// 戻り値にはできない。
// return result

// 成功 or 失敗判定
println(result.isSuccess)
println(result.isFailure)

// 値の取得
println(result.getOrNull()) // 成功の場合のみ値が取れる
println(result.exceptionOrNull()) // 失敗の場合、例外が取得できる

// 値の変換
result.map{it * 2} // 成功の場合、成功値を2倍
result.recover { 0 } // 失敗値をラムダ式の戻り値で成功値にする
}

try-catch と比べると、Result 値を適宜変換したりできるので取り回しが楽になります。

Kotlin を採用する上でチェック例外が無いにもかかわらずエラーハンドリングの代替手段が無いことが私は問題だと思っていたので、Result 型の導入は歓迎です。

ただし Result を戻り値にできないので使いどころが限られてしまうのが残念です。


シールドクラスによるエラーの表現

前掲した Result 型の提案には、 "error-handling-style-and-exceptions" という節があり興味深い内容でした。

https://github.com/Kotlin/KEEP/blob/master/proposals/stdlib/result.md#error-handling-style-and-exceptions

内容を要約したものは次の通りです。


  • Kotlin の例外は回復不能なもの(メモリ不足とかバグとか、いわゆる非チェック例外)に対して使うことを想定している


    • コードのあらゆる場所で try-catch を書くようなスタイルを避けたい



  • ドメインロジックにおけるエラーは シールドクラス による型の派生で表現したほうが良い

  • ネットワーク IO のエラーなどたまに起きるような例外については、個別にハンドリングすることは避けグローバル例外ハンドラなどで対処したほうが良い


    • 例えば前述の Result の runCatching を呼び出しの一番外側で使うなど

    • そうすることでドメインロジックのシグネチャをシンプルに保つ



要はエラーを何でも例外にするのではなく、レイヤに合わせて方法を変えようというものです。

シールドクラスを使う例は次の通りです。

ユーザーを探すというドメインロジックについて、ユーザーが見つかる・見つからないという仕様を次の通りシールドクラスの 2 つの派生型として表現し戻り値にします。

シールドクラスの派生による表現は Scala でもよく使います。Scala では ADT (代数的データ型, Algebraic data type) と呼んだりします。

data class User(val id:String, val name:String, val age:Int)

//ユーザー検索 ドメインロジックの結果
sealed class FindUserResult {
// 見つかった
data class Found(val user: User) : FindUserResult()
// 見つからない
object NotFound : FindUserResult()
}

ドメインロジックの実装はこんな感じでしょうか。

fun findUserByName(name: String, dao:UserDao): FindUserResult {

// Daoの実装は省略
val user = dao.selectUserByName(name)

if(user == null) {
return FindUserResult.NotFound
} else {
return FindUserResult.Found(user)
}
}

ドメインロジックの呼び出し側は次のようにスマートキャストを使って成功・失敗フローを実装します。

fun callDomainLogic(name:String) {

val result = findUserByName(name, UserDao())

when(result) {
is FindUserResult.NotFound -> println("$name is not found")
is FindUserResult.Found -> println("Hello ${result.user.name} !")
}
}

そして UserDao が IO 例外などを出す場合に備えて、最上位で Result にくるみます。

fun main(args:Array<String>) {

runCatching {
callDomainLogic(args[0])
}.onSuccess { println("Success End") }
.onFailure { println("Error of " + it) }
//onXXX は成功・失敗時のコールバック
}

このアプローチはレイヤごとの型変換などが煩雑にはなりますが、ドメインロジックを固く作れそうではあります。

この関数型プログラミングのお作法を取り込んだ方法は私の好みです。ですがベター Java のつもりで Kotlin を導入した場合、この方法を取り込むのには発想の転換が必要だと思いました。


Arrow ライブラリの Either を使用する

シールドクラスによる失敗の表現は、場合によっては固すぎることもあるでしょうし複数のドメインロジックの結果を合成しづらかったりもします。

もっと簡単に成功と失敗だけを示す型があったほうが便利な場合もあります。

本音を言えば Result が戻り値にできればいいのですが、そうもいかないようです。

その場合に使用できるのが、 Arrow というライブラリです。

Arrow は Scala の関数型プログラミング用ライブラリの Scalaz や Cats のように、関数型プログラミングで有用な機能を提供してくれます。

コルーチンのスコープの仕組みを上手く利用して、モナド記法のサポートもあります。

Arrow には 成功・失敗を示す Either 型があります。 Either 型なモナド型クラスのインスタンスでもあるので、複数の Either を簡単に合成できます。

Arrow を使うには依存性の定義が必要になります。gradle なら次のようにします。

def arrow_version = "0.8.1"

dependencies {
//...
compile("io.arrow-kt:arrow-core:$arrow_version")
compile("io.arrow-kt:arrow-syntax:$arrow_version")
compile("io.arrow-kt:arrow-typeclasses:$arrow_version")
compile("io.arrow-kt:arrow-data:$arrow_version")
compile("io.arrow-kt:arrow-instances-core:$arrow_version")
compile("io.arrow-kt:arrow-instances-data:$arrow_version")
// kapt が必要なのは higher kind type などを使う場合で、 Eitherだけなら無くてもよい
kapt( "io.arrow-kt:arrow-annotations-processor:$arrow_version")
//...
}

まずは引数に対応する値をあればその値、無い場合はエラーを Either で返す関数を定義してみます。

成功値を right, 失敗値を left でぞれぞれ生成します。

import arrow.core.Either

class NotFoundException(message:String?) : Exception(message)

fun findValue(key:String):Either<NotFoundException, String> {

val repo = mapOf("A" to "B", "B" to "C", "C" to "D")

if(repo.containsKey(key)) {
return Either.right(repo[key]!!)
} else {
return Either.left(NotFoundException("$key is missing"))
}
}

Either 値の取り回しは次のようにスマートキャストを使って行うのが基本になります。

    val res = findValue("A")

when(res) {
// Right は成功の場合。 b に成功値(String) が入っている
is Either.Right -> println("Success ${res.b}")
// Left は失敗の場合。 a に失敗値(NotFoundException) が入っている。
is Either.Left -> println("Fail ${res.a.message}")
}

これだけではつまらないので、続いて findValue の呼び出しが成功の場合にその戻り値でもう一度 findValue を呼び出すことを考えてみます。

Either の中から成功値を取り出して 2 度目の findValue に渡すようなコードを書く必要はなく、 flatMap を使えば簡単に合成できます。

Either から値を取得するのは最後に一度だけ行えばよいことになります。

import arrow.core.Either

import arrow.instances.either.monad.*
//
val res = findValue("A")
.flatMap { res1 -> findValue(res1)
.map{res2 -> "$res1 -> $res2"} }

when(res) {
is Either.Right -> println("Success ${res.b}")
is Either.Left -> println("Fail ${res.a.message}")
} // B -> C

上記の場合、変数 res が Right になるのは findValue の呼び出しがどちらも Right になる場合だけです。

どちらかの呼び出しが Left になったらその時点でその値をリターンします。

flatMap による結合も 2 つくらいならよいですが数が増えると見づらくなってきます。

そのときにはモナド記法によって見た目をすっきりとできます。

Either の MonadError についてのスコープを定義し、その中で bind を行うと Either の right 値が取得できるようになります。

bind が呼び出せるのはスコープを定義した型と同じ値(今回は Either )だけで、それ以外のモナドインスタンスで bind を呼び出すとコンパイルエラーになります。

import arrow.core.Either

import arrow.typeclasses.binding
// binding で使いたいモナドインスタンスを追加
import arrow.instances.either.monadError.*
import arrow.instances.either.monad.*

fun findValueThree(key:String) = Either.monadError<NotFoundException>().binding {
val v1 = findValue(key).bind()
val v2 = findValue(v1).bind()
val v3 = findValue(v2).bind()
"$key -> $v1 -> $v2 -> $v3"
}

fun main(args:Array<String>) {

val res3 = findValueThree("A")
val res4 = findValueThree("B")

println(res3) // Right(b=A -> B -> C -> D)
println(res4) // Left(a=NotFoundException: D is missing)
}

binding を使用した方法と前述の flatMap による方法はどちらも同じ処理を行います。

binding 中の bind はコルーチンの仕組みを使って内部的に後続の処理を flatMap に置き換えるような動作をするためです。

なお同様の処理は Scala では for 式で記述します。

  def findValueThree(key:String):Either[NotFoundException, String]

= for(v1 <- findValue(key); // bind相当の記述は for の中にしか書けない
v2 <- findValue(v1);
v3 <- findValue(v2))
yield s"$key -> $v1 -> $v2 -> $v3" // bind以外の処理は yield 以降に書く

for 式と違い Arrow の binding はモナドの型を明示する必要があるのが少々面倒です。

一方 for 式では bind に相当する式を記述できる場所が限られていますが、Arrow では bind は任意の場所に書けるといった利点があります。

初めて Arrow を見たときに Scala よりも関数型プログラミングのサポートが弱い Kotlin でどうするんだろうと思いました。しかし Kotlin の機能を上手く利用して、関数型プログラミングがある程度できるように仕上がっていると思います。


まとめ

Kotlin のエラーハンドリングの方法について、例外、Result、シールドクラス、Arrow の4種類を紹介しました。

Kotlin についてはエラーハンドリングが明らかに弱いと思っていたので採用をためらっていたのですが、 Result や Either を使えばどうにかなりそうだと今回の調査で感じました。

チームの技術レベルやアプリケーションの規模に応じて、例外以外のエラーハンドリングを適切に取り入れていきましょう。