こんにちは。コミュニケーションアプリ「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 == 0
や size == 1
の場合の性能は低下します。どちらの場合も、Sequence
のインスタンスを作り、Sequence.toList
でも新たな List
インスタンスを作るためです。)
以下、通常ケースとして取り扱ったほうがよいこともあるケースを紹介します。
空コレクションの走査
map
、filter
、forEach
といったコレクションを走査する高階関数は、多くの言語やライブラリで、空のコレクションに対しても有効です。また、sum
や reduce
のような畳み込みを行う関数も、空のコレクションに対して自然に振る舞うことが多いです。
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 結合演算子 ??
を使える言語もあります。?: 0
や orEmpty
などと、デフォルト値にフォールバックするのは良い選択肢の一つですが、その場合は一旦変換したデフォルト値で再度場合分けをしないように注意してください (火の null
所に煙は立た null
を参照)。
配列やリストのレンジ外
最初にインデックスの範囲を調べるようなコードがある場合は、レンジ外のときに早期リターンするよりも、getOrNull
や getOrElse
といった関数を使えることがあります。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)
標準ライブラリなどで getOrNull
や getOrElse
に相当するものがない場合は、汎用的な関数として定義するのも良いでしょう。
別のプロパティに依存するプロパティ
「あるプロパティが特定の値のときだけ、別のプロパティが意味を持つ」という状況があります。例えば textView
という UI 要素があり、textView.isVisible
が表示されているかどうかを示し、textView.text
がテキストの内容を示しているとします。この場合、text
が意味を持つのは、isVisible
が true
のときだけです。
以下のコードでは、someText
が空文字列の場合は textView
を非表示にし、そのときは text
を更新していません。
if (someText.isEmpty()) {
textView.isVisible = false
return
}
textView.isVisible = true
textView.text = someText
このような、意味の薄い代入を除外するための早期リターンは、削除しても良い場合もあります。isVisible
が false
のときは、text
がどのような値になっても問題ないこともあるでしょう。その場合は、以下のように true
と false
の処理を統合できます。
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
を利用すると見通しが良くなることがあります。まずは、FooResult
に flatMap
を定義します。
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