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