1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Kotlin Contracts の InvocationKind.EXACTLY_ONCE を「1 回呼び出す」と誤解してはいけない

Posted at

はじめに

Kotlin には Contracts という、関数の内部動作に関するヒントをコンパイラに提供する機能があります。これにより、コンパイラはスマートキャストや変数の初期化判定をより賢く行えるようになります。

通常、コンパイラは関数の内部の実装まで深く解析しませんが、contract を使うことで「この関数は引数が null でない場合のみ return する」「この関数はラムダ式を必ず呼び出す」といった情報をコンパイラと契約(約束)できます。

本記事では、InvocationKind.EXACTLY_ONCE に着目して解説します。

Kotlin Contracts の利用例

スマートキャスト

例えば、requireNotNullcontract を利用しています。

requireNotNull は「正常に戻り値を返したならば、引数は null ではない:returns() implies (value != null)」とコンパイラに伝えています。もしこれがなければ、コンパイラは null かもしれないと判断し、以下のコードはコンパイルエラーになります。

val text: String? = getTextOrNull()
requireNotNull(text)

// ここで text は String (非 null) として扱える
println(text.length)

変数の確定的初期化

他にも、runcontract を利用しています。

通常、関数に渡すラムダ式の中で変数の初期化を試みても、コンパイラは「そのラムダ式が本当に 1 回だけ呼び出されるかわからない」ため、エラーとなります。しかし、run は「ラムダ式を必ず 1 回だけ呼び出す1callsInPlace(block, InvocationKind.EXACTLY_ONCE)」とコンパイラに伝えているため、ラムダ内での初期化を認めます。

val x: Int
run {
    // 初期化できる
    x = 100
}

// ここで x は初期化済みとみなされる
println(x)

runCatchingInvocationKind.EXACTLY_ONCE

ここからが本題です。

「ラムダ式を 1 回だけ呼び出す」関数であれば、InvocationKind.EXACTLY_ONCE を指定してよいのでしょうか?

例として、例外を捕捉して Result 型を返す自作の runCatching を考えます。block() の呼び出し自体は確かに 1 回だけです。しかし、この関数に EXACTLY_ONCE を指定すると、contract と実際の制御フローに矛盾が生じてしまいます。

inline fun <R> myRunCatching(block: () -> R): Result<R> {
    contract {
        // 「block は必ず 1 回だけ呼ばれる」と考えてこれを指定する
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }

    return try {
        Result.success(block())
    } catch (e: Throwable) {
        Result.failure(e)
    }
}

発生する問題

次のコードを考えます。ラムダの途中で例外が発生するため x は初期化されません。しかし myRunCatching 自体は例外を catch して処理を続行します。コンパイラは EXACTLY_ONCE を信じて x を初期化済みと判断します。

結果として、未初期化の変数にアクセスするコードがコンパイルを通過し、実行時エラーや予期せぬ値の参照につながります。

val x: Int
myRunCatching {
    throwableFunction() // ここで例外が発生する
    x = 100 // 初期化が実行されない
}

// block の例外は catch されるため、以降の処理が続行される
println(x) // x が未初期化のままアクセスされる

本質的な誤解

InvocationKind.EXACTLY_ONCE に関する誤解は、次のように整理できます。

  • 誤解:block() が 1 回だけ呼び出される
  • 正解:block() が 1 回だけ呼び出され、かつ例外などで中断されず正常終了する

run の場合、ラムダ内で例外が発生すればその例外は外に伝播し、後続の処理は実行されません。そのため「初期化が完了していない状態で先に進む」ことが起きず、EXACTLY_ONCE を満たします。

一方、runCatching のようにラムダ内の例外を catch して処理を継続する関数では、EXACTLY_ONCE を満たせません。この場合、EXACTLY_ONCE ではなく AT_MOST_ONCE にするのが妥当でしょう。

IDE による検出

幸いなことに、最近の IDE やコンパイラはこの矛盾を検知できるようになっています。Android Studio や IntelliJ IDEA で K2 Compiler を有効にしている場合、次のような警告が表示されます。

Wrong invocation kind 'EXACTLY_ONCE' for 'block: () -> R' specified, the actual invocation kind is 'AT_MOST_ONCE'.

これは「ラムダは 1 回まで呼ばれる」という、より正確な解析結果を示しています。

まとめ

  • InvocationKind.EXACTLY_ONCE は「1 回呼び出される」ではなく、「1 回呼び出され、正常終了する」ことを意味する
  • 例外を catch して処理を続行する関数では、InvocationKind.EXACTLY_ONCE を満たさない
  • Kotlin Contracts は強力だが、コンパイラを誤誘導することも可能になるため、注意が必要

参考文献

  1. kotlin.contracts | Core API – Kotlin Programming Language
  2. InvocationKind | Core API – Kotlin Programming Language
  3. KEEP/proposals/KEEP-0139-kotlin-contracts.md at main · Kotlin/KEEP
  4. Fixing Indeterminate Behavior Caused by InvocationKind.EXACTLY_ONCE by Daiji256 · Pull Request #106 · michaelbull/kotlin-result
  1. 正確には「1 回呼び出され、かつ正常終了する」という意味です。詳細は後述します。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?