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). Якщо ви постійно використовуєте винятки для керування звичайними гілками логіки, ви ускладнюєте читання коду. Винятки добрі, коли сталося справді «щось не так», а не коли користувач просто ввів зайвий пробіл.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ