Обработка ошибок в Kotlin: try/catch и продвинутые техники

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Введение: Почему стандартного 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 {
        // Имитация                

Блоги

Рекомендаций по книгам