Why Kotlin? An Introduction. (Part 5)

Jumps

Why Kotlin? An Introduction. (Part 5)

Jumps

It has been a little too long since my last post, but it's been a busy couple of months as I've been pushing hard to get some of my side projects moving along. Be on the lookout in the future as I plan to start going into more Android focused posts.

If you're familiar with C your mind probably just jumped (pun intended) to a bad place. Don't worry though, Kotlin's implementation isn't anything as divisive as Dijkstra's or Rubin's arguments. Kotlin locks its jump implementation to loops and returning from functions, so there isn't much of a reason to worry about abnormal or undefined behavior. However, it's still good to give a little scrutiny to your implementation before deciding to use a jump inside of a loop.

So lets start with a scenario. We have two ranges, we want to know when the value of range B exceeds the index of range A for the first time when looping over B size of A times:

fun loopy() : String {
    for (rangeAVal in 4 downTo 0) {
        for (rangeBVal in 2 downTo 0) {
            if (rangeBVal > rangeAVal) {
                return "rangeBVal: $rangeBVal, rangeAVal: $rangeAVal"
            }
        }
    }
    
    return "A is always >= B"
}

In the above example we're looking at a very simple nested for loop with the outer loop looping from 4 to 0 and the inner loop looping from 2 to 0. If rangeBVal is greater than rangeAVal then we return those values as a string. The output will be:

rangeBVal: 2, rangeAVal: 1

This is fine, and in many cases may be what you want, but what if you wanted to parse the return values in the same function? You'd either need to break twice or (worse) finish both loops.

So how can we simplify this?

Kotlin's jumps are a great way to simplify our previous example. From the Kotlin documentation, Kotlin has three jump expressions:

  1. return. By default returns from the nearest enclosing function or anonymous function.
  2. break. Terminates the nearest enclosing loop.
  3. continue. Proceeds to the next step of the nearest enclosing loop.

This looks pretty similar to most languages, right? Well, Kotlin adds the ability to place labels to better direct your control flow in these situations. You can label a loop using the [label name]@ syntax and then jump to that label using the @[label name] syntax. More simply, labels are defined to the left of @ and referenced to the right of @.

Knowing this now, how can we achieve our desired outcome with Kotlin's jumps? Easy:

fun loopyBreak() {

    var printValue = ""
    loopA@for (rangeAVal in 4 downTo 0) {
        for (rangeBVal in 2 downTo 0) {
            if (rangeBVal > rangeAVal) {
                printValue = "rangeBVal: $rangeBVal, rangeAVal: $rangeAVal"
                break@loopA
            }
        }
    }

    println(printValue)
    
    // parse or modify printValue below as necessary...
}

Try to think about what's happening before you continue reading. Hint: the output will now be:

rangeBVal: 2, rangeAVal: 1

Notice the loopA@ label and the break@loopA jump. Normally we'd have to do this in another function and return printValue or, maintain some state in loopA to determine if our condition in the inner loop evaluated to true so we could then also break from loopA. However, by leveraging Kotlin's ability to jump to a label, we set our value in the inner loop and then initiate a break from loopA while still in the inner loop, giving us our desired printValue.

Grouping it together

So now that we have a basic understanding of jumps in Kotlin, lets demonstrate a series of use cases:

fun forEachLoops() {
    val range = 0..4
    
    println("return to label `loop@`")
    run loop@{
        range.forEach {
            if (it == 2) return@loop
            print("$it ")
        }
    }

    println("\nreturn in loop to the enclosing `forEach`")
    range.forEach {
        if (it == 2) return@forEach
        print("$it ")
    }

    println("\nreturn to outer loop at `loop@` from nested loop")
    range.forEach loop@{
        range.forEach {
            if (it == 2) return@loop
            print("$it ")
        }

    }

    println("\nreturn from function")
    range.forEach {
        if (it == 2) return
        print("$it ")
    }

    throw RuntimeException("We shouldn't reach this")
}

The print statements for each of these cases should be pretty self explanatory, but if you'd like to step through what is happening, the output of this function is:

return to label `loop@`
0 1 
return in loop to the enclosing `forEach`
0 1 3 4 
return to outer loop at `loop@` from nested loop
0 1 0 1 0 1 0 1 0 1 
return from function
0 1

It's pretty easy to see the advantage here, especially when looping inside of lambdas like forEach where break and continue statements are not allowed.

Powerful but not always best

Jumps are powerful, but make sure you're using the right tool for the right job. If you run into a situation where you believe the use of labels and jumps is necessary, closely evaluate what you're doing. For many purposes, the original example is probably close to the best way to implement the scenario it was designed to solve. The use of the label and jump saved us from having two different functions, but at the cost of readability and maintainability. For example:

// original example
fun loopy() : String {
    for (rangeAVal in 4 downTo 0) {
        for (rangeBVal in 2 downTo 0) {
            if (rangeBVal > rangeAVal) {
                return "rangeBVal: $rangeBVal, rangeAVal: $rangeAVal"
            }
        }
    }

    return "A is always >= B"
}

// an optimized version of loopy...
fun loopyOptimized() : String {
    val rangeA = (4 downTo 0)
    val bMax = 2

    rangeA.forEach {
        if (bMax > it)
            return "rangeBVal: $bMax, rangeAVal: $it"
    }
    return "A is always >= B"
}

fun callingFunction() {
    val loopyResult = loopy()
    // handle loopyResult
    println(loopyResult)
}

By encapsulating the two for loops into a single function (loopy) and returning the value from within the loop, we abstract the logic from callingFunction, which makes it easier to read and both functions easier to test in isolation. Additionally, once we get around to refactoring loopy to loopyOptimized, we'd only have to replace and test the logic of the looping function and not callingFunction, just which makes automated testing easier, and code reviews significantly easier.

This isn't to say labels and jumps aren't a powerful tool that should be utilized, but as with all new tools, we need to ensure we understand when to use them and when we shouldn't.

comments powered by Disqus