17 KiB
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<T>"]
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в JDKResult<T>в stdlibtry/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, а часть бросает, клиентский код обрастает тремя видами обработки. Выберите одно соглашение на слой.
Далее: Проверки и контракты