Продвинутая обработка ошибок в Kotlin: от try/catch до функциональных подходов
Обработка ошибок — одна из ключевых задач при разработке надёжного ПО. В Kotlin, помимо классического механизма try/catch/finally, существует целый арсенал продвинутых инструментов: Result, runCatching, sealed class и функциональные монады. В этой статье мы разберём, как выйти за рамки базового использования и сделать ваш код более безопасным, читаемым и предсказуемым.
1. Классический try/catch: когда он всё ещё уместен
Базовый try/catch в Kotlin — это выражение, которое может возвращать значение. Это первое, что отличает его от Java.
val result: String = try {
riskyOperation()
} catch (e: IOException) {
"Ошибка ввода-вывода: ${e.message}"
} catch (e: Exception) {
"Неизвестная ошибка: ${e.message}"
} finally {
println("Блок finally выполняется всегда")
}
Несмотря на простоту, у этого подхода есть недостатки:
- Исключения — дорогостоящая операция (создание stack trace).
- Тип ошибки не фиксируется на уровне типов — легко пропустить обработку.
- Код становится громоздким при большом количестве вложенных операций.
Когда использовать try/catch?
- При работе с внешними ресурсами (файлы, сеть, базы данных).
- В библиотечном коде, где исключения — часть контракта.
- Для критических ошибок, которые невозможно обработать локально.
2. Функциональный подход: Result и runCatching
Kotlin предлагает встроенный класс Result, который представляет результат операции, которая может завершиться успехом или ошибкой. Это альтернатива исключениям, не требующая дорогостоящего stack trace.
fun divide(a: Int, b: Int): Result<Int> {
return runCatching {
a / b
}
}
fun main() {
val result = divide(10, 0)
result.onSuccess { value ->
println("Результат: $value")
}.onFailure { error ->
println("Ошибка: ${error.message}")
}
}
Методы Result
getOrNull()— возвращает значение или null.getOrDefault(default)— возвращает значение или значение по умолчанию.mapиflatMap— для цепочек трансформаций.recover— восстановление после ошибки.
val safeResult = runCatching { riskyOperation() }
.map { it.toUpperCase() }
.recover { "default_value" }
.getOrDefault("fallback")
Важно: Result не предназначен для передачи между потоками или хранения в полях класса — используйте его только как локальное значение.
3. Sealed классы: моделирование ошибок на уровне типов
Когда нужно чётко определить все возможные состояния (успех, различные типы ошибок), на помощь приходят sealed class. Это мощный инструмент для создания алгебраических типов данных.
sealed class NetworkResult<out T> {
data class Success<T>(val data: T) : NetworkResult<T>()
data class Error(val message: String, val code: Int) : NetworkResult<Nothing>()
object Loading : NetworkResult<Nothing>()
}
fun fetchUser(id: Int): NetworkResult<User> {
return try {
val user = api.getUser(id)
NetworkResult.Success(user)
} catch (e: HttpException) {
NetworkResult.Error("Сервер вернул ошибку", e.code())
} catch (e: Exception) {
NetworkResult.Error("Неизвестная ошибка", 0)
}
}
fun handleResult(result: NetworkResult<User>) {
when (result) {
is NetworkResult.Success -> println("Пользователь: ${result.data.name}")
is NetworkResult.Error -> println("Ошибка ${result.code}: ${result.message}")
is NetworkResult.Loading -> println("Загрузка...")
}
}
Преимущества sealed class:
- Компилятор проверяет полноту обработки (when должен быть exhaustive).
- Тип ошибки фиксирован — не нужно гадать, что может пойти не так.
- Легко добавлять новые типы ошибок без поломки существующего кода.
4. Arrow и монады: Either, Option, Try
Для функционального программирования в Kotlin существует библиотека Arrow. Она предоставляет монады, которые делают обработку ошибок ещё более элегантной.
Either (левый тип — ошибка, правый — успех)
import arrow.core.Either
import arrow.core.Left
import arrow.core.Right
sealed class AppError {
object NotFound : AppError()
data class ValidationError(val field: String) : AppError()
}
fun findUser(id: Int): Either<AppError, User> {
return if (id <= 0) {
Left(AppError.ValidationError("id"))
} else {
val user = db.findUserById(id) ?: return Left(AppError.NotFound)
Right(user)
}
}
fun main() {
val result = findUser(-1)
result.fold(
ifLeft = { error -> println("Ошибка: $error") },
ifRight = { user -> println("Найден: ${user.name}") }
)
}
Option (значение или null)
Идеально для случаев, когда ошибка — это просто отсутствие значения.
import arrow.core.Option
import arrow.core.Some
import arrow.core.none
fun parseNumber(s: String): Option<Int> {
return s.toIntOrNull()?.let { Some(it) } ?: none()
}
Try (аналог Result с поддержкой функциональных цепочек)
import arrow.core.Try
import arrow.core.raise.tryOrNull
val result: Try<Int> = Try { riskyOperation() }
result.map { it * 2 }
.filter { it > 0 }
.getOrElse { -1 }
5. Практические рекомендации и антипаттерны
Что стоит делать:
- Используйте
runCatchingдля простых операций без внешних ресурсов. - Применяйте
sealed classдля API, где важна полная обработка всех состояний. - Для сложных бизнес-логик