--- title: Result и runCatching description: Ошибка как значение: Result, runCatching, свои sealed-типы и когда что выбирать tags: - kotlin - exceptions --- Исключение - не единственный способ сообщить об ошибке. Ошибку можно вернуть как значение, и тогда компилятор заставит её обработать. Три уровня, от простого к гибкому: | Способ | Что несёт ошибка | Когда | |---|---|---| | `T?` | ничего, только «не получилось» | причина не важна | | `Result` | `Throwable` | причина нужна, но типизировать её незачем | | свой `sealed`-тип | что угодно | причин несколько и они разные | ## Nullable как простейший Result Для одной очевидной причины сбоя `null` достаточно: ```kotlin fun main() { println("42".toIntOrNull()) // 42 println("abc".toIntOrNull()) // null println("abc".toIntOrNull() ?: 0) // 0 } ``` Стандартная библиотека придерживается соглашения: `toInt()` бросает, `toIntOrNull()` возвращает `null`; `first()` бросает, `firstOrNull()` - нет. ```kotlin 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-безопасность](../Синтаксис%20и%20типы/Null-безопасность/Null-безопасность.md) ## Result `Result` хранит либо значение, либо `Throwable`. ```kotlin fun main() { val ok: Result = Result.success(42) val fail: Result = 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`. ```kotlin fun parse(s: String): Result = runCatching { s.toInt() } fun main() { println(parse("42")) // Success(42) println(parse("abc").exceptionOrNull()?.javaClass?.simpleName) // NumberFormatException } ``` Есть вариант-расширение с получателем: ```kotlin fun main() { val length = "привет".runCatching { length } println(length.getOrNull()) // 6 } ``` ### Извлечение значения ```kotlin fun main() { val ok = runCatching { 42 } val fail = runCatching { 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}") // пробросилось: сбой } } ``` ### Преобразования ```kotlin 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` - что может. ```kotlin fun main() { val r = runCatching { error("сбой") } .recover { 0 } // заменяем ошибку значением println(r.getOrNull()) // 0 val folded = runCatching { "42".toInt() } .fold( onSuccess = { "значение $it" }, onFailure = { "ошибка ${it.message}" }, ) println(folded) // значение 42 } ``` ### onSuccess и onFailure Побочные эффекты без изменения значения - они возвращают сам `Result`: ```kotlin 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`. ```kotlin 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](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Sealed%20class%20и%20interface.md) ### Свой Result с типизированной ошибкой ```kotlin sealed interface Outcome { data class Ok(val value: T) : Outcome data class Err(val error: E) : Outcome } enum class ParseError { EMPTY, NOT_A_NUMBER, NEGATIVE } fun parsePositive(s: String): Outcome = 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](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Any,%20Unit,%20Nothing.md), [Вариантность](../Дженерики/Вариантность.md) ## Что выбрать ```mermaid graph TD A["Ошибка возможна"] --> B{"Вызывающий может
что-то сделать?"} B -->|"нет, это баг"| C["исключение:
require, check, error"] B -->|"да"| D{"Причину нужно
различать?"} D -->|"нет"| E["T?"] D -->|"да, но обработка общая"| F["Result<T>"] D -->|"да, по-разному"| G["свой sealed-тип"] ``` Практическое разделение: - **исключение** - для нарушенных предпосылок: неверный аргумент, недопустимое состояние. Это ошибка программиста, её исправляют в коде, а не обрабатывают. Подробнее: [Проверки и контракты](Проверки%20и%20контракты.md) - **`T?`, `Result`, sealed** - для ожидаемых ситуаций: нет записи в БД, сеть недоступна, ввод пользователя некорректен ## Граница с исключениями `runCatching` удобен на границе: внешний код бросает, ваш возвращает значение. ```kotlin 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`: ```kotlin 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` или sealed-тип | > | `Optional` | `T?` - часть системы типов | > | `Optional.map`/`orElse` | `?.let`/`?:` | > | нет аналога `Result` в JDK | `Result` в stdlib | > | `try`/`catch` для управления | `runCatching` как выражение | > > **`Result` частично возвращает то, что отняло отсутствие checked exceptions.** Сигнатура `fun load(): Result` сообщает, что функция может не справиться, и компилятор не даст использовать значение без разбора. Разница с checked exceptions в том, что это выбор автора функции, а не навязанное правило, и такие значения свободно проходят через `map`, `filter` и другие обобщённые функции. > > **`T?` вместо `Optional`.** `Optional` - обёртка-объект: аллокация, два уровня значений (`null` и пустой `Optional`), неудобство в полях. Nullable-тип в Kotlin существует только при компиляции, а в байт-коде это обычная ссылка - накладных расходов нет: > ```kotlin > fun main() { > val name: String? = null > println(name?.uppercase() ?: "<нет>") // <нет> > } > ``` > ```java > Optional name = Optional.empty(); > System.out.println(name.map(String::toUpperCase).orElse("<нет>")); > ``` > > **Sealed-типы вместо иерархии исключений.** В Java разные причины сбоя обычно выражают классами исключений и ловят их по типу. Sealed-тип даёт то же различение, но с проверкой полноты при компиляции и без стоимости стектрейса. > > Сравнить: [Система исключений в Java](../../Программирование%20на%20Java/Исключения/Система%20исключений.md) > [!warning] Подводные камни > **`runCatching` перехватывает всё, включая то, что перехватывать нельзя.** Он ловит `Throwable`, то есть `OutOfMemoryError`, `StackOverflowError` и - самое опасное - `CancellationException` в корутинах: > ```kotlin > // Требует kotlinx-coroutines-core. > // suspend fun broken() { > // runCatching { delay(1000) } // проглотит отмену корутины > // } > // > // suspend fun fixed() = runCatching { delay(1000) } > // .onFailure { if (it is CancellationException) throw it } > ``` > В корутинах либо проверяйте тип вручную, либо используйте обычный `try`/`catch` с перехватом конкретных типов. Подробнее: [Исключения в корутинах](../Корутины/Исключения%20в%20корутинах.md) > > **`Result` нельзя вернуть из функции в публичном API без оговорок.** До Kotlin 1.5 это было запрещено совсем; сейчас разрешено, но тип по-прежнему `value class`, поэтому из Java он виден странно и его нельзя использовать как тип свойства: > ```kotlin > class Holder { > // val r: Result = Result.success(1) // компилируется, но Result как поле боксируется > private val value: Int = 1 > fun get(): Result = Result.success(value) > } > > fun main() { println(Holder().get().getOrNull()) } // 1 > ``` > > **`Result` стирает тип ошибки.** Все причины сбоя - `Throwable`, поэтому различать их приходится через `is`, и компилятор полноту не проверяет: > ```kotlin > fun main() { > val r = runCatching { error("сбой") } > val msg = when (val e = r.exceptionOrNull()) { > null -> "успех" > is IllegalStateException -> "состояние: ${e.message}" > else -> "прочее" // else обязателен > } > println(msg) // состояние: сбой > } > ``` > > **Создание `failure` всё равно создаёт исключение со стектрейсом** - то есть `Result` не даёт экономии на производительности, если ошибку формирует `runCatching`. Дешёвый вариант - sealed-тип без `Throwable` внутри. > > **Проигнорированный `Result` не вызывает предупреждения:** > ```kotlin > fun save(): Result = runCatching { error("не записалось") } > > fun main() { > save() // ошибка потеряна молча > println(save().isFailure) // true — так правильно > } > ``` > В отличие от исключения, которое всплывёт само, забытый `Result` ничего не делает. Это основной риск подхода. > > **Цепочка `map` на `Result` может бросить.** `map` не перехватывает исключения из своей лямбды - для этого есть `mapCatching`. > > **`getOrThrow` на чужом `Result` бросает исходное исключение,** а не обёртку, поэтому стектрейс указывает на место первоначального сбоя, а не на `getOrThrow`. Для диагностики это хорошо, для чтения логов - неожиданно. > > **Смешение подходов в одном слое запутывает.** Если часть функций репозитория возвращает `null`, часть `Result`, а часть бросает, клиентский код обрастает тремя видами обработки. Выберите одно соглашение на слой. Далее: [Проверки и контракты](Проверки%20и%20контракты.md)