Kotlin should allow to break lambda and local functions

Problem

Assume the code below is provided by a 3rd party library:

class GraphNode(val value: Int, val neighbors: List<GraphNode> = emptyList()) {
  fun performComplexAlgorithmAndPerformActionOnEachVisitedNode(action: (visitedNode: GraphNode) -> Unit) {
    // Assume the logic is very complex with a lot of code here
    action(this)
    neighbors.forEach { node -> node.performComplexAlgorithmAndPerformActionOnEachVisitedNode(action) }
  }
}

Because the code is from a 3rd party library,

  • we cannot change it to inline function.
  • we cannot change the return type of action lambda and the logic to cancel the computation.

Even if we are the owner of the lib, the function is too large with a lot of code, we don’t want to use inline function.

Assume we have a complex graph with thousands or even more nodes, so we want to break the computation immediately when a condition is matched.

fun main() {
  val rootNode = GraphNode(0, (1..10000).map { GraphNode(it) })

  rootNode.performComplexAlgorithmAndPerformActionOnEachVisitedNode {
    print(it.value)
    // TODO: how to break if it.value >= 2
  }

  println("END")
}

Expected output:

012
END

My ugly workaround 1

fun main() {
  val rootNode = GraphNode(0, (1..10000).map { GraphNode(it) })

  runBlocking {
    launch {
      rootNode.performComplexAlgorithmAndPerformActionOnEachVisitedNode {
        print(it.value)
        if (it.value > 1) throw CancellationException()
      }
    }
  }

  println("END")
}

The workaround is ugly, because

  • it forces to add kotlinx.coroutines dependency to the current code. If we are writing a library, we don’t want to add overhead if we don’t need to.
  • it’s over complex for a common and simple problem
  • code is added is unrelated to the original problem. Why on earth, do we need concurrency (runBlocking and launch) just to break a normal method call?
  • it doesn’t work well in case the method performComplexAlgorithmAndPerformActionOnEachVisitedNode want to return a value. Of course, we can have a more complex logic to handle the return value, but as I mentioned, it’s over complex to do that with this workaround.

My ugly workaround 2

Introduce a my own new exception and use try-catch for this exception.

Conclusion

This is a very common problem, Kotlin should provide an elegant way to return and break the computation for this case.

Why not use just return@performComplexAlgorithmAndPerformActionOnEachVisitedNode? (or explicitely define a label Returns and jumps | Kotlin )

Because it’s currently not allowed in Kotlin. You will get this compiler error if you do that: “‘return’ is not allowed here”.

See Inline functions | Kotlin (kotlinlang.org) and Inline Functions in Kotlin | Baeldung on Kotlin if you don’t know about this error.

2 Likes

I know about that error, didn’t notice it applies to this situation, sorry :slight_smile: .That makes sense though, allowing a non-local return is ugly itself. I don’t think “Introduce a my own new exception and use try-catch for this exception.” is an ugly workaround, just a solution to that problem. But maybe that’s just me.

2 Likes

I think we should somehow learn the way we cancel a coroutine job for this case. I expect this problem should be natively supported with a standard approach in Kotlin, easy and simple for developers without inventing their own extra stuffs. It should be simple and ready to use like when we want to break a loop or cancel a coroutine job.

I’d add that it’s a significant code smell to use an exception for non-exceptional behavior.

Why not allow a function to be in scope within the lamda, something like this:

fun main() {
  val rootNode = GraphNode(0, (1..10000).map { GraphNode(it) })

  rootNode.performComplexAlgorithmAndPerformActionOnEachVisitedNode {
    print(it.value)
    if (it.value > 1) haltFurtherAction()
  }

  println("END")
}

Or possibly even better, just return something, maybe a boolean to continue processing:

fun main() {
  val rootNode = GraphNode(0, (1..10000).map { GraphNode(it) })

  rootNode.performComplexAlgorithmAndPerformActionOnEachVisitedNode {
    print(it.value)
    return if (it.value < 2) true else false
    // TODO: how to break if it.value >= 2
  }

  println("END")
}

It’s not possible, please read the problem again.

Because the code is from a 3rd party library,

  • we cannot change it to inline function.
  • we cannot change the return type of action lambda and the logic to cancel the computation.

In the code example, you are only allowed to do anything you want inside the main method. Everywhere else is read-only code from a 3rd party library.

Your code doesn’t work without making changes inside the read-only performComplexAlgorithmAndPerformActionOnEachVisitedNode method.

1 Like

Ah! Thanks, I did miss that somehow :man_facepalming: I’ll think about it more.

One quick idea is to wrap the function and create your own logic for skipping further processing. That would allow you to change the return type or add lambda member functions and skip the meat of the logic on further nodes.

On mobile now, otherwise I’d type an example.

EDIT:
Even if you couldn’t use extension functions to make the API appear clean for some reason (for example of you’re trying to solve this in java instead of Kotlin), I think there will be solutions within the current language feature set. If a language change is needed, we’ll at least need to gather the current solutions to compare alternatives.

1 Like

If I understood your case correctly, then what you try to do here is to force 3rd party code to do what it was not designed to do. Your case is not possible to do without some weird hacks, because performComplexAlgorithmAndPerformActionOnEachVisitedNode just doesn’t support finishing early. Kotlin can’t and shouldn’t really fix limitations in APIs of various libraries.

10 Likes

I honestly really don’t understand what’s the problem here. It’s not limitation of the library, it’s the limitation of Kotlin.

I think @broot hit the bull’s eye here. Even though you’re working with code defined externally, most of the time we customize it by wrapping it with our tooling. This is especially true when using a Java library from Kotlin–just like how you’ll usually define a handful of functions and wrappers to adapt the API into something easier to work with in Kotlin, we can do the same here.

Basically, since we’re not limited to the library API, pick a new API we prefer and adapt it.

Here’s a quick runnable example of returning a boolean to continue processing nodes.

//sampleStart
fun main() {
  val rootNode = GraphNode(0, (1..10).map { GraphNode(it) })

  rootNode.visitNodes {
      print(it.value)
      return@visitNodes (it.value <= 2)
  }

  println("END")
}
//sampleEnd

fun GraphNode.visitNodes(action: (visitedNode: GraphNode) -> Boolean) {
    var continueProcessing = true
    this.performComplexAlgorithmAndPerformActionOnEachVisitedNode {
      if (continueProcessing) {
          continueProcessing = action(it)
      }
    }
}

// ---
// Assume this is an external library and we CANNOT change it.
class GraphNode(val value: Int, val neighbors: List<GraphNode> = emptyList()) {
  fun performComplexAlgorithmAndPerformActionOnEachVisitedNode(action: (visitedNode: GraphNode) -> Unit) {
    // Assume the logic is very complex with a lot of code here
    action(this)
    neighbors.forEach { node -> node.performComplexAlgorithmAndPerformActionOnEachVisitedNode(action) }
  }
}

I might even say you could use the custom exception trick to jump out of processing further nodes in some cases. Of course, you’ll want to make sure the library behaves doing that.

1 Like

Your case here is really: foo() invokes bar() which invokes baz(). Now you want to somehow jump out of baz() straight to foo(), with skipping whatever code was in bar(). I don’t know even a single language that allows this outside of exceptions.

Only because the code of the lambda is “visually close” to the code inside main() in the source file, doesn’t mean these two code blocks are close to each other at runtime. In practice, they could run at totally different execution contexts, in different threads or even at a different time.

4 Likes

This request would likely cause lots of issues and be a code smell to use. The reason the exception handling to break out of some code is hacky is that we’re using it to perform a jump. Doing a jump out of a library’s code should look ugly–it’s a good thing that it stands out.

At least with exceptions, the library has the option to catch it before you and perform handling on it–a pure jump out would skip this option and the library wouldn’t have an option to wrap up its control flow as it does with exceptions.

For this reason, I think your best option is to perform the jump is to use a custom exception (for the clarity that you intend to use it as an exit condition), wrap the ugliness in a function so you don’t see it, and create a bunch of tests to confirm the library is able to handle early exiting well.

EDIT:
Here’s the runnable version using an exception. If you do something like this I’d recommend locking down the visibility of this code and heavily documenting it’s intended use, maybe change the name of the custom visitNodes function to mention its exceptional behavior.

//sampleStart
fun main() {
  val rootNode = GraphNode(0, (1..10).map { GraphNode(it) })

  rootNode.visitNodes {
      print(it.value)
      if (it.value > 1) haltProcessing()
  }

  println("END")
}
//sampleEnd

fun GraphNode.visitNodes(action: GraphNodeAction.(visitedNode: GraphNode) -> Unit) {
    try {
        this.performComplexAlgorithmAndPerformActionOnEachVisitedNode {
            GraphNodeAction().action(it)
        }
    } catch (e: HaltProcessingException) {
        // Ignored since we use this exception to exit.
    }
    
}

class GraphNodeAction {
    fun haltProcessing(): Nothing = throw HaltProcessingException()
}
// Private to help contain the usage of this type.
private class HaltProcessingException : Exception()

// ---
// Assume this is an external library and we CANNOT change it.
class GraphNode(val value: Int, val neighbors: List<GraphNode> = emptyList()) {
  fun performComplexAlgorithmAndPerformActionOnEachVisitedNode(action: (visitedNode: GraphNode) -> Unit) {
    // Assume the logic is very complex with a lot of code here
    action(this)
    neighbors.forEach { node -> node.performComplexAlgorithmAndPerformActionOnEachVisitedNode(action) }
  }
}
2 Likes

@arocnies, @broot:

I don’t know even a single language that allows this outside of exceptions.

Agree that we should use exceptions for this case. Using exceptions will tell developers that your action could be dangerous and you should know the consequence of what you are doing.
Actually, coroutine CancellationException is exactly the solution to break coroutines. So if we don’t have problem with this solution in coroutine, I don’t see the reason to not doing the same with lambda.

I ended up with this extension:

class CancellableException: Exception()

inline fun cancellable(cancellableAction: () -> Unit) {
  try {
    cancellableAction()
  } catch (e: CancellableException) {
    // cancel action
  }
}
cancellable {
  rootNode.performComplexAlgorithmAndPerformActionOnEachVisitedNode {
    print(it.value)
    if (it.value > 1) throw CancellableException()
  }
}

Actually, the cancellable and CancellableException above are similar to coroutine CancellationException.

Because this is the native way to break coroutines, I think, we can also make it the native way to break lambdas in Kotlin.

2 Likes

Nice! Even better than my quick runnable example since this is generic. And of course all without having to introduce a new form of jumps/returns to the language :+1:

1 Like

Yeah, and it would be nice if it is included natively in kotlin std lib because it’s consistent with the current language design of coroutines.

Gotcha. I’m happy that it’s turned into a stdlib request–much easier to swallow than a language design change. I’m not sure about the process for proposing stdlib changes (probably just a KEEP). At least in this case it’s an easy thing to add and use by itself in order to collect use cases in the real world.

1 Like

No, this is not at all consistent with the rest of the language. Exceptions are for exceptional states, not for returning the data. They could be used for different kinds of hacks and workarounds, but this is not what they are for.

Moreover, what you ask for is just not possible to do, both for conceptual and technical reasons. It could work in specific cases only, but not in general. How do you expect below code to work?

fun foo() {
    println(1)
    runAfterAnHour {
        println(3)
        // return to foo() or throw Exception()
    }
    println(2)
}

How do you want to return from the lambda to the foo() if foo() finished executing an hour before we even get to return? Throwing an exception there will only crash some other, remote component in your application, but it won’t get you to foo().

Also, I’m not sure why do you want to use CancellationException from coroutines. CancellationException is not in any way magic. You can throw just any exception with exactly the same effect.

2 Likes

You need to wrap your block inside cancellable, similar to CancellationException, which should be wrapped inside a coroutine.

No, please read the way CancellationException works. It’s handled different than other exceptions by default inside coroutine. And note that, my code uses a similar exception CancellableException for demonstration. You don’t need to catch CancellationException if they are inside a coroutine scope. Similar, if you use my proposed cancellable and CancellableException, you don’t need to catch it too. The code becomes clean and reasonable. Of course, the code I proposed is just a naive version to explain the same logic of how to apply the same way of coroutines to normal lambda.

If we want something in lib, I would expect something better like this.

If you see that it’s not good, then I understand that you are somehow meaning that current CancellationException in coroutine is not good too.

As I said, it will work in specific cases only. This is impossible to do in generic case and Kotlin can’t anyhow provide such functionality.

It is handled in a special way by coroutines framework only. From the rest of the world it is just a regular exception. You can rewrite your cancellable with any other random exception and it will work exactly the same.