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 { ... } — варіант, у якому ви задаєте запасне значення:

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) { "Використання: 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
Опитування
Винятки, рівень 40, лекція 4
Недоступний
Винятки
Винятки поглиблено
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ