🚀 · Considerations

7 min read · Updated on by

Read on to learn about an important missing Kotlin feature, which you should keep in mind when using Result, what the true performance cost of exceptions is, and how this relates to using Result. Also learn about things to keep in mind when using Result in a multithreaded or coroutine context.

In the previous articles, we introduced programming with Result and described how it makes your code easier to write, easier to reason about, and safer. In this article, we want to take a closer look at some of the things you should be aware of when using Result.

A missing Kotlin feature

Kotlin does not force you to handle the return values of functions.


//sampleStart
val myMutableList: MutableList<Int> = mutableListOf()

// This return Boolean, but we're ignoring it. 
// Kotlin doesn't mind.
myMutableList.add(2)
//sampleEnd
fun main() {
    val poem = """
        When you're sailing in the sea of code,
        Kotlin's syntax is the compass, the road.
        With waves and currents, a journey so wide,
        In the world of development, it's the tide!
    """.trimIndent()
    println(poem)
}

The above code does not issue a warning or a failure. This might not seem like a big problem at first but becomes very serious once you start using Result.


//sampleStart
fun alwaysThrows(): Unit = throw OutOfMemoryError()

// An exception gets thrown, which means the stack will unwind.
// This happens automatically, we don't need to write anything
// special for this to happen.
fun willNotIgnoreError() {
    alwaysThrows()
}

fun alwaysFails(): Result<Unit> = runCatching {
    throw OutOfMemoryError()
}

// An exception gets thrown but gets caught and wrapped in a
// Result instance. Since we're not processing the return value
// of alwaysFails, execution continues like nothing happened.
fun ignoresError() {
    alwaysFails()
}
//sampleEnd
fun main() {
    val poem = """
        In the code's carnival, Kotlin's the ride,
        With extension functions, it's the guide.
        From loops to spins, a coding spree,
        In the world of development, it's the key!
    """.trimIndent()
    println(poem)
}

In other words, when we use exceptions, execution never continues unless we specifically say it should (using a try/catch block). However, when we use Resultexecution always continues unless we specifically say it shouldn’t (by handling the return value somehow, e.g. calling getOrThrow in a runCatching block). This is a dangerous state of affairs because it allows you to completely ignore errors by accident, and is especially risky when using functions that return Result<Unit>. Returning Unit means that the function was only called for its side effect (e.g. writing to a file), and returning Result<Unit> means that this side effect can fail. Unfortunately, normally there’s no reason to handle Unit return values, so it can be easy to ignore Result<Unit> as well, which can have serious unwanted consequences.

Thankfully, it was recently announced that there are plans to remedy this and require return values to be handled, so we should soon see a Kotlin version where you can be sure no error falls through. Also, for the vast majority of functions, you do call functions for their return value, which makes it more likely you will handle the return value and react appropriately should a failure occur. But this is no guarantee, and until the above-mentioned change is implemented, this is probably the most important thing you need to keep in mind when using Result.

Performance

A paradigm that involves catching and re-throwing exceptions might have raised all sorts of alarms in your head — after all, exceptions are expensive (right?), and we’re advocating we should throw and catch them all over the place.

Before we address this, it is important to understand what the true cost of exceptions is. There is an excellent article by Aleksey Shipilёv which goes into the nitty-gritty details of exception performance, and which I highly recommend reading. If you do that, you will find that even the worst-case scenarios are measured on a scale of about 1 microsecond. So before you even start to think about this topic, ask yourself this: is a microsecond timescale (multiplied by occurrence frequency) significant for the use case I’m dealing with? If not (and unless you’re developing real-time trader bots, it probably isn’t), the following discussion is purely academic.

That being said, two things make exceptions potentially expensive:

  1. Building the stack trace when creating the exception (regardless of whether we throw it)
  2. Unwinding the stack when catching the exception at a different location

The cost of building stack traces is proportional to the stack depth, which can get pretty deep when using web servers (e.g. Tomcat) combined with web frameworks (e.g. Spring). The cost of unwinding the stack depends on how far up the stack we catch the exception when we throw it.

The impact of 1) will be the same with or without Result - the same Throwable instance is created either way. This can be mitigated by creating the instance without a stack trace, however, again, the performance will be the same regardless of whether you use Result or not. Also, more often than not, you’ll be using Result to handle situations where you are not the one throwing the exception, in which case you have no control over how it’s created anyway.

As for 2), working with Result implicitly involves moving the try/catch block as close to the place where the exception is thrown as possible. Remember, that was the whole point — to recover normal execution flow as quickly as we can. And as it turns out, by doing this, we are also effectively lowering the amount by which the stack needs to unwind. In situations where we control all the code, runCatching encloses the method the exception is thrown in. And upon closer inspection of the runCatchinggetOrThrow, and the various other functions described above, you will find that they are all inline. This means that the exception is thrown and caught in the same context, the stack is never unwound, and the effective cost is that of local branch.

In general, the golden rule of working with exceptions is this: exceptions should only be used (e.g. created and/or thrown) in exceptional scenarios. If this rule is observed, you have nothing to worry about, even when writing performant code.

Exceptions as control flow

Since using exceptions for control flow purposes is considered an anti-pattern in the vast majority of cases, you don’t usually have to worry when catching everything. However, there are…well…exceptions to this rule, and you must keep them in mind.

One of the simplest examples is InterruptedException, which arises in the context of multithreaded programming. Specifically, it is thrown when a thread is waiting, sleeping, or otherwise occupied, and the thread is interrupted, either before or during the activity.

Here’s a simple example:


//sampleStart
val childThread = Thread {
   try {
      // This will throw InterruptedException once 'childThread.interrupt()' bellow is executed in the parent thread
      Thread.sleep(1000)
   } catch (e: InterruptedException) {
      Thread.currentThread().interrupt() // Set the interrupt flag on the thread (considered good practice)
      System.err.println("InterruptedException caught!")
   }
}

childThread.start()
childThread.interrupt()
//sampleEnd

InterruptedException is used for control flow - it gets thrown by many blocking operations when the thread gets interrupted and this can be part of a legitimate business scenario (e.g. user cancels an operation) and should be handled, e.g.:


//sampleStart
// Just for demonstration purposes, don't use this in production
fun <T: Any> asCancellableAction(action: () -> T, onCancel: () -> Unit) = object : Thread() {
   lateinit var result: T
   override fun run() {
      try {
         result = action()
      } catch (e: InterruptedException) {
         // Set the interrupt flag on the thread (considered good practice)
         Thread.currentThread().interrupt()
         onCancel()
      }
   }
}

// Renders progress in a separate thread
asCancellableAction( {
    while(!isCompleted()) { 
        renderProgress()
        Thread.sleep(Constants.PROGRESS_RENDER_PERIOD)
    }
}, {
    renderActionCancelled()
}).start()
//sampleEnd

So far, so good. However, we would run into a problem if we used asCancellableAction on an action that used runCatching, e.g. something like this:


//sampleStart
fun action() = runCatching {
   val value = calculateValue()
   // This is a blocking operation that, among other things, checks
   // if the thread is interrupted and throws InterruptedException
   persistToMongo(value)
}

asCancellableAction(
   ::action,
   ::renderActionCancelled
).start()
//sampleEnd

Even if we interrupted the thread, renderActionCancelled would never get called, because the runCatching would swallow the InterruptedException.

An almost identical scenario happens with Kotlin coroutines, which use CancellationException to implement cancellation. And
there are other situations where exceptions are used as a control flow construct.

What this means for you is that you need to think about the code you’re wrapping in runCatching and, if it turns out this is something you need to deal with, just roll your own variants of the functions:


//sampleStart
inline fun <R> runCatchingInterruptible(block: () -> R): Result<R> = try {
   Result.success(block())
} catch (e: InterruptedException) {
   throw e
} catch (e: Throwable) {
   Result.failure(e)
}

// etc
//sampleEnd

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.

The Kotlin Primer