1. Коли потрібен try/catch
Запитання справедливе: «Навіщо мені try/catch, якщо я можу написати readln().toIntOrNull() і жити спокійно?» У багатьох випадках саме так і варто робити. Але винятки мають неприємну особливість: вони виникають не лише там, де ви їх очікуєте. І не завжди для потрібної операції існує «безпечна версія».
Наприклад, toInt() для неправильного рядка викидає виняток (а не повертає null). Ділення на нуль теж викидає виняток. Індекс масиву поза діапазоном — так само. Якщо ви хочете, щоб застосунок не завершувався аварійно, вам потрібен механізм «перехопити проблему й продовжити». Саме це й робить try/catch: «спробуй виконати небезпечний код; якщо сталася очікувана помилка — не падай, а зроби зрозумілу дію».
З погляду Kotlin це виглядає так: виконання коду в try може перерватися винятком. Тоді керування переходить у відповідний catch.
Ментальна модель: try/catch — це не «ще один if»
Дуже важливо налаштувати правильну «картинку в голові». if — це вибір гілки у межах нормального потоку виконання. Ви перевірили умову — і спокійно пішли потрібним шляхом. Виняток — це не шлях. Це люк у підлозі: ви йдете-йдете, а потім раптово провалюєтеся в підвал.
try/catch — це спосіб натягнути під цим люком страхувальну сітку. Ви не кажете: «якщо буде помилка — зроби ось так». Ви кажете: «спробуй; якщо зірвешся — я впіймаю». Тому try/catch особливо корисний там, де ви не можете заздалегідь усе перевірити умовою (або така перевірка буде громіздкою). Також він потрібен там, де причина проблеми проявляється саме як виняток.
Синтаксис try/catch і обʼєкт винятку e
Розберімо форму, яку ви писатимете найчастіше. У Kotlin мінімальний варіант обробки очікуваної помилки — це try { ... } catch (e: SomeException) { ... }. У try ми кладемо те, що потенційно може «впасти», а в catch — те, що хочемо зробити замість аварійного завершення застосунку.
Найкорисніше для новачка в catch — це змінна e (її зазвичай так і називають), тобто обʼєкт винятку. У нього є як мінімум message (рядок із текстом помилки; інколи це null) і сам тип винятку. На цьому етапі вам не потрібно запамʼятовувати всю ієрархію винятків. Але важливо зрозуміти просту річ: тип винятку — це підказка, «що саме пішло не так».
Мінішаблон:
fun main() {
try {
// тут те, що може "впасти"
} catch (e: Exception) {
println("Щось пішло не так: ${e.message}")
}
}
Зверніть увагу: ловити одразу Exception — це «широка сітка». Іноді так справді роблять, але сьогодні ми тренуємо правильну звичку: ловити конкретну очікувану помилку, а не «все підряд».
2. Приклад: рядок → число через toInt()
Почнімо з класичної ситуації: користувач вводить число, але вводить не число. Раніше ми розвʼязували це через toIntOrNull(). Тепер спеціально візьмемо toInt(), щоб побачити, як виглядає «падіння» і як try/catch перетворює його на нормальне повідомлення.
fun main() {
print("Введіть ваш вік: ")
val raw = readln()
try {
val age = raw.toInt()
println("Вік = $age") // наприклад: Вік = 25
} catch (e: NumberFormatException) {
println("Помилка: це не схоже на ціле число") // якщо ввели "12а"
}
}
Тут важливо помітити таку поведінку: якщо toInt() «падає», ми не виконуємо рядок println("Вік = $age"). Замість цього керування одразу переходить у catch.
І ще один невеликий, але практичний момент: не варто намагатися «вгадувати» e.message і виводити його користувачу як готовий текст. Такі повідомлення часто не надто дружні. Для користувача зазвичай краще ваш текст («Введіть ціле число»), а e.message — радше підказка для вас.
Приклад: ділення і ArithmeticException
Тепер ситуація, коли користувач міг увести число правильно, але помилка все одно сталася. Ділення на нуль — ідеальний приклад: «введення коректне, але за змістом не підходить».
Зробімо невеличкий фрагмент для нашого навчального застосунку: хочемо порахувати середні витрати на день. Для цього вводимо суму та кількість днів.
fun main() {
print("Сума витрат: ")
val total = readln().toIntOrNull() ?: 0
print("Кількість днів: ")
val days = readln().toIntOrNull() ?: 0
try {
val avg = total / days
println("У середньому: $avg на день") // наприклад: У середньому: 100 на день
} catch (e: ArithmeticException) {
println("Помилка: не можна ділити на 0 днів (логічно, так?)")
}
}
Чому тут try/catch узагалі потрібен, якщо ми використали toIntOrNull()? Тому що помилка виникає не під час перетворення рядка, а під час арифметичної операції. Ви можете скільки завгодно «безпечно» отримувати 0, але ділення total / 0 усе одно лишається діленням на нуль.
3. Розмір try
Коли новачок уперше дізнається про try/catch, зазвичай трапляються дві крайнощі: або не ставити try взагалі, або зробити один величезний try на весь main і ловити все підряд. Другий варіант здається зручним: «ну зате не впаде». Але за цю зручність доводиться платити: ви перестаєте розуміти, що саме впало і де шукати причину.
Хороший стиль на старті такий: try має бути мінімальним. Тобто обгортати лише ту операцію, яка саме тут справді може викинути очікуваний виняток. Що менший try, то менше «магії». Так легше зневаджувати, і ви точніше формулюєте обробку.
Порівняйте два варіанти. Поганий (надто широкий):
fun main() {
try {
print("Введіть число: ")
val n = readln().toInt()
println("Квадрат: ${n * n}")
println("Готово!")
} catch (e: NumberFormatException) {
println("Не число")
}
}
Код наче нормальний, але уявіть, що помилка буде не в toInt(), а десь пізніше (не сьогодні, але загалом). Ви спіймаєте NumberFormatException, хоча причина могла бути зовсім іншою.
Трохи акуратніше (мінімальний try навколо toInt()):
fun main() {
print("Введіть число: ")
val raw = readln()
val n = try {
raw.toInt()
} catch (e: NumberFormatException) {
println("Не число, беру 0")
0
}
println("Квадрат: ${n * n}") // якщо ввели "abc" -> Квадрат: 0
}
Формально тут try використано як вираз (тобто він повертає значення). Kotlin це дозволяє: результат береться з останнього виразу в try або в catch.
4. Патерн «ловимо → пояснюємо → продовжуємо»
Найпрактичніша користь try/catch у консольних програмах — зробити так, щоб користувач міг помилятися, а застосунок не перетворювався на «увів не те — до побачення». Щоб це працювало, зазвичай потрібен цикл: питаємо, намагаємося обробити, якщо є помилка — пояснюємо й повторюємо.
Напишемо функцію readIntWithRetry(prompt: String): Int, яка просить увести ціле число й повторює введення, доки не вийде.
fun readIntWithRetry(prompt: String): Int {
while (true) {
print(prompt)
val raw = readln()
try {
return raw.toInt()
} catch (e: NumberFormatException) {
println("Потрібне ціле число. Спробуйте ще раз.")
}
}
}
Зверніть увагу на стиль: у try в нас рівно одна «небезпечна» операція — toInt(). Якщо вона спрацювала, ми робимо return і виходимо з функції. Якщо ні — друкуємо повідомлення, і цикл повторюється.
Тепер можна використовувати її в main, і код стане чистішим:
fun main() {
val total = readIntWithRetry("Сума витрат: ")
val days = readIntWithRetry("Кількість днів: ")
println("Ви ввели total=$total, days=$days")
}
5. Міні-застосунок: облік витрат зі стійким введенням
Щоб приклади не виглядали набором окремих шматочків, зберімо маленький консольний застосунок, який можна буде доповнювати далі за курсом. Нехай це буде «скарбничка витрат»: додаємо витрати, показуємо суму, рахуємо середнє (і не падаємо).
Поки що ми не використовуємо when (він буде пізніше), тож команди зробимо через if/else. Дані збережемо в IntArray, бо масиви ви вже знаєте.
Заготовка сховища і додавання витрати
Спочатку накиньмо зберігання: масив expenses і лічильник count. А також маленьку функцію для додавання.
fun addExpense(expenses: IntArray, count: Int, value: Int): Int {
expenses[count] = value
return count + 1
}
Так, поки що ми не перевіряємо переповнення масиву. Просто тримаємо в голові, що це навчальний приклад (і що масив не гумовий).
Сума витрат
fun total(expenses: IntArray, count: Int): Int {
var sum = 0
var i = 0
while (i < count) {
sum += expenses[i]
i++
}
return sum
}
Середнє значення: можлива помилка ділення на нуль
Ось тут у нас зʼявляється природна точка для try/catch: середнє = сума / кількість. Якщо count == 0, буде ділення на нуль.
fun average(expenses: IntArray, count: Int): Int {
val sum = total(expenses, count)
return try {
sum / count
} catch (e: ArithmeticException) {
0
}
}
Цей приклад показує практичний сенс try/catch як виразу: ми хочемо повернути число в разі успіху або «розумний» 0, якщо порахувати неможливо.
main: команди і стійке введення
Тепер зробімо простий цикл команд. Нехай список команд буде такий: "add", "total", "avg", "exit".
fun main() {
val expenses = IntArray(100)
var count = 0
while (true) {
print("Команда (add/total/avg/exit): ")
val cmd = readln().trim()
if (cmd == "add") {
val value = readIntWithRetry("Введіть витрату (ціле число): ")
count = addExpense(expenses, count, value)
println("Додано: $value") // наприклад: Додано: 150
} else if (cmd == "total") {
println("Сума: ${total(expenses, count)}")
} else if (cmd == "avg") {
println("Середнє: ${average(expenses, count)}")
} else if (cmd == "exit") {
println("Бувайте!")
return
} else {
println("Невідома команда: $cmd")
}
}
}
Що важливо за змістом: застосунок більше не падає через банальне введення "qwe" замість числа. Він спокійно каже «Потрібне ціле число» і просить повторити. Це і є те саме «ловимо → пояснюємо → продовжуємо».
6. Типові помилки під час роботи з try/catch
Помилка №1: ловити Exception «про всяк випадок», не розуміючи, що саме ви очікуєте.
Цього хочеться з найкращих намірів: «аби не впало». Але так ви перетворюєте помилки на кашу: застосунок скаже «щось пішло не так», а вам потім буде незрозуміло, чому. На старті правило просте: якщо ви очікуєте «увели не число», ловіть NumberFormatException. Якщо очікуєте ділення на нуль — ловіть ArithmeticException. Загальний Exception краще лишати на потім — коли ви вже розумієте стратегію обробки.
Помилка №2: робити try надто великим і змішувати в ньому введення, обчислення та виведення.
Величезний try створює ефект «чорної скриньки»: щось усередині впало, але ви втрачаєте контекст. До того ж легко випадково перехопити виняток не там, де ви його справді очікували. Практика для новачка: у try кладіть одну-дві конкретні операції, які можуть викинути конкретну очікувану помилку.
Помилка №3: порожній catch, який «проковтує» помилку мовчки.
Якщо ви нічого не робите в catch, застосунок може продовжити роботу в дивному стані: дані не зчиталися, значення не обчислилося, але зовні «все ок». Потім це дуже боляче зневаджувати. Навіть якщо ви не знаєте, що робити, краще хоча б вивести зрозуміле повідомлення користувачу або собі.
Помилка №4: показувати користувачу лише ${e.message} і сподіватися, що це хороший UX.
Повідомлення винятків часто технічні, а інколи це взагалі null. Для користувача краще «Введіть ціле число» або «Ділити на нуль не можна». А e.message — радше допоміжна деталь, яку можна додати в дужках, якщо у вас є режим зневадження.
Помилка №5: сподіватися, що try/catch замінює валідацію.
try/catch допомагає, але не робить код «розумним». Наприклад, якщо ви хочете заборонити відʼємні витрати, це не виняток — це бізнес-правило. Його краще перевірити if (value < 0) і пояснити користувачу. Винятки — для справді «аварійних» ситуацій виконання, а не для звичайної логіки.
Помилка №6: перехопити виняток і продовжити, але забути відновити стан.
Якщо ви почали змінювати змінні, а потім щось упало, можна лишити застосунок у «напівзламаному» стані. Тому хороший стиль такий: спочатку отримати й перевірити введення, а вже потім змінювати стан (наприклад, збільшувати count). У нашому прикладі ми спочатку читаємо value і лише потім додаємо його в масив. Це невелика, але важлива звичка.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ