対象読者
RustのResult
型(や、他言語における同等機能)を知らない人。
投げる例外しか扱ったことがないのに、kotlin-resultを使ってくれと言われた人。
例外を投げる方式の問題点
エラーを通知する方法として、例外を投げるという方法を採る言語は多いです。この方法の欠点は、(言語仕様にもよりますが)エラー発生時のインタフェースを適切に設定できないことです。
例えば、
fun doubleIfPositiveOrThrow(input: Int): Int {
if (input <= 0) {
throw Error("Invalid input!")
}
return input * 2
}
という関数は、引数に整数を取って戻り値として整数を返す、というところまでインタフェース定義できているにも関わらず、例外を投げるかどうかは実装詳細を読む必要があります。このせいで、呼び出し元にエラーハンドリングを強制することができず、バグを生む原因になります。
エラーを例外として扱わず、戻り値として扱う方法
そこで、いくつかの言語・・・特に新しめの言語では、エラーを例外として特例扱いするのではなく、普通に戻り値として扱う方法を採っています。
例えばGoでは、エラーが発生する可能性のある関数の戻り値は、常にerr
を含みます。
func Atoi(s string) (int, error)
この Atoi
関数の場合、第二戻り値がerror
なので、この関数はエラーが発生する可能性があるのだ、ということが、型を見るだけで分かります。
RustやScalaでは、Goと違ってジェネリクスが使えるため、少し異なるアプローチを採っています。それがResult
型です。(※ScalaではEither
と呼びますが、実質同じです。)
これから紹介するKotlinのResult
も、それと同じものです。
kotlin-resultライブラリ
では、本命のkotlin-resultライブラリの説明を始め・・・たいところなのですが、その前に注意点です。
KotlinのスタンダードライブラリにResult
型というのがありますが、これは全く異なるものです。これはこれでエラーハンドリングに役立ちそうな気配はありますが、今回は触れません。
今回説明するのは、kotlin-resultライブラリのResult
型です。
関数定義
Result
を使うと、前述の
fun doubleIfPositiveOrThrow(input: Int): Int {
if (input <= 0) {
throw Error("Invalid input!")
}
return input * 2
}
は、
fun doubleIfPositive(input: Int): Result<Int, String> {
if (input <= 0) {
return Err("Invalid input!")
}
return Ok(input * 2)
}
と書き換えられます。
まずは、インタフェース(関数の型)だけ見てみます。
変更前のfun doubleIfPositiveOrThrow(input: Int): Int
は、「整数を食って、整数を返す」ということを言っています。ただし実は、定義にないエラーが発生することがあり得ます。
変更後のfun doubleIfPositive(input: Int): Result<Int, String>
は、「整数を食って、成功したら整数を返す、失敗したら文字列を返す」ということを言っています。(Result
の第一型引数が成功時の型、第二型引数が失敗時の型を表す。)
関数の中身も見てみましょう。
変更前はthrow
していたところが、変更後はreturn Err("Invalid input!")
に変わっています。例外として投げてしまうのではなく、あくまで戻り値として返しているわけです。ただ、エラーであることを表現するため、Err
で戻り値を囲っています。
最後のreturn
ですが、変更前はreturn input * 2
とIntを返していたところが、変更後はreturn Ok(input * 2)
に変わっています。成功したことを表すため、Ok
で戻り値を囲うことが必要となります。
Resultを返す関数の使い方
例えば、
val result = doubleIfPositive(5)
とした場合、result
の値は10
にはなりません。Ok(10)
になります。よって、そのままでは使えません。
例えば、以下のように使います。
fun tryResultType() {
val result = doubleIfPositive(5) // これはOk(10)を返すので、このままではIntとして使えない。
// 成功なら中身の値(=10)、エラーならnull。
val doubled0: Int? = result.get()
// 成功なら中身の値(=10)、エラーなら0。
val doubled1: Int = result.getOr(0)
// 成功なら中身の値(=10)、エラーならtryResultType自体から抜けてしまう。
val doubled2: Int = result.getOrElse { return }
}
以上の使用例を見ると分かるように、どの使い方でも、常にエラーだった場合のことを意識しています。つまり、
fun tryResultType() {
val result = doubleIfPositive(-3) // これはErr("Invalid input!")を返す。
val doubled0: Int? = result.get() // null
val doubled1: Int = result.getOr(0) // 0
val doubled2: Int = result.getOrElse { return } // tryResultType自体から抜けてしまう。
}
という感じです。
いったんまとめ
まとめると、例外を投げる方式と比べて、
- インタフェース(関数の型)を見るとエラーが発生しうることが分かる。
- 関数呼び出し側にエラー処理を強制できる。(→処理忘れによるバグを生みづらい。)
というメリットがあります。
bindingとbind
しかし、いちいちgetOrElse
などを書くのは結構面倒です。
つまり、従来通りの例外投げ方式だと、以下のように書けていました。
try {
val a: Int = doubleIfPositiveOrThrow(2)
val b: Int = doubleIfPositiveOrThrow(5)
val c: Int = doubleIfPositiveOrThrow(-8)
} catch (e: Error) {
// エラー処理
}
Result
方式だと、こうなります。
val a: Int = doubleIfPositive(2).getOrElse {
// エラー処理
return
}
val b: Int = doubleIfPositive(5).getOrElse {
// エラー処理
return
}
val c: Int = doubleIfPositive(-8).getOrElse {
// エラー処理
return
}
これを楽に書けるようにするのが、binding
とbind
です。
binding
とbind
を使うと、このように書けます。
binding<Unit, String> {
val a: Int = doubleIfPositive(2).bind()
val b: Int = doubleIfPositive(5).bind()
val c: Int = doubleIfPositive(-8).bind()
}.getOrElse {
// エラー処理
return
}
簡単に言えば、こういうことです。
binding
ブロックの中では、Result
型はbind()
を呼び出すことができます。
bind()
は、もし呼び出し元のResult
が成功(Ok
)であれば中身の値を取り出し、失敗(Err
)であればbinding
ブロックから抜けて、エラー(Err
)を返します。
楽できるので、使えるところではbinding
とbind
を使った方が良いです。
詳細は、公式ドキュメントを参照してください。
最後に
詳しく説明すると長くなるので、いったんここまでにします。
投げる例外にしか触れてこなかった人にとっては、最初はややこしく感じるかと思います。しかし、投げる例外では、厳密なエラー処理をプロジェクト全体に浸透させるのは難しいです。Result
を使う方法だと、いい加減に書いてしまうことが許されなくなる分、バグの少ないコードを生み出すことができる・・・と思っています。
少なくとも、Rustは言語レベルでそういう選択をしたわけですし。
とにかく、触っているうちに慣れますから!