LINEヤフー Tech Blog

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

コード品質向上のテクニック: 第 23 回(return の切れ目が edge case の切れ目)

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

この記事は、毎週木曜の定期連載 “Weekly Report” 共有の第 23 回です。Weekly Report については、第 1 回の記事を参照してください。

return の切れ目が edge case の切れ目

早期リターン (early return, return early) は、動作の流れを明確にする上で重要なテクニックです。エラーケースを最初に除外することで、「関数の主な目的」に焦点を当てたコードを書きやすくなります。また、エラーの条件とその処理ロジックの関係もより分かりやすくなりという利点もあります。(詳しくは、 https://speakerdeck.com/munetoshi/code-readability-session-5-ver-2-ja?slide=39 から slide=48 を参照してください。)

以下のコードは、ユーザのIDのリストを受け取り、ユーザ名のリスト返します。ここでも早期リターンを適用しているのですが、なにか問題はあるでしょうか?

fun getUserNames(userIds: List<UserId>): List<String> {
    if (userIds.isEmpty()) {
        return emptyList()
    }
    if (userIds.size == 1) {
        val userData = repository.getUserData(userIds[0])
        return listOf(userData.name)
    }

    return userIds.asSequence()
        .map(repository::getUserData)
        .map(UserData::name)
        .toList()
}

縁は切るより含めよう

早期リターンはどんな場合でも無条件に適用して良いわけではありません。早期リターンを適用すべきかどうかは、エラーケースと通常ケースで処理がどの程度違うかに依存します。もし、エラーケースと通常ケースで処理が同じならば、その「エラー」はエラーケースとして扱うのではなく、通常ケースとして扱う方がコードを単純化しやすいです。今回の場合は、userIds.isEmpty()userIds.size == 1 を通常ケースと見なすことで、処理を統合できます。

fun getUserNames(userIds: List<UserId>): List<String> =
    userIds.asSequence()
        .map(repository::getUserData)
        .map(UserData::name)
        .toList()

(ただし、このコードは size == 0size == 1 の場合の性能は低下します。どちらの場合も、Sequence のインスタンスを作り、Sequence.toList でも新たな List インスタンスを作るためです。)

以下、通常ケースとして取り扱ったほうがよいこともあるケースを紹介します。

空コレクションの走査

mapfilterforEach といったコレクションを走査する高階関数は、多くの言語やライブラリで、空のコレクションに対しても有効です。また、sumreduce のような畳み込みを行う関数も、空のコレクションに対して自然に振る舞うことが多いです。

val empty: List<Int> = emptyListOf()
empty.all { false } // => true
empty.any { true } // => false
empty.sum() // 0

null

言語によってはセーフコール演算子 ?. と呼ばれる演算子があります。これは、null (nil, undefined) を通常ケースとして取り扱う場合に有効です。例えば、以下のような == null の早期リターンは、?. で通常ケースに含めることができます。

// Before
fun function(foo: Foo?): Bar? {
    if (value == null) {
        return null
    }
    return value.toBar()
}

// After
fun function(foo: Foo?): Bar? =
    value?.toBar()

 また、null をデフォルト値にフォールバックするために、エルヴィス演算子 ?: や null 結合演算子 ?? を使える言語もあります。?: 0orEmpty などと、デフォルト値にフォールバックするのは良い選択肢の一つですが、その場合は一旦変換したデフォルト値で再度場合分けをしないように注意してください (火の null 所に煙は立た null を参照)。

配列やリストのレンジ外

最初にインデックスの範囲を調べるようなコードがある場合は、レンジ外のときに早期リターンするよりも、getOrNullgetOrElse といった関数を使えることがあります。getOrNull を使うことで、上記の null の問題に帰着できます。

val fooList: List<Foo?> = ...

// Before
fun function(index: Int): Foo? {
    if (index < 0 || fooList.size <= index) {
        return null
    }
    return fooList[index]
}

// After
fun function(index: Int): Foo? =
    fooList.getOrNull(index)

標準ライブラリなどで getOrNullgetOrElse に相当するものがない場合は、汎用的な関数として定義するのも良いでしょう。

別のプロパティに依存するプロパティ

「あるプロパティが特定の値のときだけ、別のプロパティが意味を持つ」という状況があります。例えば textView という UI 要素があり、textView.isVisible が表示されているかどうかを示し、textView.text がテキストの内容を示しているとします。この場合、text が意味を持つのは、isVisibletrue のときだけです。

以下のコードでは、someText が空文字列の場合は textView を非表示にし、そのときは text を更新していません。

if (someText.isEmpty()) {
    textView.isVisible = false
    return
}
textView.isVisible = true
textView.text = someText

このような、意味の薄い代入を除外するための早期リターンは、削除しても良い場合もあります。isVisiblefalse のときは、text がどのような値になっても問題ないこともあるでしょう。その場合は、以下のように truefalse の処理を統合できます。

textView.isVisible = someText.isNotEmpty()
textView.text = someText

一連の関数呼び出し中の例外

関数中のさまざまな場所で例外が発生する場合、早期リターンを適用しようとするとコードが煩雑になることがあります。

例えば、以下のコードでは someData -> anotherData -> yetAnotherData と変換していくのですが、その途中で例外が発生する可能性があり、その場合は return しています。

sealed class FooResult {
    class Success(val fooData: FooData): FooResult()
    class Error(val errorType: ErrorType): FooResult()
}
enum class ErrorType { SOME, ANOTHER, YET_ANOTHER }

fun getFooData(): FooResult {
    val someData = try {
        apiClient.getSomeData()
    } catch (_: SomeException) {
        return Error(ErrorType.SOME)
    }
    val anotherData = try {
        unreliableRepository.getAnotherData(someData)
    } catch (_: AnotherException) {
        return Error(ErrorType.ANOTHER)
    }

    return try {
        unreliableRepository.getYetAnotherData(anotherData)
    } catch (_: YetAnotherException) {
        Error(ErrorType.YET_ANOTHER)
    }
}

このような場合は Success に対してだけ動作を適用するような関数 flatMap を利用すると見通しが良くなることがあります。まずは、FooResultflatMap を定義します。

sealed class FooResult<T> {
    class Success<T>(val value: T): FooResult<T>()
    class Error<T>(val errorType: ErrorType): FooResult<T>()

    @Suppress("UNCHECKED_CAST") // ...
    fun <U> flatMap(action: (T) -> FooResult<U>): FooResult<U> = when (this) {
        is Success -> action(value)
        is Error -> this as FooResult<U>
    }
}

さらに、getSomeData などの補助的な関数内を定義し、例外を FooResult に変換します。

private fun getSomeData(): FooResult<SomeData> = try {
    FooResult.Success(apiClient.getSomeData())
} catch (_: SomeException) {
    FooResult.Error(ErrorType.SOME)
}

このようにして、例外から変換された FooResult.Error も通常ケースのように取り扱うことができ、関数の流れが明確になります。

fun getFooData(): FooResult = getSomeData()
    .flatMap(::toAnotherData)
    .flatMap(::toYetAnotherData)

一言まとめ: 早期リターンを使う前に、エラーケースと通常ケースを統合できないかを考える。

キーワード: early return, error case, function flow