JavaRush /Курси /Kotlin SELF /try/catch для очікуваних помилок

try/catch для очікуваних помилок

Kotlin SELF
Рівень 12 , Лекція 2
Відкрита

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 і лише потім додаємо його в масив. Це невелика, але важлива звичка.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ