こんにちは。コミュニケーションアプリ「LINE」のモバイルクライアントを開発している石川です。
この記事は、毎週木曜の定期連載 “Weekly Report” 共有の第 20 回です。Weekly Report については、第 1 回の記事を参照してください。
異例の過剰包装
Kotlin の Closeable.use
は、引数を実行した後に Closeable.close()
を呼び出すという便利な高階関数です。以下のコードでは、2 行目のラムダを実行した後に inputStream
がクローズされます。
file.inputStream.use { stream ->
// We can use stream here
}
// After `use` execution,
// we don't need to call `close()` for `stream` here.
スコープポインタやオートリファレンスカウンタ (ARC) 等がある言語ならば、デストラクタでリソースを開放することで同じようなことをできます。また、Java の場合は AutoCloseable
を継承することで、try-with-resources 文を使うことができます。
このパターンを採用する利点の 1 つに、例外を投げたり非ローカルなリターンを行ったりしても、リソースの開放忘れを防げることが挙げられます。以下のコードでは use
のラムダ内で Exception
を投げていますが、この場合でも inputStream.close()
が呼ばれます。
file.inputStream.use { stream ->
// We can use stream here
throw Exception()
}
// Even an exception is thrown,
// we can expect `close` was called.
独自に定義したインターフェースやクラスに対しても、同じようなパターンを実装できます。以下の Disposable
では、使い終わったときに dispose
を呼ぶことを期待しています。このような独自のクラスに対しても、use
という拡張関数を定義することで、dispose
の呼び出し忘れを防ぐことができます。(拡張関数という機能をもたない言語でも、レシーバの代わりに引数を使うことで、同じことができます。)
interface Disposable {
fun dispose()
}
fun <T : Disposable?, R> T.use(block: (T) -> R): R {
try {
return block(this)
} finally {
dispose()
}
}
この Disposable
の実装後、「dispose
も例外を投げるかもしれない」ことに気がついたとします。つまり、use
の呼び出し中に発生しうる例外は、block
実行中のものとdispose
呼び出し中のものの 2 つの可能性があります。また、1 回の use
の呼び出しで、両方の例外が投げられることもありえます。そこで、以下のように DisposableException
という例外を実装し、use
中に発生する例外を 1 つにまとめるように実装を更新しました。
class DisposableException(
val exceptionAtBlock: Throwable?,
val exceptionAtDispose: Throwable?
): Exception()
interface Disposable {
fun dispose()
}
fun <T : Disposable?, R> T.use(block: (T) -> R): R {
var exceptionAtBlock: Throwable? = null
try {
return block(this)
} catch (originalException: Throwable) {
exceptionAtBlock = originalException
throw DisposableException(exceptionAtBlock, null)
} finally {
try {
this?.dispose()
} catch(exceptionAtDispose: Throwable) {
throw DisposableException(
exceptionAtBlock,
exceptionAtDispose
)
}
}
}
このコードに問題点はなにかありますか?
例外中の例外
先述のコードでは、発生した例外を別の例外に置き換えています。しかし、その変換は呼び出し元が期待していないものである可能性があります。
例えば以下の SomeDataWriter
では、 write
中に IOException
が発生する可能性があります。そして、呼び出し元の someFunction
はその例外を補足するように書いているつもりなのですが、実際に投げれられる例外は DisposableException
のため、この catch
は意図通りには動きません。この例では、someFunction
内に例外処理と use
の両方が存在するため、間違いに比較的気が付きやすいのですが、補助的な関数を作って抽出を行った場合は、この間違いを見落としやすくなるでしょう。
class SomeDataWriter : Disposable {
fun write(someData: SomeData) {
// write someData
if (/* for some error case */) {
throw IOException(...)
}
}
fun dispose() { /* ... */ }
}
fun someFunction(...) {
try {
createWriter()
.use { writer -> writer.write(someData) }
} catch (exception: IOException) {
// handle IO Exception
}
}
無駄な包装を省く
1 ヶ所で複数の例外が起き得る場合は、別の例外を作ってラップするよりも、Throwable.addSuppressed
を使って「どちらがより重要な例外か」を明確にする と良いでしょう。ただし、どちらがより重要なのかは、慎重に決める必要があります。例えば以下のコードは 不適切な 実装と言えます。
fun <T : Disposable?, R> T.use(block: (T) -> R): R {
var exceptionAtBlock: Throwable? = null
try {
return block(this)
} catch (originalException: Throwable) {
exceptionAtBlock = originalException
throw originalException
} finally {
try {
this?.dispose()
} catch (exceptionAtDispose: Throwable) {
if (exceptionAtBlock != null) {
exceptionAtDispose.addSuppressed(exceptionAtBlock)
}
throw exceptionAtDispose
}
}
}
この実装では、block
中に IOException
が発生したとしても、その後 dispose
で別の例外 exceptionAtDispose
が発生すると、最終的に投げられる例外が exceptionAtDispose
になってしまいます。dispose
はあくまでも補助的な呼び出しであるため、block
中の例外が優先したほうが、誤解の少ない挙動になります。
修正後のコードは以下のようになります。このコードでは、block
と dispose
の両方で例外が起きた場合、block
のものが優先されます。(Kotlin 1.1 以降の Closeable.use
の実装も、これと同等の挙動をします。より詳しくは Closeable?.closeFinally
を参照してください。)
fun <T : Disposable?, R> T.use(block: (T) -> R): R {
var exceptionAtBlock: Throwable? = null
try {
return block(this)
} catch (originalException: Throwable) {
exceptionAtBlock = originalException
throw originalException
} finally {
try {
this?.dispose()
} catch (exceptionAtDispose: Throwable) {
if (exceptionAtBlock == null) {
throw exceptionAtDispose
} else {
exceptionAtBlock.addSuppressed(exceptionAtDispose)
}
}
}
}
Java は例外?
Java のような検査例外がある場合、例外を別の例外でラップしたとしても、例外の型による区別ができるため、比較的安全になります。ただし、以下のような場合もあるので注意が必要です。
- 呼び出し元で複数種類の例外を処理していて、かつ、例外に親子関係がある場合: 例えば、
IOException
とException
でcatch
している場合、IOException
を別の例外に変えた場合、Exception
としてキャッチされてしまう上に、それはコンパイル時にエラーとはなりません。 - 非検査例外に変換した場合:
RuntimeException
でラップしてしまうと、対応するcatch
/throws
がなくてもコンパイルが通ってしまいます。検査例外をRuntimeException
でラップするのは、回復不能な場合に限るのが良いでしょう。
一言まとめ
例外処理中に例外が発生した場合、どちらを優先するべきかを検討する。
キーワード: exception
, error handling
, wrapper