Введение: Почему стандартного try/catch недостаточно?
Разработка на Kotlin, особенно в контексте серверных приложений или Android, часто сталкивается с необходимостью надёжной обработки ошибок. Стандартный блок try/catch — это база, но на практике он приводит к «размазыванию» логики и усложнению тестирования. На продвинутом уровне мы хотим не просто ловить исключения, а проектировать систему так, чтобы ошибки были частью типа данных, а не неожиданным потоком управления.
В этой статье мы разберём, как выйти за рамки try/catch и использовать функциональные подходы: Result, Either (из библиотек), и sealed class. Вы узнаете, как сделать код предсказуемым, типобезопасным и легко поддерживаемым.
1. Классический try/catch: когда он всё ещё полезен?
Не стоит полностью отказываться от try/catch. Он незаменим для работы с legacy-кодом, внешними библиотеками (например, при парсинге JSON) и системными вызовами (ввод-вывод). Однако есть важные нюансы.
Правильная структура и ресурсы
В Kotlin, в отличие от Java, нет проверяемых исключений, но есть изящная конструкция use для автоматического закрытия ресурсов:
fun readFile(path: String): String? = try {
BufferedReader(FileReader(path)).use { it.readText() }
} catch (e: IOException) {
println("Ошибка чтения файла: ${e.message}")
null
}
Здесь use гарантирует закрытие файла, даже если внутри произошло исключение. Это элегантная замена try-with-resources.
Проблема: потеря контекста
Главный недостаток try/catch — он не сообщает вызывающему коду, что функция может «упасть». Исключение — это скрытый побочный эффект. Вызов readFile() может вернуть null или выбросить IOException, но сигнатура функции этого не отражает. Это ведёт к ошибкам в рантайме.
Решение — перейти к явному представлению ошибок.
2. Result: встроенная монада для успеха или неудачи
Kotlin предоставляет стандартный класс Result<T>. Он является простой алгебраической структурой: либо успех (значение T), либо неудача (Throwable). Это делает обработку ошибок частью системы типов.
Базовое использование
fun riskyDivision(a: Int, b: Int): Result<Int> = runCatching {
a / b
}
fun main() {
val result = riskyDivision(10, 0)
result.onSuccess { println("Результат: $it") }
.onFailure { println("Ошибка: ${it.message}") }
}
runCatching — это обёртка над try/catch, которая возвращает Result. Методы onSuccess и onFailure позволяют реагировать на исход, не прерывая цепочку вызовов.
Трансформация и цепочки
Вы можете преобразовывать результат, не беспокоясь об исключениях внутри map:
val finalResult = riskyDivision(10, 2)
.map { it * 2 } // если успех — умножаем
.recover { error -> // если ошибка — возвращаем запасное значение
println("Используем запасное значение из-за: ${error.message}")
0
}
println(finalResult.getOrDefault(-1)) // 10
Важно: Result не предназначен для хранения в коллекциях или передачи между потоками без дополнительной обработки. Для этих целей лучше подходят sealed class или библиотеки вроде Arrow.
3. Either: различаем тип ошибки
Класс Result фиксирует только Throwable. Но часто мы хотим различать типы ошибок: «не найден», «недостаточно прав», «ошибка валидации». Здесь на помощь приходит Either<L, R> из функциональных библиотек (например, Arrow или самописный).
Either — это дизъюнкция: либо левое значение (Left — ошибка), либо правое (Right — успех).
Пример с самодельным Either
sealed class Either<out L, out R> {
data class Left<out L>(val value: L) : Either<L, Nothing>()
data class Right<out R>(val value: R) : Either<Nothing, R>()
}
// Специфичные типы ошибок
sealed class ApiError {
object NotFound : ApiError()
data class ServerError(val code: Int) : ApiError()
data class AuthError(val message: String) : ApiError()
}
fun fetchUser(id: Int): Either<ApiError, User> {
return if (id == 0) {
Either.Left(ApiError.NotFound)
} else {
Either.Right(User(id, "John"))
}
}
fun main() {
when (val result = fetchUser(0)) {
is Either.Left -> when (result.value) {
is ApiError.NotFound -> println("Пользователь не найден")
is ApiError.ServerError -> println("Ошибка сервера: ${result.value.code}")
is ApiError.AuthError -> println("Ошибка авторизации")
}
is Either.Right -> println("Привет, ${result.value.name}")
}
}
Этот подход даёт полную типобезопасность. Компилятор проверяет, что вы обработали все возможные ошибки. Это идеально для API-клиентов, бизнес-логики и валидации форм.
4. Sealed классы для моделирования состояний
Sealed class — это мощный инструмент Kotlin, который позволяет создать закрытую иерархию типов. В контексте обработки ошибок он превосходит Result тем, что вы сами определяете все возможные состояния.
Пример: загрузка данных с кешированием
sealed class LoadingState<out T> {
object Loading : LoadingState<Nothing>()
data class Success<T>(val data: T) : LoadingState<T>()
data class Error(val message: String, val cause: Throwable? = null) : LoadingState<Nothing>()
}
fun loadData(): LoadingState<String> {
return try {
// Имитация