Skip to content
academia.sh

Lesson 23 / 23

Exception Handling

Measures the unchecked exception model, the try expression, Result and propagation in a coroutine, shows that two exceptions with the same message are not counted as equal, and closes the course.

Published Updated

In this lesson

  • Measures that Kotlin does not require declaring exceptions and that try is an expression that produces a value
  • Carries an error as a value with runCatching, Result, getOrElse and exceptionOrNull
  • Shows that inside coroutineScope the failure of one sibling cancels the other and propagates outward
  • Measures that two exceptions built with the same message are not counted as equal, and that a data class exception is compared by content
Contents

The previous lesson measured that two flows are not counted as equal. This last lesson asks, instead of a value, whether an error is equal: are two exceptions that carry the same message equal? First the error model of Kotlin is read. In Java a function declares with throws which exceptions it may throw, and the caller has to catch them. In Kotlin there is no such declaration; all exceptions count as unchecked exceptions, and catching is up to the caller. Throwing is done with throw, catching with try and catch. The measuring question is again the same: what makes these two values equal?

// Criterion.kt — classifies the criterion that makes two values equal
class Criterion(val label: String, first: Any?, twin: Any?, other: Any?) {
    val sameObject = first === twin
    val equalTwin = first == twin
    val equalOther = first == other

    val name: String = when {
        sameObject -> "singleton"
        !equalTwin -> "identity"
        !equalOther -> "content"
        else -> "partial-content"
    }

    fun report() {
        println("$label: same-object=$sameObject, twin=$equalTwin, other=$equalOther -> $name")
    }
}

fun summarize(checks: List<Criterion>) {
    for (name in listOf("singleton", "identity", "content", "partial-content")) {
        println("$name: ${checks.count { it.name == name }}")
    }
}

The Unchecked Exception and the try Expression

The block below measures two things. readDisk throws Java’s IOException, but there is no declaration in its signature and the compiler asks for nothing. try, on the other hand, is an expression: the block gives the value of its last expression, the catch branch gives its own value, and finally runs in every case but produces no value. The log list shows the order.

// Main.kt — unchecked exception, try expression and finally
import java.io.IOException

fun readDisk(): String {
    throw IOException("disk")
}

fun withdraw(balance: Int, amount: Int): Int {
    if (amount > balance) throw IllegalStateException("insufficient")
    return balance - amount
}

fun main() {
    val log = mutableListOf<String>()
    val left = try {
        log.add("try")
        withdraw(100, 500)
    } catch (e: IllegalStateException) {
        log.add("catch")
        -1
    } finally {
        log.add("last")
    }
    println(left)
    println(log)
    try {
        readDisk()
    } catch (e: IOException) {
        println(e::class.simpleName)
    }
    println(withdraw(100, 30))
}
-1
[try, catch, last]
IOException
70

The first line is -1: because withdraw threw, the value of the try block was discarded and the -1 of the catch branch was assigned to left. The log tells the order [try, catch, last]; finally ran last, after the value was settled. The third line gives the name IOException; it was caught because a catch was written, and the compilation would have passed even if it had not been. The last line shows that the path that does not throw returns 70.

CC35. These measurements are for try expressions with a single thread and no return. The result of a return or a new exception thrown from inside finally, closing a resource with use, and the Error classes were not measured.

runCatching and Result

To carry an error as a value instead of catching it, runCatching { } is used. If the block returned, the Result is successful; if it threw, it is failed. getOrElse gives a fallback value on failure, and exceptionOrNull gives null if successful and the exception itself if not. First, an exception class of its own is defined in a helper file; the main block builds a function that throws it.

// Errors.kt — an exception class of its own
class LimitException(val limit: Int) : Exception("limit exceeded")
// Main.kt — runCatching and Result
fun parse(text: String): Int {
    if (text.length > 3) throw LimitException(3)
    return text.toInt()
}

fun main() {
    val ok = runCatching { parse("42") }
    val badFormat = runCatching { parse("x") }
    val tooLong = runCatching { parse("12345") }
    println(ok.getOrElse { -1 })
    println(badFormat.getOrElse { -1 })
    println(ok.exceptionOrNull())
    println(badFormat.exceptionOrNull()?.let { it::class.simpleName })
    val error = tooLong.exceptionOrNull()
    if (error is LimitException) {
        println(error.limit)
    }
}
42
-1
null
NumberFormatException
3

Because ok is successful, getOrElse gave 42; because "x".toInt() threw NumberFormatException, the second line is the fallback value -1. The third line shows that on a successful result exceptionOrNull is null, and the fourth line shows the type of the exception on a failed result. The last line reads the limit value that the exception class of its own carries: after the is check, error becomes LimitException by smart cast.

CC36. These measurements are for runCatching calls with an Int result and a class under Exception. Chains of map, recover and onFailure, getOrThrow, and using Result as the return type of a function were not measured.

Propagation in a Coroutine

In structured concurrency, if a child coroutine fails, the scope is cancelled: the siblings receive the cancellation and the exception comes out of coroutineScope. In the block below the first child will wait a thousand milliseconds and the second will throw at once. Only the type of the exception caught outside is printed, not its message.

// Main.kt — propagation of an exception in a coroutine
import kotlinx.coroutines.*

fun main() = runBlocking {
    val log = mutableListOf<String>()
    val caught = try {
        coroutineScope {
            launch {
                try {
                    delay(1000)
                    log.add("done")
                } finally {
                    log.add("cancelled")
                }
            }
            launch {
                throw IllegalStateException("sibling")
            }
        }
        "none"
    } catch (e: Exception) {
        e::class.simpleName
    }
    println(caught)
    println(log)
}
IllegalStateException
[cancelled]

The exception caught outside is the IllegalStateException that the child threw. In the log there is only cancelled: the cancellation reached the waiting child, the finally block ran, and done was never written. The "none" line after the coroutineScope call was never reached. The exception goes past the scope of the coroutine and can be caught with an ordinary try.

CC37. These measurements are for a single coroutineScope, a single runBlocking and one failed child. supervisorScope, CoroutineExceptionHandler, and how the exceptions of several failed children are gathered were not measured.

The Criterion: Identity

Now the real question. Are two exceptions built with the same message equal? The Throwable class does not define equals, so it falls back to identity. The bounding measurements add Result and an exception written as a data class. Result.failure carries the exception inside: two separate exceptions make two separate Results, and the same exception makes two Results equal.

// Main.kt — the criterion of an exception and the bounding measurements
data class CodedError(val code: Int) : Exception("code")

fun main() {
    val shared = IllegalStateException("m")
    val checks = listOf(
        Criterion("exception", IllegalStateException("m"), IllegalStateException("m"), IllegalStateException("x")),
        Criterion("result-two-exceptions", Result.failure<Int>(IllegalStateException("m")), Result.failure<Int>(IllegalStateException("m")), Result.failure<Int>(IllegalStateException("x"))),
        Criterion("result-same-exception", Result.failure<Int>(shared), Result.failure<Int>(shared), Result.failure<Int>(IllegalStateException("x"))),
        Criterion("result-success", Result.success(1), Result.success(1), Result.success(2)),
        Criterion("data-class-exception", CodedError(7), CodedError(7), CodedError(8)),
    )
    for (check in checks) check.report()
    summarize(checks)
}
exception: same-object=false, twin=false, other=false -> identity
result-two-exceptions: same-object=false, twin=false, other=false -> identity
result-same-exception: same-object=false, twin=true, other=false -> content
result-success: same-object=false, twin=true, other=false -> content
data-class-exception: same-object=false, twin=true, other=false -> content
singleton: 0
identity: 2
content: 3
partial-content: 0

The first line is the criterion of this lesson: two exceptions with the same message are not equal, and the criterion is identity. On the second line the Results that wrap two separate exceptions are not equal either. On the third line two separate Result objects that wrap the same exception are equal: Result inherits the criterion of the exception. Result.success(1) gives content twice. CodedError, written as a data class, is equal with the same code, because the generated equals redefines the identity equality inherited from Exception. The bounding measurement is this: the criterion of an exception is decided by its type, not by its message.

CC38. These measurements are for IllegalStateException and Result. The cause chain of an exception, the contents of stackTrace, and other exception classes that have their own equals were not measured.

Summary

  • Kotlin has no throws; all exceptions are unchecked exceptions. try is an expression that produces a value, and finally runs last.
  • runCatching carries the error as a Result; getOrElse gives a fallback value, and exceptionOrNull gives the exception.
  • Inside coroutineScope, when one child fails, the siblings are cancelled and the exception propagates outward.
  • Two exceptions with the same message are not equal. The criterion of this lesson: identity.
  • The bounding measurement: Result.failure carries the criterion of the exception; an exception written as a data class is content with the same fields.

Course Wrap-Up

In twenty-three lessons the course asked a single question, and the criterion of each lesson was taken from the summary of the source lesson:

Lesson Measured value Criterion Bounding measurement
Kotlin’s Place data class, JVM list content not equal if name in the constructor differs
Variables const val constant singleton the same text built at run time is content
Basic Types boxed 1000 content 127 is singleton; NaN changes with the static type
String Templates two separate templates content a template with one variable is singleton
Control Flow when branch, plain class identity is Box matches; two 1..3 are content
Functions two ::plain content the listOf() default is singleton
Lambdas and Higher-Order Functions two separate lambdas identity a non-capturing lambda is singleton; ::inc is content
Scope Functions let with copy content the twin of also is singleton and spoils the measurement
Null Safety null with null singleton two filled strings are content; filled with null is identity
Safe Calls and the Elvis Operator two safe calls content on an empty receiver two nulls are singleton
Classes and Constructors class without equals identity the same pattern is content, partial content by equals
Data Classes object built with copy content an IntArray field is identity, a List field is content
Sealed Types and Enumerations enumeration entry singleton the kind of the subtype decides the criterion
Object Declarations two reads singleton an object expression is new each time, identity
Interfaces and Delegation two objects delegating with by identity two data classes with the same delegate are equal
Extension Functions extension equals identity the member beats the extension
Generics and Variance two empty lists, two type parameters singleton a generic data class looks at the field value
Collection Types list equality content set order is partial content; the empty list is singleton
Transformation Operations two maps content distinct uses the criterion of the element
Sequences two sequenceOfs identity content with toList()
Coroutines two Deferreds identity await results are content
Flows distinctUntilChanged content with a plain class 5 values remain; the flow is identity
Exception Handling two exceptions with the same message identity Result carries the criterion of the exception; a data class exception is content

The distribution of the twenty-three lessons: singleton 5, identity 8, content 10, partial content 0. Partial content was not the criterion of any lesson; it appeared only in the bounding measurements. The core of the opening lesson is a separate count: the distribution of its eight values is 2/2/3/1 (singleton/identity/content/partial content). The two are not the same thing, because the core counts eight values and the table counts twenty-three lessons.

That two exceptions built with the same message do not come out equal gathers the whole answer of the course: in Kotlin, equality is given not by how a value looks but by the rule of its type. The next course is Android Platform and Tools; it covers the parts of an app and the life cycles of those parts.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close