The original article was published on January 9, 2025.
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.
Climb the function mountain with many notifications
When a function is executed synchronously, the common way to return the result to the caller is by using a return value. On the other hand, if you want to notify the caller of the result of asynchronous processing, you need to use the following techniques:
- Block the caller's execution until the result is obtained (≈ make it synchronous)
- Use language features for asynchronous processing (such as async/await or coroutine)
- Return a promise (
Promise/Future/Single) or stream (Flow/Observable) as a return value - Pass an asynchronous callback as an argument
Here, we assume a class called Future<T> as an example of a promise. The result T held by Future<T> can be accessed using a method called then. In the following code, emitter.emit returns the username asynchronously, and the processing is defined within caller. doAsync in getUserNameAsync is executed asynchronously, and by calling emitter.emit, the result is passed to the callback of then.
fun caller() {
val userId = ...
getUserNameAsync(userId)
.then { name -> println("Name: $name") }
}
fun getUserNameAsync(userId: UserId): Future<String> {
...
return doAsync { emitter ->
val userName = ...
emitter.emit(userName)
}
}
Suppose you need error handling for when the username retrieval fails. A developer implemented the error handling code as follows:
fun caller() {
val userId = ...
getUserNameAsync(userId, onError = { println("ERROR!") })
.then { name -> println("Name: $name") }
}
fun getUserNameAsync(
userId: UserId,
onError: () -> Unit
): Future<String> {
...
return doAsync { emitter ->
val userName = ...
if (userName != null) {
emitter.emit(userName)
} else {
onError()
}
}
}
Is there any problem with this code?
Limit the number of captains
In the above code, multiple asynchronous processing mechanisms, such as Future and asynchronous callbacks, are used simultaneously, making it difficult to understand how and when the results are notified. For a single function, it is better to limit the asynchronous processing method to one as much as possible for clearer code. In this case, since Future<T> is already being used, it would be better to also use Future<T> for error notification.
To achieve this, the following code defines a result indicator NameQueryResult.
sealed interface NameQueryResult {
data class Success(name: String) : NameQueryResult
data object Failure : NameQueryResult
}
fun caller() {
val userId = ...
getUserNameAsync(userId).then { result ->
val message = when (result) {
is NameQueryResult.Success -> "Name: ${result.name}"
NameQueryResult.Failure -> "ERROR!"
}
println(message)
}
}
fun getUserNameAsync(userId: UserId): Future<NameQueryResult> {
...
return doAsync { emitter ->
val userName = ...
val result = if (userName != null) {
NameQueryResult.Success(userName)
} else {
NameQueryResult.Failure
}
emitter.emit(result)
}
}
This approach has the following advantages:
- The caller can choose whether to integrate or separate the handling of normal cases and errors.
- It becomes easier to ensure that
emitter.emitis called exactly once.
(If Future itself has a way to handle error values, using that mechanism is also an option.)
When using async/await or coroutine, or when the return value is a Promise or Future, it is rarely necessary to have asynchronous callbacks as arguments. When adding asynchronous results, check if there is an existing asynchronous mechanism.
In a nutshell
Limit the asynchronous processing mechanism to one per function.
Keywords: asynchronous, coroutine, callback