Kotlin should allow to break lambda and local functions

Exactly, and we can also make a similar exception in a special way in lambda cancellable scope only.

The code is generic, isn’t it? Don’t know why it is only for specific cases?

Yes, we can. Assuming that this 3rd party library and all other associated code, somehow will agree to treat our magic exception in a special way and will implement this special treatment. I think you said you can’t control how this 3rd party code works.

No, it is specific: it calls the lambda in-place and without any exception handling. As said earlier, lambda could be passed to some other object, it could be executed in another thread, at another time, etc. 3rd party function can also catch exceptions and then you won’t be able to pass through it.

cancellable scope and the exception is in our control, not the library. Library doesn’t know about them at all.

Understand the problem now. But isn’t that also a problem of coroutines? It would be nice if you could explain the differences when coroutine is canceled. Maybe I do not understand coroutine deeply enough.
I am thinking that, if we can cancel a coroutine, why we cannot do it with a lambda? Can we find a similar solution for lambda?

The problem is exactly the same with CancellationException. It works only because the code actively cooperates in propagating the exception. All functions from coroutines framework like launch(), coroutineScope(), runBlocking(), etc. are aware of this exception and treat it in a special way. When we write coroutine code, we are also aware (or we should be) of structured concurrency feature and we have to cooperate in order for it to work properly.

For example, if we execute passed lambda in another thread, cancellation won’t propagate. If we launch() it using another scope, it won’t propagate. If we run CPU-intensive calculations without checking isActive flag, our code will just ignore cancellations. If we catch a generic Throwable and consume it doing nothing, cancellations will be also consumed and ignored (at least until next launch() or similar function where it will be thrown again). Above code patterns are not disallowed in any way, but as a developer who writes coroutines code, we should be aware of consequences.

You wanted to do something much different: add a similar feature to the code that is not cooperating and is not even aware of your special exception. I doubt this is doable in general. Sometimes it will work, sometimes it won’t.

And even in the case of lambda invoked in-place, once again, exceptions are not really designed for this purpose. Throwing and catching is several times slower than just returning (I believe) and such pattern makes the code much harder to read and debug. CancellationException is a different case, because it is for situations where the code may be interrupted externally at any time and at any line of code. We could add cancellation checks at each line of code, but that would be impractical. In your case your own code controls that it needs to finish early, so it should just return, not throw. In other words: if you leave the office early, because you finished all your tasks for this day, then this is a different case than if you leave the office early, because there is a fire in the building.

But having said that
 yes, sometimes we have to use some 3rd party library, we don’t like its design and therefore we make some hacks or workarounds to force it to do what we need. Exceptions is one way to achieve this. But this is still hacking and should be avoided if possible.

4 Likes

I’m not convinced this should be in the stdlib (since arguably return in this way is not something that should be encouraged as a first choice), but I do think it’s a good start to find some other use-cases.

Here are my versions, renamed as “exitable”.

// Private to contain the use of this exception to here and here alone.
private class ExitableException(): Exception()

object Exitable {
    fun exit(): Nothing = throw ExitableException()
}

inline fun exitable(exitableAction: Exitable.() -> Unit) {
  try {
    Exitable.exitableAction()
  } catch (e: ExitableException) {
    // Ignored since the user chose to exit
  }
}

fun main() {
    exitable {
        (0..10).forEach {
            print(it)
            if (it > 2) exit()
        }
    }
}

Here’s another version that returns a value.

// Sadly can't make this private as the function is now used by an inline function.
// We could always make users Opt-in as it's an internal API. 
class ExitableException(val value: Any?): Exception()

class Exitable<T> {
    fun <T> exitWith(value: T): Nothing = throw ExitableException(value)
}

inline fun <reified T : Any?> exitable(exitableAction: Exitable<T>.() -> T): T {
  return try {
    Exitable<T>().exitableAction()
  } catch (e: ExitableException) {
    e.value as T
  }
}

fun main() {
    exitable<Unit> {
        (0..10).forEach {
            print(it)
            if (it > 2) exitWith(Unit)
        }
    }
    //sampleStart
    val selectedValue = exitable<Int> {
        (0..10).forEach {
            print(it)
            if (it > 2) exitWith(42) // Exit early with a found value--no need to continue processing.
        }
        return@exitable 0 // Return something else...
    }
    println("\nSelected = $selectedValue")
    //sampleEnd
}

I have a pretty poor memory for concurrent patterns. If you have multiple connections waiting for a single value, how would you branch out and halt a concurrent search once the first value is returned by one of the coroutines? Assuming the coroutines are just waiting on some external CPU blocking process to keep the comparison apples-to-apples.

To be fair, at this point, we’re just creating our own exception-based return. I imagine this would break a ton of places–primarily it would probably break in the places it would need to be used. If a library expects to run through, exiting early may have consequences.

Coroutines are a bit different in that they cancel at suspension points, however, I’d be interested to learn the specifics of any differences in a coroutine having to handle cancellation vs this, where structured exiting is enforced on the CancellationException/ExitException.

EDIT:
TL:DR

  • Using exceptions like this for control flow definitely fits as a code smell

  • OPs use-case seems like it might be an appropriate time to plug the nose and take this approach if alternatives don’t fit.
  • I wonder if coroutine cancellation exceptions share the same pitfalls as this kind of control flow and if there are some code examples to demonstrate.
2 Likes

I’m starting to see quite a similarity between the suggestions in this thread and Effects from Arrow-kt. So, to add my 2 cents:


    val result = either {
        (0..10).forEach {
            print(it)
            if (it > 2) shift(Unit)
        }
        "Success"
    }
    println(result.fold({ "Failure" }, { it }))
4 Likes

Ohh, thank you, this is interesting.

Their use case is very specific and different than OP’s. They try to hack the language to allow monads that are not provided by the Kotlin itself. They needed a way to return from a function without using return, to make the code cleaner. But still, I believe this is mostly to return from local functions that are aware of this functionality. We probably could use this to return from 3rd party not-inline functions, but I guess in that cases we are not guaranteed that it will work properly and the developer is expected to understand how 3rd party function works internally.

Anyway, I forgot that in Kotlin there is an alternative to exceptions for jumping out of the whole stack. That are suspend functions - they can do this without a performance hit of exceptions.

2 Likes

Thank you all for valuable comments from different point of views.
After our discussion until now, I totally agree with you guys: using exceptions to cancel a lambda is always a last choice for specific situations and should not be in stdlib.

I am thinking a bit further, based on the key concept of coroutine cancellation to find a solution: “co-operation.”

The usage side only allowed to send a cancellation signal to the implementation. The implementation side (library) decides when is the best time for the cancellation and could do something before cancellation (ex. clean up or revert changes).

If the implementation in library doesn’t support cancellation, we have no choice to hack it and be aware of the consequences.

Now, let’s say, I am the library owner and want to support cancellation, so users don’t need the hack. What is the next step if I would like to have a solution, that:

  • doesn’t require any public API change. So everything is backward compatibility for users, who don’t need cancellation.
  • doesn’t have side effects for various cases, ex. passing lambda around or running in different contexts.
  • doesn’t need to add any new dependency.

I think that we might find a solution for the requirements above if we understand the current implementation of coroutines. It would be really nice if Kotlin can have a standard solution to coordinate between the usage and implementation side of a lambda, similar to the coordination to cancel a coroutine.

Very interesting!
I didn’t know that, stdlib has kotlin.coroutines package, so we already have something to use for this case without the need of kotlinx.coroutines dependency.

I’ve also enjoyed thinking through things. The ArrowKt effects article is a good read.

Focusing on the concept of cooperation is useful. I have some questions that come to mind with that though.

In the case of ArrowKt effects, would there be anything to dissuade a user from shifting within any passed lambda like the node graph traversal, or maybe calling shift in a lambda that isn’t invoked until much later? And is that fine or is there any value in restricting where a shift/exit can happen (such as a DSL marker annotation). My guess is that in cases where the shift is never called or called much later, the context of the effect would guarantee to not exit without returning something even if shift isn’t invoked–and if it is invoked much later, a mechanism like suspending functions would be needed to guarantee the shift is being invoked within some other effect context

Which at this point we’ve arrived at coroutines and suspending functions but it’s interesting to think through. After all a suspending function call, especially with how it cooperatively cancels, is functionally a mandatory exit point and option resume point.

1 Like

There is a discussion about including CancellationException to stdlib: https://youtrack.jetbrains.com/issue/KT-39126

1 Like

Of course, there is kotlin.coroutines package. It includes very basic primitives on which kotlinx.coroutines are built. but it doesn’t have CancellationException, because coroutines cancellation is not a language part, cancellation is purely kotlinx.coroutines library feature, there was some discussion about moving it to a separate dependency to allow cancellation be used across different coroutines libraries (such as arrow)

Just throwing my half informed opinion in this thread


I’d think using an (Cancellation) exception for non exceptional cases should be avoided whenever possible.

I’d think the 3rd party should have implemented a yield in their flow, handing over the control of the iteration/flow to the caller.