LINEヤフー Tech Blog

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

コード品質向上のテクニック:第53回 通知多くして関数山に登る

こんにちは。コミュニケーションアプリ「LINE」のモバイルクライアントを開発している石川です。

この記事は、毎週木曜の定期連載 "Weekly Report" 共有の第 53 回です。 LINEヤフー社内には、高い開発生産性を維持するための Review Committee という活動があります。ここで集まった知見を、Weekly Report と称して毎週社内に共有しており、その一部を本ブログ上でも公開しています。(Weekly Report の詳細については、過去の記事一覧を参照してください)

通知多くして関数山に登る

関数が同期的に実行される場合、結果を呼び出し元に返す一般的な方法は、戻り値を使うことでしょう。一方、非同期処理の結果を呼び出し元に通知したい場合には、以下のような工夫が必要になります。

  • 結果が得られるまで呼び出し元の実行をブロックする (≈ 同期的な実行にする)
  • 非同期処理の言語機能を使う (async/await や coroutine 等)
  • 戻り値としてプロミス (Promise/Future/Single) やストリーム (Flow/Observable) を返す
  • 非同期コールバックを引数として渡す

ここではプロミスの例として、Future<T> というクラスがあることを仮定します。Future<T> が持つ結果 Tthen というメソッドによって利用できるとしましょう。以下のコードでは、emitter.emit で非同期にユーザ名を返し、その処理を caller 内で定義しています。getUserNameAsync 内の doAsync は非同期に実行され、emitter.emit を呼び出すことで結果を then のコールバックに渡しています。

fun caller() {
    val userId = ...

    getUserNameAsync(userId)
        .then { name -> println("Name: $name") }
}

fun getUserNameAsync(userId: UserId): Future<String> {
    ...

    return doAsync { emitter ->
        val userName = ...
        emitter.emit(userName)
    }
}

このコードに対して、ユーザ名の取得に失敗した場合のエラー処理が必要になったとします。そこで、ある開発者がエラー処理のコードを以下のように実装しました。

fun caller() {
    val userId = ...

    getUserNameAsync(userId, onError = { println("ERROR!") })
        .then { name -> println("Name: $name") }
}

fun getUserNameAsync(
    userId: UserId,
    onError: () -> Unit
): Future<String> {
    ...

    return doAsync { emitter ->
        val userName = ...
        if (userName != null) {
            emitter.emit(userName)
        } else {
            onError()
        }
    }
}

このコードに何か問題点はありますか?

船頭を一人に絞る

上記のコードでは、Future と非同期コールバックという、複数の非同期処理の仕組みを同時に使っているため、どの状況でどう結果が通知されるのかが分かりにくくなっています。1 つの関数に対しては、可能な限り 非同期処理の方法も 1 つに絞った方が見通しの良いコードになりやすい です。今回の場合は、すでに Future<T> を使っているため、エラーの通知も Future<T> に任せたほうが良いでしょう。

そのために、以下のコードでは結果を示す NameQueryResult を定義しています。

sealed interface NameQueryResult {
    data class Success(name: String) : NameQueryResult
    data object Failure : NameQueryResult
}

fun caller() {
    val userId = ...

    getUserNameAsync(userId).then { result ->
        val message = when (result) {
            is NameQueryResult.Success -> "Name: ${result.name}"
            NameQueryResult.Failure -> "ERROR!"
        }
        println(message)
    }
}

fun getUserNameAsync(userId: UserId): Future<NameQueryResult> {
    ...

    return doAsync { emitter ->
        val userName = ...
        val result = if (userName != null) {
            NameQueryResult.Success(userName)
        } else {
            NameQueryResult.Failure
        }
        emitter.emit(result)
    }
}

このようにすることで、以下のような利点があります。

  1. 通常ケースとエラーの処理を、統合するか/分離するかを呼び出し元が選択できる。
  2. emitter.emit が丁度 1 回呼ばれることを保証しやすくなる。

(Future 自体にエラーの値を取り扱う方法がある場合は、その仕組みを使うことも選択肢に入ります。)

async/await や coroutine を使っている場合、あるいは戻り値が PromiseFuture である場合は、引数として非同期コールバックが必要になることはほとんどありません。非同期の結果を追加するときは、既存の非同期の仕組みがないか確認しましょう。

一言まとめ

1 つの関数につき、非同期処理の仕組みは 1 つに絞る。

キーワード: asynchronous, coroutine, callback

コード品質向上のテクニックの他の記事を読む

コード品質向上のテクニックの記事一覧