337 lines
17 KiB
Markdown
337 lines
17 KiB
Markdown
---
|
||
title: Result и runCatching
|
||
description: Ошибка как значение: Result, runCatching, свои sealed-типы и когда что выбирать
|
||
tags:
|
||
- kotlin
|
||
- exceptions
|
||
---
|
||
|
||
Исключение - не единственный способ сообщить об ошибке. Ошибку можно вернуть как значение, и тогда компилятор заставит её обработать.
|
||
|
||
Три уровня, от простого к гибкому:
|
||
|
||
| Способ | Что несёт ошибка | Когда |
|
||
|---|---|---|
|
||
| `T?` | ничего, только «не получилось» | причина не важна |
|
||
| `Result<T>` | `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<T>` хранит либо значение, либо `Throwable`.
|
||
```kotlin
|
||
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`.
|
||
```kotlin
|
||
fun parse(s: String): Result<Int> = 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<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}") // пробросилось: сбой
|
||
}
|
||
}
|
||
```
|
||
|
||
### Преобразования
|
||
```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<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`:
|
||
```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<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](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Any,%20Unit,%20Nothing.md), [Вариантность](../Дженерики/Вариантность.md)
|
||
|
||
## Что выбрать
|
||
```mermaid
|
||
graph TD
|
||
A["Ошибка возможна"] --> B{"Вызывающий может<br/>что-то сделать?"}
|
||
B -->|"нет, это баг"| C["исключение:<br/>require, check, error"]
|
||
B -->|"да"| D{"Причину нужно<br/>различать?"}
|
||
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<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 существует только при компиляции, а в байт-коде это обычная ссылка - накладных расходов нет:
|
||
> ```kotlin
|
||
> fun main() {
|
||
> val name: String? = null
|
||
> println(name?.uppercase() ?: "<нет>") // <нет>
|
||
> }
|
||
> ```
|
||
> ```java
|
||
> Optional<String> 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<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`, и компилятор полноту не проверяет:
|
||
> ```kotlin
|
||
> 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` не вызывает предупреждения:**
|
||
> ```kotlin
|
||
> fun save(): Result<Unit> = runCatching { error("не записалось") }
|
||
>
|
||
> fun main() {
|
||
> save() // ошибка потеряна молча
|
||
> println(save().isFailure) // true — так правильно
|
||
> }
|
||
> ```
|
||
> В отличие от исключения, которое всплывёт само, забытый `Result` ничего не делает. Это основной риск подхода.
|
||
>
|
||
> **Цепочка `map` на `Result` может бросить.** `map` не перехватывает исключения из своей лямбды - для этого есть `mapCatching`.
|
||
>
|
||
> **`getOrThrow` на чужом `Result` бросает исходное исключение,** а не обёртку, поэтому стектрейс указывает на место первоначального сбоя, а не на `getOrThrow`. Для диагностики это хорошо, для чтения логов - неожиданно.
|
||
>
|
||
> **Смешение подходов в одном слое запутывает.** Если часть функций репозитория возвращает `null`, часть `Result`, а часть бросает, клиентский код обрастает тремя видами обработки. Выберите одно соглашение на слой.
|
||
|
||
Далее: [Проверки и контракты](Проверки%20и%20контракты.md)
|