LINEヤフー Tech Blog

LINEヤフー株式会社のサービスを支える、技術・開発文化を発信しています。

コード品質向上のテクニック: 第 20 回(異例の過剰包装)

こんにちは。コミュニケーションアプリ「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 中の例外が優先したほうが、誤解の少ない挙動になります。

修正後のコードは以下のようになります。このコードでは、blockdispose の両方で例外が起きた場合、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 のような検査例外がある場合、例外を別の例外でラップしたとしても、例外の型による区別ができるため、比較的安全になります。ただし、以下のような場合もあるので注意が必要です。

  • 呼び出し元で複数種類の例外を処理していて、かつ、例外に親子関係がある場合: 例えば、IOExceptionExceptioncatch している場合、IOException を別の例外に変えた場合、Exception としてキャッチされてしまう上に、それはコンパイル時にエラーとはなりません。
  • 非検査例外に変換した場合: RuntimeException でラップしてしまうと、対応する catch/throws がなくてもコンパイルが通ってしまいます。検査例外を RuntimeException でラップするのは、回復不能な場合に限るのが良いでしょう。

一言まとめ: 例外処理中に例外が発生した場合、どちらを優先するべきかを検討する。

キーワード: exception, error handling, wrapper