1. Як вибирається catch і чому важливий порядок
Коли ви вперше опановуєте try/catch, дуже хочеться зробити так: «гаразд, я зловлю все й напишу одне повідомлення». Це відчувається як броня, як чарівний щит від усіх бід. Проблема в тому, що такий щит — картонний: так, програма перестане падати, але ви перестанете розуміти, що відбувається. А користувач отримуватиме лише «Помилка» — без підказки, що саме він зробив не так.
У Kotlin обробник catch спрацьовує лише тоді, коли всередині try справді стався виняток. Водночас вибирається перший відповідний catch — зверху вниз. Причому «відповідний» означає не лише точний збіг типу, а й відповідність за ієрархією: якщо ви ловите загальніший клас винятків, він «накриє» і конкретніші. Для сьогоднішньої теми це принципово: порядок catch визначає поведінку програми.
Щоб відчути користь кількох catch, уявіть просту консольну програму, де користувач вводить число. Помилки можуть бути різні: він може ввести "12a" (це про формат), може ввести 0 (це про ділення на нуль), а може ввести індекс 100 для масиву з трьох елементів (це про вихід за межі). Якщо про все це повідомляти однією фразою, вийде сумний квест «вгадай, що не так».
Правило «перший відповідний»
Зараз буде трохи «формальної математики». Без неї далі легко написати код, який виглядає правильно… і працює не так, як ви очікуєте.
Вибір обробника можна уявити так:
flowchart TD
A[Код у try] --> B{Стався виняток?}
B -->|ні| C[Пропускаємо catch, йдемо далі]
B -->|так| D[Порівнюємо з 1-м catch]
D --> E{Тип підходить?}
E -->|так| F[Виконуємо цей catch]
E -->|ні| G[Пробуємо наступний catch]
G --> E
Ключовий момент: перевірка йде зверху вниз, і спрацює лише перший відповідний обробник.
Чому це повʼязано з ієрархією? Тому що у винятків є «батьківські» типи. Наприклад, NumberFormatException — це окремий випадок помилки, а Exception — дуже загальний «батько» для багатьох помилок часу виконання. Якщо ви поставите обробник для «батька» надто рано, він забере керування, і конкретніші обробники просто не матимуть шансу.
Спочатку конкретні, потім загальні
Дуже типовий шлях новачка виглядає так: «а давайте першим catch зловимо Exception, щоб уже точно…». Kotlin на це зазвичай відповідає доволі суворо: «нижче вже є catch для NumberFormatException, але він ніколи не виконається, бо Exception перехопить усе раніше». І це добре: компілятор буквально рятує вас від неробочої логіки.
Подивімося на приклад неправильного порядку. Він короткий, але показовий:
fun main() {
try {
println(readln().toInt())
} catch (e: Exception) {
println("Будь-яка помилка")
}
catch (e: NumberFormatException) { ... } // так не можна: недосяжно
}
Цей код демонструє принцип: загальний catch (e: Exception) має бути «останнім рубежем», якщо ви взагалі вирішили його писати.
А ось варіант, де порядок уже розумний: спочатку ловимо конкретну причину, а потім — максимально загальний варіант, якщо раптом сталося щось несподіване.
fun main() {
try {
println(readln().toInt())
} catch (e: NumberFormatException) {
println("Помилка: потрібно ввести ціле число") // наприклад: "12a"
} catch (e: Exception) {
println("Непередбачена помилка: ${e.message}")
}
}
Зверніть увагу: e.message може бути null, і це нормально (не всі винятки зобовʼязані мати текст). Але навіть із null ви принаймні розумієте: «щось пішло не за планом — і це не помилка формату».
До речі, якщо ви Java-програміст: Kotlin не вимагає ловити винятки явно (у Kotlin вони «unchecked»), але дозволяє це робити, якщо ви хочете перетворити падіння на контрольовану поведінку.
2. Гранулярність: наскільки точно ловити винятки
Гранулярність — це слово, яке звучить як назва дорогого кавового напою, але означає просту річ: наскільки детально ви розрізняєте причини помилок.
Можна ловити дуже конкретно: окремо NumberFormatException, окремо ArithmeticException, окремо ArrayIndexOutOfBoundsException. Тоді ви зможете дати користувачу максимально точну підказку. Але якщо ви напишете по 30 рядків у кожному catch, програма перетвориться на роман у трьох томах «Страждання обробників». Романи бувають цікавими, але компілятор їх не читає.
Можна ловити надто загально: одним catch (e: Exception) і друкувати «Помилка». Тоді програма не падає, але ви втрачаєте діагностику й можливість пояснити користувачу, що саме не так. У цей момент застосунок стає схожим на банкомат, який пише «ПОМИЛКА» і дивиться на вас із докором.
Здорова середина зазвичай така: очікувані, зрозумілі помилки обробляємо конкретно; несподіване — або взагалі не ловимо (нехай програма падає і показує трасування стека, особливо в навчальних проєктах), або ловимо останнім catch (e: Exception) і друкуємо нейтральне повідомлення, не вдаючи, що ми «все полагодили».
Невелика таблиця для орієнтира:
| Підхід | Що пишемо | Що отримуємо в підсумку |
|---|---|---|
| Надто загальний | catch (e: Exception) і один текст | «Не падає», але незрозуміло, чому |
| Надто дробний | 10 різних catch + складна логіка | Точно, але громіздко й важко підтримувати |
| Практичний | 2–4 конкретних + загальний наприкінці | І зрозуміло користувачу, і логіка читається |
Стратегія обробки: очікувані й неочікувані помилки
Зараз ми зберемо все в одну думку: кілька catch — це не «бо так красивіше», а тому що різні причини потребують різних дій.
Якщо користувач увів "12a" замість числа, це очікувана помилка введення. Програма має спокійно пояснити, що потрібно вводити цифри, і, можливо, попросити повторити введення (це ви вже вмієте робити через цикли). Це не катастрофа, а просто людський фактор: у людей є пальці.
Якщо користувач увів дільник 0, це теж очікувана ситуація. Так, ділити на нуль не можна, але це легко пояснити.
Якщо у вас раптово вилетів ArrayIndexOutOfBoundsException, тут ситуація двояка. Іноді це теж «очікувана» помилка (якщо користувач сам вводить індекс). Але іноді це звичайний баг у програмі: ви самі порахували індекс неправильно. І ось тут надто загальний catch може замаскувати реальну помилку — і тоді ви годинами шукатимете, чому «іноді програма поводиться дивно».
Тому стратегічно корисно поставити собі просте запитання: хто винен?
Якщо винен користувач (введення), пишемо дружнє повідомлення й лишаємося «живими». Якщо винен код (баг), краще, щоб розробник це побачив. У навчальних програмах іноді корисніше взагалі не ловити такий виняток, щоб отримати трасування стека й швидко знайти рядок, у якому ви помилилися.
3. Практичний приклад: мініпрограма «Оцінки»
Продовжімо практику в стилі «консольних міні застосунків», які ми будували з перших днів: введення, проста логіка, виведення. Нехай у нас є масив оцінок, і користувач хоче подивитися оцінку за індексом і порахувати «10 / d» (щоб спеціально мати шанс зловити ділення на нуль). Так, це штучний приклад, але він чесно тренує кілька різних винятків.
Спочатку зробімо маленький каркас даних:
fun main() {
val grades = intArrayOf(5, 4, 3)
print("Індекс оцінки: ")
val rawIndex = readln()
print("Дільник: ")
val rawDiv = readln()
// далі додамо try/catch
}
Тепер додамо try і кілька catch. Зверніть увагу: всередині try у нас одразу три місця, де може бути проблема: toInt() (формат), grades[i] (межі), 10 / d (ділення на нуль). А отже, має сенс розрізняти причини.
fun main() {
val grades = intArrayOf(5, 4, 3)
try {
val i = readln().toInt()
val d = readln().toInt()
println("Оцінка = ${grades[i]}")
println("10 / d = ${10 / d}")
} catch (e: NumberFormatException) {
println("Помилка: введення має бути цілим числом")
} catch (e: ArrayIndexOutOfBoundsException) {
println("Помилка: індекс поза діапазоном 0..${grades.lastIndex}")
} catch (e: ArithmeticException) {
println("Помилка: ділення на нуль")
}
}
Тут важливі два моменти.
Перший момент: ми ловимо три різні винятки й даємо три різні повідомлення. У цьому й полягає практична цінність кількох catch.
Другий момент — порядок. Тут немає Exception, але все одно зберігається ідея «спочатку конкретне». А якби ми додали загальний обробник, він мав би бути останнім — інакше він «зʼїсть» інші.
4. Мінімізуємо try: ділимо обробку на блоки
Зараз зробимо невеликий рефакторинг — у мисленні, а не «архітектуру століття». Часто радять: try має бути якомога меншим — накривати лише те, що справді може впасти і що ви дійсно хочете обробити.
У попередньому прикладі ми накрили багато одразу. Для теми «кілька catch» це нормально. Але в реальному житті вам іноді корисніше відокремити введення індексу від обчислень. Тоді ви точніше розумієте, яка операція дала збій, а код легше «тестується очима».
Наприклад, можна зробити так: один try — для парсингу чисел, другий — для роботи з масивом і ділення. Ми не зобовʼязані так робити, але як мисленнєвий інструмент це допомагає.
fun main() {
val grades = intArrayOf(5, 4, 3)
val i = try { readln().toInt() }
catch (e: NumberFormatException) { return }
val d = try { readln().toInt() }
catch (e: NumberFormatException) { return }
try {
println(grades[i])
println(10 / d)
} catch (e: Exception) {
println("Помилка обчислення: ${e.message}")
}
}
Тут я спеціально зробив return у разі неправильного введення. Це не «ідеальний UX», але це акуратна демонстрація: якщо введення некоректне, далі виконувати нічого — бо в нас немає валідних чисел. Ви вже знаєте, що return дає змогу швидко завершити функцію.
І так, тут є загальний catch (e: Exception) у другому блоці. Такий хід іноді допустимий як «останній рубіж», але його варто використовувати усвідомлено й тримати простим.
5. Повідомлення в catch і загальний обробник
Що казати користувачу
Коли ви вже вмієте ловити різні винятки, зʼявляється нова небезпека: почати писати в catch «справжню логіку застосунку», ніби це звичайна гілка if. Так можна, але це швидко бʼє по читабельності: основний сценарій стає рваним, бо логіка розповзається по обробниках.
У консольній навчальній програмі зазвичай достатньо, щоб catch робив три речі: коротко пояснював проблему, підказував правильне введення й повертав програму в контрольований стан (наприклад, завершував виконання або просив повторити введення).
Наприклад, якщо ви ловите NumberFormatException, корисно сказати «потрібно ввести ціле число», а не виводити користувачу полотно на кшталт «java.lang.NumberFormatException: For input string: ...». Таке повідомлення приємне лише тому, хто хотів побачити трасування стека (тобто вам, коли ви налагоджуєтеся), а не користувачу.
Загальний catch як страховка, а не мішок для сміття
Загальний catch (e: Exception) — це як великий пакет «різне» у коморі. Він іноді потрібен, але якщо ви складаєте туди все підряд, ви перестаєте розуміти, де у вас що лежить.
Якщо ви додаєте загальний catch, тримайте в голові два правила.
По-перше, він має бути останнім. Інакше конкретніші обробники стануть недосяжними, бо загальний обробник перехопить винятки раніше.
По-друге, він не повинен удавати, що «все полагодив». Якщо ви ловите взагалі все, повідомлення має бути чесним і нейтральним: «непередбачена помилка» — і, можливо, e.message. Ви не зобовʼязані «лікувати» те, чого не розумієте.
Ось типовий шаблон для навчальної консольної програми:
fun main() {
try {
println("Результат: ${100 / readln().toInt()}")
} catch (e: NumberFormatException) {
println("Помилка: потрібно ціле число")
} catch (e: ArithmeticException) {
println("Помилка: ділення на нуль")
} catch (e: Exception) {
println("Непередбачена помилка: ${e.message}")
}
}
Тут два перші catch — «очікувані» помилки для цього сценарію, а останній — страховка.
6. Типові помилки під час роботи з кількома catch
Помилка № 1: ставити catch (e: Exception) першим.
Це виглядає як «зроблю універсально», але за змістом ламає всю ідею кількох обробників. Загальніший тип перехоплює і конкретніші випадки, і в результаті ваш акуратний catch (e: NumberFormatException) або стає недосяжним (компілятор поскаржиться), або ви просто ніколи не побачите, що він узагалі спрацьовує. Правильна звичка — розташовувати catch від найконкретніших до найзагальніших.
Помилка № 2: робити однаковий текст у всіх catch.
Якщо в кожному обробнику ви друкуєте «Помилка», ви формально написали кілька catch, але практичної користі від них — нуль. Сенс розгалуження в тому, щоб реакції відрізнялися: «не число» і «ділення на нуль» — це різні підказки користувачу, і їх варто розділяти хоча б текстом.
Помилка № 3: перетворювати catch на «другий main» зі складною логікою.
В обробниках новачок іноді починає писати половину програми: повторно вводити дані, створювати заново масиви, запускати цикли й друкувати звіти на 40 рядків. Так код стає важко читати: основний сценарій розпадається на шматки. Краще, щоб catch був коротким, а складна логіка жила у звичайних функціях і в звичайному потоці виконання.
Помилка № 4: ловити виняток «про всяк випадок» і мовчки його ковтати.
Порожній catch — це як заклеїти лампочку «Check Engine» ізоляційною стрічкою: машина не перестає ламатися, ви просто перестаєте про це дізнаватися. Навіть у навчальній програмі мінімум — вивести зрозумілий текст. Інакше ви втрачаєте діагностику, а потім дивуєтеся, чому «воно іноді нічого не робить».
Помилка № 5: плутати «де сталося» і «де обробили».
Виняток виникає в конкретному рядку всередині try, а обробляється в catch. Якщо в голові немає цього звʼязку, зʼявляються дивні очікування на кшталт «я напишу catch, і програма продовжить із того самого рядка». Ні: рядок, де стався виняток, не «закінчується», виконання стрибає в catch. Тому після try/catch має сенс тримати код так, щоб він коректно працював і за успішного сценарію, і за сценарію обробки помилки.
Помилка № 6: надто широкий try, який приховує джерело проблеми.
Коли try накриває «пів програми», ви втрачаєте розуміння, яка саме операція потенційно падає і яку помилку ви насправді обробляєте. Це особливо неприємно, якщо всередині try багато різних дій: введення, обчислення, робота з масивами. Намагайтеся, щоб try обгортав невеликий фрагмент, який ви можете пояснити словами: «ось тут я парсю число» або «ось тут я звертаюся до елемента масиву».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ