The original article was published on July 4, 2024.
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.
Return from the abyss
Early return is an effective technique to separate cases where a function achieves its main purpose (happy path) from cases where it does not (unhappy path), making the function flow easier to understand. However, careful consideration is needed on how to apply early return.
In the following code, return is used for unexpected cases of messageData.mainMimeType and failed saving of messageData.content, but it makes the code harder to read. How can it be improved?
fun ...(...) {
try {
val messageType = if (messageData.isParsed) {
messageData.messageType
} else {
when (messageData.mainMimeType) {
"image" -> MessageType.IMAGE
"video" -> MessageType.VIDEO
else -> return false
}
}
val saveResult = saveContent(messageData.content, messageType) // IOException may be thrown
if (!saveResult.isSuccessful) {
return false
}
return true
} catch (_: IOException) {
return false
}
}
Create a clear return path
The return in the previous function is hard to understand for the following two reasons:
- There are
returnstatements from deeply nested places or parts of branches (e.g., the firstreturn falseis insidetry,if, andwhen, and the conditions to reach there are not immediately clear) - The
returndoes not significantly contribute to separating the happy path from the unhappy path (e.g., thereturn falsefor!saveResult.isSuccessfulhas no subsequent actions)
When using early return, it is important to place return in prominent locations, make conditions clear, and highlight the happy path.
For example, by doing the following, it becomes clear that "early return occurs when messageType becomes null". Also, by looking at the last line, it is easy to understand that true is returned when the message is saved successfully.
fun ...(...) {
val messageType = messageData.messageType.takeIf { messageData.isParsed }
?: MAIN_MIME_TO_TYPE_MAP[messageData.mainMimeType]
?: return false
val saveResult = try {
saveContent(messageData.content, messageType)
} catch (_: IOException) {
SaveResult.Error()
}
return saveResult.isSuccessful
}
companion object {
val MAIN_MIME_TO_TYPE_MAP: Map<String, MessageType> = mapOf(
"image" to MessageType.IMAGE,
"video" to MessageType.VIDEO
)
}
Especially when using multi-branch structures like when or switch, writing code that returns for only some conditions can lead to overlooking the early return itself. For example, in the code converting Foo to Bar below, you might miss the return in the INVALID case.
fun ...(foo: Foo) {
val bar = when (foo) {
Foo.FIRST -> Bar.FIRST
Foo.SECOND -> Bar.SECOND
Foo.THIRD -> Bar.THIRD
Foo.INVALID -> return
}
...
}
This issue can be resolved by separating multi-branch structures and early returns. In the improvement proposal below, the code converting Foo to Bar is extracted as a function.
fun ...(foo: Foo) {
val bar = toBar(foo)
?: return
...
}
private fun toBar(foo: Foo): Bar? = when (foo) {
Foo.FIRST -> Bar.FIRST
Foo.SECOND -> Bar.SECOND
Foo.THIRD -> Bar.THIRD
Foo.INVALID -> null
}
This emphasizes that "early return occurs when Foo cannot be converted to Bar".
For more on early return, refer to the "Code Readability" presentation (slides 39-48).
In a nutshell
When using early return, make its presence, conditions, and the happy path stand out.
Keywords: early return, conditional branch, function flow