3
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?

More than 1 year has passed since last update.

【Kotlin】suspend 関数を lazy に呼び出す

Last updated at Posted at 2022-11-01

呼び出しコストが大きい関数(以下、重たい関数)は、
呼び出さずに済むなら呼び出さないようにしたいし、
呼び出さなくてはならないなら呼び出し回数を最小限にしたいものです。

非 suspend 関数の場合

重たい関数が suspend 関数ではない普通の関数であれば、Lazy 委譲プロパティ(delegated property)を使うことで簡単にそのようにできます。
Lazy 委譲プロパティは lazy 関数を使うことで生成できます。

// 「呼び出しコストが大きい」関数。
// 今回の呼び出しを除いた呼び出し回数を出力し、
// 今回の呼び出しを含めた呼び出し回数を返す。
fun myFunction(): Int {
    println("old count: $myCount")

    // ...

    myCount += 1
    return myCount
}
private var myCount = 0

fun main() {
    // Lazy 委譲プロパティ
    val myLazyProperty by lazy {
        myFunction()
    } // 何も出力しない。

    myLazyProperty // > old count: 0
        .also { println("new count: $it") } // > new count: 1
    myLazyProperty // 何も出力しない。
        .also { println("new count: $it") } // > new count: 1
}

重たい関数が呼ばれるのは Lazy 委譲プロパティを使ったプロパティ(以下、lazy プロパティ)(上の例では myLazyProperty プロパティ)が初めて参照されたときだけで、
以降の参照では同じ値が返されます。

suspend 関数の場合

では、重たい関数が suspend 関数だった場合はどうでしょう。

// 「呼び出しコストが大きい」suspend 関数。
// 今回の呼び出しを除いた呼び出し回数を出力し、
// 今回の呼び出しを含めた呼び出し回数を返す。
suspend fun mySuspendFunction(): Int {
    println("old count: $myCount")

    // ...

    myCount += 1
    return myCount
}
private var myCount = 0

直接呼び出すことはできない

lazy 関数のブロック内では suspend 関数を直接呼び出すことはできません。

ダメな例
val myLazyProperty: Int by lazy {
    mySuspendFunction() // コンパイルエラー!
}

runBlocking はスレッドをブロックする

runBlocking 関数を使うのはどうでしょう。

ダメな例
val myLazyProperty: Int by lazy {
    runBlocking {
        mySuspendFunction()
    }
}

fun main() {
    println("new count: $myLazyProperty") // スレッドがブロックされてしまう!
}

コンパイルはできるようになります。
問題なく動く場合もあるでしょう。

しかし lazy プロパティを最初に呼び出したスレッドが、重たい関数が処理を返すまでブロックされてしまいます。
それが UI スレッドであれば、その間アプリがフリーズすることになります。
また、重たい関数の処理に使用されるスレッドと lazy プロパティを呼び出したスレッドが同じであれば、デッドロックになってしまいます。

async 関数と Deferred 型を使うとよい

次の例のように、プロパティの返値型を Deferred にして、lazy 関数のブロック内で async 関数を使って重たい関数の呼び出しを囲いましょう。
プロパティの呼び出し側では、Deferred オブジェクトから本来の値を取り出すため、await 関数を呼び出します。
await 関数は suspend 関数なので、コルーチンの中で呼び出す必要があります。

// Lazy 委譲プロパティを使ったプロパティ
val myLazyProperty: Deferred<Int> by lazy {
    CoroutineScope(Dispatchers.Default) // 注意! 状況に応じて適切な CoroutineScope を使うこと!
        .async {
            mySuspendFunction()
        }
}

fun main() {
    runBlocking {
        // ^ suspend 関数である await 関数を呼び出すため、コルーチンビルダーで囲う必要があるが、
        // ^ launch 関数だと呼び出し元が即座に処理を返してメインスレッドが終了してしまうため、
        // ^ runBlocking 関数を使用する。
    
        println("new count: ${myLazyProperty.await()}")
        // ^ > old count: 0
        // ^ > new count: 1
        println("new count: ${myLazyProperty.await()}")
        // ^ > new count: 1
    }
}

async 関数は呼び出されると即座に Deferred オブジェクトを返します。

Deferred オブジェクトは結果の引換券のようなものです。
await 関数を呼び出すと、async 関数のブロックでの処理が終わって結果を得られるまで、呼び出し元コルーチンが中断suspendされます。
1つの Deferred オブジェクトに対して await() を複数回呼び出しても、async ブロックは一度しか実行されず、同じ結果が返ります。
async ブロックの処理が終わってから await() を呼び出すと、即座に結果が返ります。

CoroutineScope に注意

上の例では CoroutineScope(Dispatchers.Default) を使用しましたが、状況に応じて適切な CoroutineScope オブジェクトを使用するようにしてください。

lazy プロパティをメンバに持つオブジェクトが破棄されても、CoroutineScope オブジェクトがキャンセルされていないと、async ブロックが無駄に処理を続けてしまうことがあるかもしれません。

逆に async ブロックが処理を完了する前に CoroutineScope がキャンセルされてしまうと、await() を呼び出した際に CancellationException がスローされることになります。

改良版(2023-03-22 追記)

次の記事で使いやすく改良しました。

/以上

3
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
3
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?