JavaRush /Курсы /Kotlin SELF /runCatching: try/catch, onSuccess/onFailure

runCatching: try/catch, onSuccess/onFailure

Kotlin SELF
40 уровень , 4 лекция
Открыта

1. Введение

Когда вы только начинаете программировать, try/catch кажется чем-то вроде пожарного огнетушителя: висит на стене, пусть будет. А когда пишете реальный код (даже консольный), внезапно выясняется, что «огнетушитель» нужен постоянно: парсинг чисел, проверки предусловий через require, доступ к коллекциям, работа с внешними данными — всё это потенциальные источники исключений.

Проблема не в том, что try/catch плохой. Проблема в том, что он быстро разрастается: появляются лишние переменные, вложенные блоки, и код начинает напоминать лабиринт. Kotlin помогает тем, что try/catch является выражением (можно присвоить результат в val). Но всё равно хочется более «функциональной» формы: получить результат, а дальше работать с ним цепочкой — как мы привыкли с коллекциями (map, filter и т.д.).

Вот здесь и появляется runCatching: он превращает «код, который может бросить исключение» в значение типа Result<T>, с которым можно работать как с обычным объектом: достать значение, обработать ошибку, напечатать сообщение, вернуть дальше наверх.

2. Что делает runCatching на самом деле

Если говорить честно, runCatching — это не «новая система обработки ошибок». Это всего лишь компактная упаковка try/catch в Result. Он не решает за вас, что считать ошибкой и где её обрабатывать. Он просто меняет форму: было «исключение как управление потоком», стало «значение Result».

Важно держать в голове ещё одну деталь: try/catch в Kotlin возвращает значение последнего выражения в try или в сработавшем catch, а finally выполняется всегда, но не влияет на вычисленное значение. Эта модель очень похожа по идее: «внутри что-то произошло → на выходе получился результат».

Представьте, что runCatching концептуально делает примерно это (упрощённо):

fun <T> myRunCatching(block: () -> T): Result<T> =
    try {
        Result.success(block())
    } catch (e: Throwable) {
        Result.failure(e)
    }

Синтаксис runCatching { ... } позволяет не писать всё это каждый раз.

Мини-пример на понятной задаче — парсинг числа:

fun parseIntViaTry(text: String): Int? {
    return try {
        text.toInt()
    } catch (e: NumberFormatException) {
        null
    }
}

А теперь то же самое через runCatching, только мы возвращаем не Int?, а Result<Int>:

fun parseIntResult(text: String): Result<Int> {
    return runCatching { text.toInt() }
}

Смысловая разница большая: Int? говорит только «получилось/не получилось», но не объясняет почему. Result<Int> хранит исключение, то есть сохраняет информацию о причине ошибки.

3. Как достать значение из Result, не превращая код в кашу

На этом этапе у новичков обычно возникает желание сделать так: «О, Result! Значит, я сейчас достану значение и пойду дальше». И это нормально — просто важно делать это осознанно, потому что в этот момент вы выбираете политику обработки ошибок.

В Kotlin try/catch как выражение уже приучил нас, что «всё заканчивается значением». Result — такая же идея, только значение может быть «успех» или «ошибка».

Давайте посмотрим на самые практичные способы извлечения:

fun main() {
    val r1 = runCatching { "123".toInt() }
    println(r1.getOrNull())          // 123

    val r2 = runCatching { "oops".toInt() }
    println(r2.getOrNull())          // null
}

getOrNull() хорош, когда вам действительно достаточно «получилось/нет». Но есть риск: вы получили null и забыли, что внутри лежала ошибка. Это почти как выключить сигнализацию, потому что она громко пищит.

getOrElse { ... } — вариант, где вы задаёте fallback-значение:

fun main() {
    val amount = runCatching { "oops".toInt() }
        .getOrElse { 0 }

    println(amount)                  // 0
}

Это удобно, но опасно, если 0 меняет смысл программы. Иногда 0 — нормальный дефолт, иногда — «мы случайно записали нулевой расход и потом удивляемся отчётам».

И есть «жёсткий» вариант — getOrThrow(): он возвращает значение или снова бросает исключение. Это полезно, если на данном уровне вы не хотите принимать решение и считаете ошибку аварийной.

4. onSuccess и onFailure: ветвление без if, но с дисциплиной

Когда вы видите onSuccess { ... } и onFailure { ... }, мозг радостно думает: «Ура, я могу писать обработку красиво цепочкой!» И это правда. Но у этих методов есть важная особенность: они предназначены для побочных эффектов (напечатать, обновить состояние, записать сообщение), а не для «превращения ошибки в значение».

То есть onSuccess и onFailure не заменяют вам getOrElse и не «чинят» ошибку. Они просто дают удобное место, где можно сделать что-то по исходу.

Мини-пример:

fun main() {
    runCatching { "42".toInt() }
        .onSuccess { println("Успех: $it") }           // Успех: 42
        .onFailure { println("Ошибка: ${it.message}") }

    runCatching { "x".toInt() }
        .onSuccess { println("Успех: $it") }
        .onFailure { println("Ошибка: ${it.message}") } // Ошибка: For input string: "x"
}

Обратите внимание на психологический эффект: даже если мы пока не решили, что делать дальше, мы хотя бы не потеряли факт ошибки — она проявилась в явном месте.

И здесь появляется ключевой принцип лекции: пустой onFailure { } — это почти всегда тревожный звоночек. Если вы написали обработчик ошибки, который ничего не делает, вы создали «чёрную дыру», куда исчезают причины проблем.

5. runCatching в консольном приложении: парсинг команды

Сейчас будет важный момент: мы не пишем runCatching «просто потому что можно». Мы используем его как инструмент внутри конкретного слоя, чтобы код стал проще, но контракт ошибок остался понятным.

Представим, что наш практический проект — консольный трекер расходов BudgetBuddy. Он уже умеет принимать команды вроде:

  • add 120 food
  • list
  • remove 3

(Мы не будем усложнять список команд — нам здесь важна техника обработки ошибок, а не функциональность приложения.)

Схематично поток выглядит так:

flowchart TD
    A["readln()"] --> B["parseCommand(line)"]
    B -->|Result.Success| C["executeCommand(cmd)"]
    B -->|Result.Failure| D[печать ошибки пользователю]
    C --> E[печать результата]

Идея: парсинг команды — идеальное место для runCatching, потому что там много потенциальных исключений (toInt(), require(...)), а результат удобно вернуть наверх как Result.

Сделаем маленькую модель команды добавления:

data class AddExpenseCommand(
    val amount: Int,
    val category: String,
)

Теперь парсер, который возвращает Result<AddExpenseCommand>:

fun parseAddCommand(tokens: List<String>): Result<AddExpenseCommand> {
    return runCatching {
        require(tokens.size >= 3) { "Usage: add <amount> <category>" }
        val amount = tokens[1].toInt()
        val category = tokens[2]
        AddExpenseCommand(amount = amount, category = category)
    }
}

Здесь есть важная «магия без магии»: require(...) при провале бросает исключение (обычно IllegalArgumentException), а toInt() может бросить NumberFormatException. Мы не ловим их руками — мы даём им случиться, а runCatching упакует их в Result.failure.

Обработка результата в CLI через onSuccess/onFailure

Теперь давайте покажем, как это выглядит в консольном слое. Мы читаем строку, режем её на токены и пробуем распарсить.

Этот кусок важен тем, что именно здесь чаще всего рождается «глушение»: разработчик увидел ошибку, устал, сделал getOrElse { ... }, и программа перестала падать… но начала вести себя странно.

Сделаем аккуратный обработчик:

fun handleLine(line: String) {
    val tokens = line.trim().split(" ").filter { it.isNotBlank() }
    if (tokens.isEmpty()) return

    when (tokens[0]) {
        "add" -> parseAddCommand(tokens)
            .onSuccess { cmd ->
                println("Добавляю расход: ${cmd.amount}, категория=${cmd.category}")
            }
            .onFailure { e ->
                println("Ошибка команды add: ${e.message}")
            }

        else -> println("Неизвестная команда: ${tokens[0]}")
    }
}

Почему это хорошо: ошибка не исчезает. Мы явно сообщаем пользователю, что именно было не так. По сути, onFailure здесь — это наш «человеческий переводчик» ошибок.

И да, это не заменяет контракт: если на этом уровне ошибка критична, мы бы не печатали сообщение, а, возможно, завершали программу. Но для CLI-команд парсинг часто является ожидаемой ошибкой («пользователь ввёл не то») — значит, сообщение и продолжение работы выглядят адекватно.

6. Глушение ошибок: как оно выглядит и почему это больно

Слово «глушение» звучит как что-то из мира автосервисов: «Сейчас мы вам заглушим ошибку, и лампочка “Check Engine” перестанет бесить». Лампочка и правда перестанет… а двигатель нет.

В программировании «глушение» обычно случается в двух формах.

Первая форма — вы сделали getOrNull() и дальше игнорируете null, подставляя дефолт, который ломает смысл:

fun parseAmountUnsafe(text: String): Int {
    return runCatching { text.toInt() }.getOrNull() ?: 0
}

Если 0 допустим — может быть нормально. Но если 0 означает «бесплатно» или «расхода нет», вы только что незаметно для себя поменяли бизнес-логику. Пользователь ввёл add abc food, а вы добавили расход 0 food. И всё «успешно». Пугающе успешно.

Вторая форма — «пустой обработчик»:

fun parseAmountSilently(text: String): Result<Int> {
    return runCatching { text.toInt() }
        .onFailure { /* тишина */ }
}

Снаружи кажется, что вы «обработали ошибку», но нет — вы просто сделали её невидимой.

Как защититься? Почти всегда помогают два правила.

Первое правило: если вы делаете fallback (getOrElse), он должен быть осмысленным, и желательно сопровождаться сообщением пользователю. Второе правило: если вы не готовы принимать решение здесь, не принимайте — верните Result выше или пробросьте исключение.

Пример «осмысленного fallback с объяснением»:

fun parseAmountWithMessage(text: String): Int {
    return runCatching { text.toInt() }
        .getOrElse { e ->
            println("Некорректная сумма '$text': ${e.message}")
            0
        }
}

Да, это всё ещё fallback. Но теперь хотя бы понятно, почему он случился.

7. Когда runCatching не лучший выбор: лучше честный try/catch

Очень соблазнительно взять runCatching и обернуть им «вообще всё». Но тогда вы превращаете программу в набор контейнеров, где ошибки ездят по конвейеру и нигде не выходят на поверхность.

Кроме того, runCatching ловит Throwable (то есть потенциально слишком широкий спектр проблем). Иногда вам нужно поймать строго конкретную ошибку и обработать её как ожидаемую, а всё остальное — не трогать. В таких случаях try/catch с конкретным типом исключения проще читается и надёжнее по смыслу. И Kotlin прямо показывает базовую форму try/catch и напоминает, что try/catch может быть выражением, возвращающим значение.

Например, если вы хотите считать «не число» ожидаемой ошибкой, а всё остальное пусть падает (как авария), то честный try/catch иногда понятнее:

fun parseIntOrZero(text: String): Int {
    return try {
        text.toInt()
    } catch (e: NumberFormatException) {
        0
    }
}

Это читается прямолинейно: «если не число — верни 0». А вот runCatching здесь добавляет лишний слой абстракции, который не всегда нужен.

8. Типичные ошибки при использовании runCatching

Ошибка №1: оборачивать в runCatching слишком большой кусок логики.
Когда в runCatching попадает «половина функции», вы теряете понимание, какая именно операция могла упасть. Потом в onFailure вы видите исключение, но контекст размывается: непонятно, это toInt(), это require, это доступ к списку или что-то ещё. Лекарство простое: оборачивайте минимальный участок, который реально может бросить исключение, особенно если дальше вы хотите дать человеку понятное сообщение.

Ошибка №2: писать onFailure { } «на всякий случай».
Пустой onFailure — это почти буквальное «я знаю, что может быть ошибка, но мне всё равно». Так появляются баги, которые невозможно воспроизвести: программа иногда делает странности, но логов нет, сообщений нет, и исключение никто не видел. Если вы уж добавили onFailure, сделайте там хотя бы минимальную реакцию: сообщение, возврат Error, прекращение сценария — любое осмысленное решение.

Ошибка №3: превращать любую ошибку в дефолт без анализа (getOrElse { 0 } повсюду).
Иногда кажется, что дефолт — это добро: программа «не падает». Но если дефолт ломает смысл, вы просто переносите ошибку дальше, где она вылезет в отчётах, итогах и пользовательских данных. Это тот случай, когда «не упало» хуже, чем «упало сразу»: потому что вы сохранили неправильное состояние.

Ошибка №4: путать смену формы и обработку.
runCatching меняет форму ошибки: из исключения делает Result.failure. Но это не обработка. Обработка — это когда вы приняли решение: продолжать/остановиться, вернуть пользователю сообщение, вернуть Error наружу, пробросить исключение. Если после runCatching вы нигде не приняли решение и не показали ошибку — она, скорее всего, просто исчезла.

Ошибка №5: смешивать Result<T> и свой sealed-результат без причины.
В проекте часто есть своя конвенция Ok/Error (например, в доменном слое). Result тоже «успех/ошибка», но с другой философией. Если вы начнёте возвращать то Result, то Ok/Error в соседних функциях без чёткого правила, код станет трудно читать. Обычно хорошо работает подход: внутри слоя можно использовать runCatching и Result, а на границе слоя превращать это в ваш стабильный контракт (Ok/Error) с понятным сообщением.

Ошибка №6: считать, что runCatching — это аналог валидации.
runCatching ловит исключения. Но многие проверки лучше выражаются не через исключения, а через явную валидацию (например, if/when и возврат Error). Если вы постоянно используете исключения для управления обычными ветками логики, вы усложняете чтение кода. Исключения хороши, когда произошло действительно «что-то не так», а не когда пользователь просто ввёл лишний пробел.

1
Задача
Kotlin SELF, 40 уровень, 4 лекция
Недоступна
Сканер жетона
Сканер жетона
1
Задача
Kotlin SELF, 40 уровень, 4 лекция
Недоступна
Деление в отчёте
Деление в отчёте
1
Задача
Kotlin SELF, 40 уровень, 4 лекция
Недоступна
Скидка дня
Скидка дня
1
Задача
Kotlin SELF, 40 уровень, 4 лекция
Недоступна
Путеводитель городов
Путеводитель городов
1
Опрос
Исключения, 40 уровень, 4 лекция
Недоступен
Исключения
Исключения углубленно
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ