Files
obsidian/Kotlin/Исключения/Result и runCatching.md
T

17 KiB
Raw Blame History


title: Result и runCatching description: Ошибка как значение: Result, runCatching, свои sealed-типы и когда что выбирать tags:

  • kotlin
  • exceptions

Исключение - не единственный способ сообщить об ошибке. Ошибку можно вернуть как значение, и тогда компилятор заставит её обработать.

Три уровня, от простого к гибкому:

Способ Что несёт ошибка Когда
T? ничего, только «не получилось» причина не важна
Result<T> Throwable причина нужна, но типизировать её незачем
свой sealed-тип что угодно причин несколько и они разные

Nullable как простейший Result

Для одной очевидной причины сбоя null достаточно:

fun main() {
    println("42".toIntOrNull())        // 42
    println("abc".toIntOrNull())       // null
    println("abc".toIntOrNull() ?: 0)  // 0
}

Стандартная библиотека придерживается соглашения: toInt() бросает, toIntOrNull() возвращает null; first() бросает, firstOrNull() - нет.

fun main() {
    val items = listOf(1, 2, 3)
    println(items.getOrNull(10))                 // null
    println(items.firstOrNull { it > 5 })        // null
    println(mapOf("a" to 1)["b"])                // null
}

Подробнее: Null-безопасность

Result

Result<T> хранит либо значение, либо Throwable.

fun main() {
    val ok: Result<Int> = Result.success(42)
    val fail: Result<Int> = Result.failure(IllegalStateException("сбой"))

    println(ok.isSuccess)                 // true
    println(fail.isFailure)               // true
    println(ok.getOrNull())               // 42
    println(fail.getOrNull())             // null
    println(fail.exceptionOrNull()?.message)   // сбой
}

runCatching

Оборачивает блок: результат - Result, исключение - failure.

fun parse(s: String): Result<Int> = runCatching { s.toInt() }

fun main() {
    println(parse("42"))        // Success(42)
    println(parse("abc").exceptionOrNull()?.javaClass?.simpleName)  // NumberFormatException
}

Есть вариант-расширение с получателем:

fun main() {
    val length = "привет".runCatching { length }
    println(length.getOrNull())      // 6
}

Извлечение значения

fun main() {
    val ok = runCatching { 42 }
    val fail = runCatching<Int> { error("сбой") }

    println(ok.getOrNull())                           // 42
    println(fail.getOrDefault(0))                     // 0
    println(fail.getOrElse { e -> e.message!!.length }) // 4
    println(ok.getOrThrow())                          // 42

    try {
        fail.getOrThrow()
    } catch (e: IllegalStateException) {
        println("пробросилось: ${e.message}")         // пробросилось: сбой
    }
}

Преобразования

fun main() {
    val r = runCatching { "42" }
        .map { it.toInt() }            // только при успехе
        .map { it * 2 }
    println(r.getOrNull())             // 84

    // mapCatching перехватывает исключение внутри преобразования
    val bad = runCatching { "abc" }.mapCatching { it.toInt() }
    println(bad.isFailure)             // true

    // map не перехватывает — исключение улетит наружу
    val thrown = runCatching { runCatching { "abc" }.map { it.toInt() } }
    println(thrown.isFailure)          // true
}

Разница существенная: map предполагает, что преобразование не падает, mapCatching - что может.

fun main() {
    val r = runCatching<Int> { error("сбой") }
        .recover { 0 }                 // заменяем ошибку значением
    println(r.getOrNull())             // 0

    val folded = runCatching { "42".toInt() }
        .fold(
            onSuccess = { "значение $it" },
            onFailure = { "ошибка ${it.message}" },
        )
    println(folded)                    // значение 42
}

onSuccess и onFailure

Побочные эффекты без изменения значения - они возвращают сам Result:

fun main() {
    runCatching { "abc".toInt() }
        .onSuccess { println("получили $it") }
        .onFailure { println("не вышло: ${it.message}") }
        .getOrDefault(0)
        .let { println("итог: $it") }
    // не вышло: For input string: "abc"
    // итог: 0
}

Свой sealed-тип

Когда причин несколько и клиент должен их различать, Result слишком беден - в нём любая ошибка это Throwable.

sealed interface LoadResult {
    data class Success(val user: String) : LoadResult
    data object NotFound : LoadResult
    data class Forbidden(val reason: String) : LoadResult
    data class NetworkError(val retryAfter: Int) : LoadResult
}

fun load(id: Int): LoadResult = when {
    id == 1 -> LoadResult.Success("Иван")
    id == 2 -> LoadResult.Forbidden("нет прав")
    id < 0 -> LoadResult.NetworkError(retryAfter = 5)
    else -> LoadResult.NotFound
}

fun describe(r: LoadResult) = when (r) {      // else не нужен
    is LoadResult.Success -> "загружен ${r.user}"
    LoadResult.NotFound -> "не найден"
    is LoadResult.Forbidden -> "запрещено: ${r.reason}"
    is LoadResult.NetworkError -> "повтор через ${r.retryAfter} с"
}

fun main() {
    listOf(1, 2, 3, -1).forEach { println(describe(load(it))) }
    // загружен Иван
    // запрещено: нет прав
    // не найден
    // повтор через 5 с
}

Главное преимущество - компилятор проверяет, что обработаны все варианты. Добавили новый - все when перестанут компилироваться, пока его не учтут. С Result такой гарантии нет.

Подробнее: Sealed class и interface

Свой Result с типизированной ошибкой

sealed interface Outcome<out T, out E> {
    data class Ok<T>(val value: T) : Outcome<T, Nothing>
    data class Err<E>(val error: E) : Outcome<Nothing, E>
}

enum class ParseError { EMPTY, NOT_A_NUMBER, NEGATIVE }

fun parsePositive(s: String): Outcome<Int, ParseError> = when {
    s.isBlank() -> Outcome.Err(ParseError.EMPTY)
    s.toIntOrNull() == null -> Outcome.Err(ParseError.NOT_A_NUMBER)
    s.toInt() < 0 -> Outcome.Err(ParseError.NEGATIVE)
    else -> Outcome.Ok(s.toInt())
}

fun main() {
    listOf("42", "", "abc", "-1").forEach { input ->
        when (val r = parsePositive(input)) {
            is Outcome.Ok -> println("'$input' -> ${r.value}")
            is Outcome.Err -> println("'$input' -> ошибка ${r.error}")
        }
    }
    // '42' -> 42
    // '' -> ошибка EMPTY
    // 'abc' -> ошибка NOT_A_NUMBER
    // '-1' -> ошибка NEGATIVE
}

Nothing в позиции второго параметра позволяет Ok подходить под любой тип ошибки. Подробнее: Any, Unit, Nothing, Вариантность

Что выбрать

graph TD
    A["Ошибка возможна"] --> B{"Вызывающий может<br/>что-то сделать?"}
    B -->|"нет, это баг"| C["исключение:<br/>require, check, error"]
    B -->|"да"| D{"Причину нужно<br/>различать?"}
    D -->|"нет"| E["T?"]
    D -->|"да, но обработка общая"| F["Result&lt;T&gt;"]
    D -->|"да, по-разному"| G["свой sealed-тип"]

Практическое разделение:

  • исключение - для нарушенных предпосылок: неверный аргумент, недопустимое состояние. Это ошибка программиста, её исправляют в коде, а не обрабатывают. Подробнее: Проверки и контракты
  • T?, Result, sealed - для ожидаемых ситуаций: нет записи в БД, сеть недоступна, ввод пользователя некорректен

Граница с исключениями

runCatching удобен на границе: внешний код бросает, ваш возвращает значение.

class User7(val id: Int, val name: String)

fun fetchFromDb(id: Int): User7 =
    if (id == 1) User7(1, "Иван") else throw NoSuchElementException("нет пользователя $id")

fun findUser(id: Int): User7? = runCatching { fetchFromDb(id) }.getOrNull()

fun main() {
    println(findUser(1)?.name)      // Иван
    println(findUser(99))           // null
}

Обратное направление - когда API требует исключений, а внутри Result:

fun loadOrThrow(id: Int): String = runCatching {
    require(id > 0) { "id должен быть положительным" }
    "данные $id"
}.getOrThrow()

fun main() {
    println(loadOrThrow(1))      // данные 1
    try {
        loadOrThrow(-1)
    } catch (e: IllegalArgumentException) {
        println(e.message)        // id должен быть положительным
    }
}

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

Java Kotlin
checked exceptions в сигнатуре Result<T> или sealed-тип
Optional<T> T? - часть системы типов
Optional.map/orElse ?.let/?:
нет аналога Result в JDK Result<T> в stdlib
try/catch для управления runCatching как выражение

Result частично возвращает то, что отняло отсутствие checked exceptions. Сигнатура fun load(): Result<User> сообщает, что функция может не справиться, и компилятор не даст использовать значение без разбора. Разница с checked exceptions в том, что это выбор автора функции, а не навязанное правило, и такие значения свободно проходят через map, filter и другие обобщённые функции.

T? вместо Optional<T>. Optional - обёртка-объект: аллокация, два уровня значений (null и пустой Optional), неудобство в полях. Nullable-тип в Kotlin существует только при компиляции, а в байт-коде это обычная ссылка - накладных расходов нет:

fun main() {
    val name: String? = null
    println(name?.uppercase() ?: "<нет>")      // <нет>
}
Optional<String> name = Optional.empty();
System.out.println(name.map(String::toUpperCase).orElse("<нет>"));

Sealed-типы вместо иерархии исключений. В Java разные причины сбоя обычно выражают классами исключений и ловят их по типу. Sealed-тип даёт то же различение, но с проверкой полноты при компиляции и без стоимости стектрейса.

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

[!warning] Подводные камни runCatching перехватывает всё, включая то, что перехватывать нельзя. Он ловит Throwable, то есть OutOfMemoryError, StackOverflowError и - самое опасное - CancellationException в корутинах:

// Требует kotlinx-coroutines-core.
// suspend fun broken() {
//     runCatching { delay(1000) }      // проглотит отмену корутины
// }
//
// suspend fun fixed() = runCatching { delay(1000) }
//     .onFailure { if (it is CancellationException) throw it }

В корутинах либо проверяйте тип вручную, либо используйте обычный try/catch с перехватом конкретных типов. Подробнее: Исключения в корутинах

Result нельзя вернуть из функции в публичном API без оговорок. До Kotlin 1.5 это было запрещено совсем; сейчас разрешено, но тип по-прежнему value class, поэтому из Java он виден странно и его нельзя использовать как тип свойства:

class Holder {
    // val r: Result<Int> = Result.success(1)    // компилируется, но Result как поле боксируется
    private val value: Int = 1
    fun get(): Result<Int> = Result.success(value)
}

fun main() { println(Holder().get().getOrNull()) }    // 1

Result стирает тип ошибки. Все причины сбоя - Throwable, поэтому различать их приходится через is, и компилятор полноту не проверяет:

fun main() {
    val r = runCatching<Int> { error("сбой") }
    val msg = when (val e = r.exceptionOrNull()) {
        null -> "успех"
        is IllegalStateException -> "состояние: ${e.message}"
        else -> "прочее"              // else обязателен
    }
    println(msg)      // состояние: сбой
}

Создание failure всё равно создаёт исключение со стектрейсом - то есть Result не даёт экономии на производительности, если ошибку формирует runCatching. Дешёвый вариант - sealed-тип без Throwable внутри.

Проигнорированный Result не вызывает предупреждения:

fun save(): Result<Unit> = runCatching { error("не записалось") }

fun main() {
    save()                     // ошибка потеряна молча
    println(save().isFailure)  // true — так правильно
}

В отличие от исключения, которое всплывёт само, забытый Result ничего не делает. Это основной риск подхода.

Цепочка map на Result может бросить. map не перехватывает исключения из своей лямбды - для этого есть mapCatching.

getOrThrow на чужом Result бросает исходное исключение, а не обёртку, поэтому стектрейс указывает на место первоначального сбоя, а не на getOrThrow. Для диагностики это хорошо, для чтения логов - неожиданно.

Смешение подходов в одном слое запутывает. Если часть функций репозитория возвращает null, часть Result, а часть бросает, клиентский код обрастает тремя видами обработки. Выберите одно соглашение на слой.

Далее: Проверки и контракты