Hello, I'm Munetoshi Ishikawa, a mobile client developer for the LINE messaging app.
This article is the latest installment of our weekly series "Improving code quality". For more information about the Weekly Report, please see the first article.
Too early to return
Early return is an important technique for clarifying the flow of operations. By excluding error cases first, it becomes easier to write code that focuses on the "main purpose of the function". It also makes the relationship between error conditions and their handling logic clearer. (For more details, refer to slides 39 to 48 from my Code readability: Session 5 presentation.)
The following code takes a list of user IDs and returns a list of usernames. Early return is applied here, but is there a problem?
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()
}
Include rather than exclude
Early return should not be applied unconditionally in all cases. Whether to apply early return depends on how different the processing is between error cases and normal cases. If the processing is the same for both, it may be easier to simplify the code by treating the "error" as a normal case. In this case, by considering userIds.isEmpty()
and userIds.size == 1
as normal cases, the processing can be unified.
fun getUserNames(userIds: List<UserId>): List<String> =
userIds.asSequence()
.map(repository::getUserData)
.map(UserData::name)
.toList()
(However, this code performs worse for size == 0
or size == 1
because it creates a Sequence
instance and a new List
instance with Sequence.toList
in both cases.)
Below are cases where it might be better to treat them as normal cases.
Scanning empty collections
Higher-order functions like map
, filter
, and forEach
that scan collections are effective even for empty collections in many languages and libraries. Functions like sum
and reduce
that perform folding often behave naturally with empty collections.
val empty: List<Int> = emptyListOf()
empty.all { false } // => true
empty.any { true } // => false
empty.sum() // 0
null
Some languages have an operator called the safe call operator ?.
, which is useful for treating null
(nil
, undefined
) as a normal case. For example, the early return with == null
can be included in the normal case with ?.
.
// Before
fun function(foo: Foo?): Bar? {
if (value == null) {
return null
}
return value.toBar()
}
// After
fun function(foo: Foo?): Bar? =
value?.toBar()
Additionally, some languages allow using the Elvis operator ?:
or the null coalescing operator ??
to fall back to a default value. Falling back to a default value with ?: 0
or orEmpty
is a good option, but be careful not to re-branch with the converted default value (refer to When there is null, there is smoke).
Out-of-range arrays or lists
If there is code that checks the index range first, you can use functions like getOrNull
or getOrElse
instead of returning early when out of range. Using getOrNull
can address the null
issue mentioned above.
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)
If there is no equivalent to getOrNull
or getOrElse
in the standard library, it might be good to define them as generic functions.
Properties dependent on other properties
There are situations where "a property only has meaning when another property has a specific value". For example, consider a UI element called textView
, where textView.isVisible
indicates whether it is visible, and textView.text
indicates the text content. In this case, text
only has meaning when isVisible
is true
.
In the following code, if someText
is an empty string, textView
is hidden, and text
is not updated.
if (someText.isEmpty()) {
textView.isVisible = false
return
}
textView.isVisible = true
textView.text = someText
Such early returns to exclude meaningless assignments can sometimes be removed. When isVisible
is false
, it might not matter what value text
has. In that case, you can unify the processing for true
and false
as follows:
textView.isVisible = someText.isNotEmpty()
textView.text = someText
Exceptions in a series of function calls
When exceptions occur at various points in a function, applying early return can make the code cumbersome.
For example, in the following code, someData
is converted to anotherData
and then to yetAnotherData
, but exceptions may occur along the way, and in such cases, it returns.
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)
}
}
In such cases, using a function like flatMap
that applies operations only to Success
can improve readability. First, define flatMap
in FooResult
.
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>
}
}
Next, define helper functions like getSomeData
to convert exceptions to FooResult
.
private fun getSomeData(): FooResult<SomeData> = try {
FooResult.Success(apiClient.getSomeData())
} catch (_: SomeException) {
FooResult.Error(ErrorType.SOME)
}
By doing this, FooResult.Error
converted from exceptions can also be treated as a normal case, making the function flow clearer.
fun getFooData(): FooResult = getSomeData()
.flatMap(::toAnotherData)
.flatMap(::toYetAnotherData)
Summary
Before using early return, consider whether error cases and normal cases can be integrated.
Keywords: early return
, error case
, function flow