What is the difference between onCompletion and try/finally for Flows?

Viewed 538

The documentation on Flow describes why emit should never be run in a try/catch block:

Flow implementations never catch or handle exceptions that occur in downstream flows. From the implementation standpoint it means that calls to emit and emitAll shall never be wrapped into try { ... } catch { ... } blocks. Exception handling in flows shall be performed with catch operator and it is designed to only catch exceptions coming from upstream flows while passing all downstream exceptions... The same reasoning can be applied to the onCompletion operator that is a declarative replacement for the finally block.

However, the documentation for onCompletion specifies

Unlike catch, this operator reports exception that occur both upstream and downstream and observe exceptions that are thrown to cancel the flow. Exception is empty if and only if the flow had fully completed successfully.

So: what is the difference between using finally and onCompletion, if it's not transparency to downstream exceptions? There are many use cases where onCompletion would be extremely difficult to use, notably any "try-with-resources"-based flow where there is state not passed along in the flow.

Specifically, I would be satisfied with a piece of code demonstrating a behavior difference.

2 Answers

As you said, both onCompletion and finally will be run in case of upstream exception, downstream exception, and normal completion. In that sense, they have no difference in behaviour (to my knowledge), so I can't craft an example to exhibit a difference.

One addition though is that onCompletion's lambda gives access to a FlowCollector which can emit() further elements to the downstream flow (when no exception occurred).

I guess apart from that, the only difference here is style. Just like .filter{} would be the same as if(condition) emit(it).

My conclusion after using both is that:

  • onCompletion is useful when the cleanup code needs to know whether the flow succeeded or failed
  • try/finally is useful when you always want to run the exact same cleanup regardless of whether the flow succeeded or failed

This is because onCompletion receives the failure reason as a parameter, or null if the flow finished normally without an exception.

myFlow.onCompletion { failure ->
    if (failure == null) println("Success") else println("Failure")
}

From the docs:

The key advantage of onCompletion is a nullable Throwable parameter of the lambda that can be used to determine whether the flow collection was completed normally or exceptionally.

https://kotlinlang.org/docs/flow.html#declarative-handling

To achieve the same thing with try/catch is a bit more verbose (and actually needs a catch instead of a finally:

try {
    collect()
} catch (t: Throwable) {
    println("Failure")
    throw t
}
println("Success")

You mentioned try-with-resources. I think it's important to note the difference between try/finally in a flow builder and try/finally in a flow collector. The former is useful for closing resources used by the flow; the latter is more analogous to onCompletion.

The example above shows a try/catch in a flow collector. It could be easily replaced with try/finally if you don't need to know whether it succeeded or failed. On the other hand, using finally for cleanup in a flow builder would look like this:

flow {
    val myResource = openResource()
    try {
        myResource.values.forEach { emit(it) }
    } finally {
        myResource.cleanup()
    }
}

In reality you'd probably do this with use rather than try/finally. As you noted, it isn't something you could do with onCompletion (or a try/catch/finally in the flow collector), because it relies on state that lives inside the flow builder. I think it's one of the great benefits of flows that each stage of the flow can encapsulate its setup and cleanup in this way.

In actual fact, onCompletion sort of combines the flow collector and flow builder examples above. That's because in the case where the flow terminated without an exception, onCompletion can emit new values. The fact that it can't emit values when the flow failed with an exception is an artificial restriction that, when you look at the source code, actually complicates the implementation.

By writing our own flow operator we can overcome that constraint:

fun <T> Flow<T>.finally(block: suspend FlowCollector<T> -> Unit) {
    try {
        emitAll(this@finally)
    } finally {
        block(t)
    }
}

With the above operator, someFlow.finally { emit(...) } will always emit a new value, even if the flow failed with an exception. I'm not sure whether it violates the prohibition on wrapping emit with try/catch, but it doesn't feel like a particularly good idea.

Related