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.tryis an expression that produces a value, andfinallyruns last. runCatchingcarries the error as aResult;getOrElsegives a fallback value, andexceptionOrNullgives 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.failurecarries 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.