Files
obsidian/Kotlin/Исключения/Система исключений.md
T

16 KiB
Raw Blame History

title, description, tags
title description tags
Система исключений Иерархия Throwable, try как выражение, отсутствие checked exceptions и правильная обработка
kotlin
exceptions

Все исключения в Kotlin непроверяемые. Компилятор не требует ни объявлять, ни перехватывать их.

fun parse(s: String): Int = s.toInt()      // может бросить, но throws не нужен

fun main() {
    println(parse("42"))      // 42
    try {
        parse("abc")
    } catch (e: NumberFormatException) {
        println("не число: ${e.message}")   // не число: For input string: "abc"
    }
}

Иерархия

graph TD
    T["Throwable"] --> E["Exception"]
    T --> R["Error"]
    E --> RE["RuntimeException"]
    E --> IOE["IOException"]
    RE --> NPE["NullPointerException"]
    RE --> IAE["IllegalArgumentException"]
    RE --> ISE["IllegalStateException"]
    RE --> CCE["ClassCastException"]
    R --> OOM["OutOfMemoryError"]
    R --> SOE["StackOverflowError"]

Иерархия та же, что в Java, - это классы JDK. Разница только в том, что Kotlin не делит их на проверяемые и непроверяемые.

Класс Смысл Перехватывать
Error сбой среды: нет памяти, переполнен стек нет
RuntimeException ошибка программиста: неверный аргумент, состояние обычно нет, лучше исправить код
прочие Exception внешний сбой: сеть, файл, БД да

try как выражение

try возвращает значение - это важное отличие от Java:

fun parseOrZero(s: String): Int = try {
    s.toInt()
} catch (e: NumberFormatException) {
    0
}

fun main() {
    println(parseOrZero("42"))     // 42
    println(parseOrZero("abc"))    // 0
}

Результат - последнее выражение блока try или соответствующего catch. Блок finally на результат не влияет:

fun main() {
    val result = try {
        "из try"
    } finally {
        "из finally"      // игнорируется
    }
    println(result)        // из try
}

throw - тоже выражение

Тип throw - Nothing, поэтому он подходит в любую позицию:

fun findUser(id: Int): String =
    if (id > 0) "пользователь $id"
    else throw IllegalArgumentException("неверный id: $id")

fun main() {
    println(findUser(1))        // пользователь 1
    val name = findUser(1).takeIf { it.isNotEmpty() } ?: throw IllegalStateException("пусто")
    println(name)                // пользователь 1
}

Это позволяет писать ?: throw ... и ?: error(...) в одном выражении.

Перехват

fun risky(mode: Int): String = when (mode) {
    1 -> throw IllegalArgumentException("плохой аргумент")
    2 -> throw IllegalStateException("плохое состояние")
    else -> "ок"
}

fun handle(mode: Int): String = try {
    risky(mode)
} catch (e: IllegalArgumentException) {
    "аргумент: ${e.message}"
} catch (e: IllegalStateException) {
    "состояние: ${e.message}"
} catch (e: Exception) {
    "прочее: ${e.message}"
} finally {
    println("finally выполняется всегда")
}

fun main() {
    println(handle(1))      // аргумент: плохой аргумент
    println(handle(3))      // ок
}

Блоки catch проверяются по порядку, поэтому конкретные типы идут раньше общих. catch (e: Exception) первым сделал бы остальные недостижимыми.

Несколько типов в одном блоке Kotlin не поддерживает - аналога Java catch (A | B e) нет. Варианты:

fun main() {
    try {
        throw IllegalStateException("сбой")
    } catch (e: RuntimeException) {              // общий супертип
        println("перехвачено: ${e::class.simpleName}")
    }

    try {
        throw NumberFormatException("плохое число")
    } catch (e: Exception) {
        when (e) {                                // или when по типу
            is NumberFormatException, is IllegalStateException ->
                println("ожидаемая ошибка: ${e.message}")
            else -> throw e                       // остальное пробрасываем
        }
    }
}

Свои исключения

class InsufficientFundsException(
    val required: Long,
    val available: Long,
) : Exception("нужно $required, доступно $available")

class ValidationException(message: String, cause: Throwable? = null) : Exception(message, cause)

fun withdraw(balance: Long, amount: Long): Long {
    if (amount > balance) throw InsufficientFundsException(amount, balance)
    return balance - amount
}

fun main() {
    println(withdraw(100, 30))      // 70
    try {
        withdraw(100, 500)
    } catch (e: InsufficientFundsException) {
        println("не хватает ${e.required - e.available}")   // не хватает 400
    }
}

Своё исключение полезно, когда вызывающему нужно различить ситуацию и получить данные о ней. Для «ошибки программиста» хватает стандартных IllegalArgumentException и IllegalStateException.

Цепочка причин

class RepositoryException(message: String, cause: Throwable) : Exception(message, cause)

fun loadUser(id: String): String = try {
    "пользователь ${id.toInt()}"
} catch (e: NumberFormatException) {
    throw RepositoryException("не удалось загрузить '$id'", e)   // сохраняем причину
}

fun main() {
    try {
        loadUser("abc")
    } catch (e: RepositoryException) {
        println(e.message)                    // не удалось загрузить 'abc'
        println("причина: ${e.cause?.message}")   // причина: For input string: "abc"
    }
}

Передавать cause обязательно: без него теряется исходный стектрейс и причина сбоя.

use - автоматическое закрытие

Аналог try-with-resources:

import java.io.StringReader

fun main() {
    val content = StringReader("данные").use { reader ->
        reader.readText()                // close() вызовется автоматически
    }
    println(content)      // данные
}

use - функция-расширение для Closeable, она закрывает ресурс и при исключении. Это предпочтительный способ: finally с ручным close() легко написать неверно.

class Resource(val name: String) : AutoCloseable {
    fun work() = "работаю с $name"
    override fun close() = println("$name закрыт")
}

fun main() {
    val result = Resource("файл").use { it.work() }
    println(result)
    // файл закрыт
    // работаю с файл

    try {
        Resource("другой").use { throw IllegalStateException("сбой") }
    } catch (e: IllegalStateException) {
        println("ресурс всё равно закрыт: ${e.message}")
    }
}

@Throws для Java

Kotlin не объявляет исключения, но Java-клиенту это может быть нужно:

import java.io.IOException

@Throws(IOException::class)
fun readFile(path: String): String {
    if (path.isEmpty()) throw IOException("пустой путь")
    return "содержимое $path"
}

fun main() { println(readFile("a.txt")) }     // содержимое a.txt

Без @Throws Java-код не сможет перехватить проверяемое исключение - компилятор Java сочтёт, что метод его не бросает. Подробнее: Kotlin из Java

Чего не делать

fun main() {
    // проглатывание: ошибка исчезает
    try {
        error("сбой")
    } catch (e: Exception) {
        // ничего — худшая практика
    }

    // перехват Throwable: ловит OutOfMemoryError и StackOverflowError
    try {
        error("сбой")
    } catch (e: Exception) {          // не Throwable
        println("перехвачено корректно")
    }

    // исключение как управление потоком
    val items = listOf(1, 2, 3)
    println(items.getOrNull(10) ?: "нет элемента")   // вместо try/catch
}

Правила:

  • не перехватывать, если не знаете, что делать с ошибкой - пусть идёт выше
  • не перехватывать Throwable и Error
  • не использовать исключения для ожидаемых ситуаций - для них есть null, Result или sealed-типы
  • всегда логировать или пробрасывать; пустой catch скрывает причину сбоя

Альтернативы исключениям: Result и runCatching

Исключения в корутинах

Отмена корутины реализована через CancellationException, и перехватывать её нельзя:

// Требует kotlinx-coroutines-core.
// suspend fun work() {
//     try {
//         delay(1000)
//     } catch (e: CancellationException) {
//         throw e          // обязательно пробросить!
//     } catch (e: Exception) {
//         println("настоящая ошибка")
//     }
// }

catch (e: Exception) перехватит и отмену, из-за чего корутина продолжит работу после отмены. Подробнее: Исключения в корутинах

[!info] Отличия от Java

Java Kotlin
checked exceptions нет, все непроверяемые
throws IOException в сигнатуре @Throws только для Java-клиентов
try - инструкция try - выражение
catch (A | B e) нет аналога, нужен when
try-with-resources use { }
throw - инструкция throw - выражение типа Nothing

Отсутствие checked exceptions - самое спорное решение. Аргументы против них, которые приводят авторы Kotlin:

  1. Они не масштабируются. Сигнатура с пятью throws нечитаема, а обобщённый код (map, forEach) не может объявить исключения вызываемой лямбды - из-за этого Java-стримы несовместимы с проверяемыми исключениями.
  2. На практике их проглатывают. Самый частый способ «обработать» обязательное исключение - пустой catch, то есть защита превращается в сокрытие ошибки.
  3. Обёртывание ради компиляции. Код вроде catch (IOException e) { throw new RuntimeException(e); } встречается в любом крупном Java-проекте.

Цена отказа - сигнатура больше не документирует ошибки. Это компенсируют KDoc и типы вроде Result, но компилятор уже не помогает.

try как выражение убирает var:

int value;                       // Java: нужна изменяемая переменная
try { value = Integer.parseInt(s); }
catch (NumberFormatException e) { value = 0; }
fun parseOrZero(s: String): Int =
    try { s.toInt() } catch (e: NumberFormatException) { 0 }     // val вместо var

fun main() { println(parseOrZero("abc")) }    // 0

use вместо try-with-resources - обычная функция, а не синтаксис. Поэтому её можно применить к своему типу, добавив реализацию Closeable.

Сравнить: Система исключений в Java

[!warning] Подводные камни Сигнатура не говорит об исключениях. Единственные источники информации - документация и чтение кода. Для важных случаев указывайте в KDoc:

/**
 * @throws IllegalArgumentException если id не положительный
 */
fun load(id: Int): String {
    require(id > 0) { "id должен быть положительным" }
    return "данные $id"
}

fun main() { println(load(1)) }     // данные 1

finally не влияет на результат try-выражения - значение из него отбрасывается.

return в finally проглатывает исключение:

fun bad(): String {
    try {
        throw IllegalStateException("потеряется")
    } finally {
        return "из finally"       // исключение исчезло!
    }
}

fun main() { println(bad()) }      // из finally

Исключение не дойдёт до вызывающего. return в finally писать нельзя.

Перехват Exception ловит все RuntimeException, включая NullPointerException в вашем коде - то есть маскирует собственные баги под «обработанную ошибку».

CancellationException нельзя проглатывать в корутинах - иначе отмена перестанет работать.

Исключение в init или в инициализаторе свойства оставляет побочные эффекты. Объект не создастся, но то, что уже выполнилось, останется. Подробнее: Инициализация

Создание исключения дорого из-за сбора стектрейса. Для ожидаемых ситуаций в горячем коде используйте null или Result:

fun main() {
    println("abc".toIntOrNull() ?: 0)      // без исключения
    // println("abc".toInt())              // исключение + стектрейс
}

Исключение из лямбды в forEach прерывает весь цикл, а не одну итерацию:

fun main() {
    val results = mutableListOf<Int>()
    try {
        listOf("1", "abc", "3").forEach { results.add(it.toInt()) }
    } catch (e: NumberFormatException) {
        println("прервано на второй строке, собрано: $results")   // [1]
    }
}

Чтобы обработать все элементы, перехватывайте внутри лямбды или используйте mapNotNull { it.toIntOrNull() }.

@Throws не проверяется компилятором Kotlin - это только метаданные для Java. Можно объявить исключение, которое никогда не бросается, и наоборот.

Далее: Result и runCatching