1. Помилки компіляції та помилки виконання
Якщо досі ви сприймали помилки як «червоне підкреслення в IDE», то тепер важливо розрізняти два світи. Є помилки, які Kotlin знаходить до запуску (на етапі компіляції), а є проблеми, що проявляються лише під час роботи програми. Винятки належать до другого типу: вони виникають тоді, коли програма вже запущена й натрапила на ситуацію, у якій не може продовжувати роботу «за планом».
Для наочності зручно порівняти це в таблиці:
| Що це | Коли виникає | Що ви бачите | Приклад |
|---|---|---|---|
| Помилка компіляції | До запуску програми | IDE «свариться», kotlinc не збирає | |
| Виняток (runtime exception) | Під час виконання | Програма падає, друкується повідомлення та stack trace | |
Виняток — це не «ще один if». Це аварійний механізм, як пожежна сигналізація. Якщо вона спрацювала й ніхто не знає, що робити, люди (тобто виконання програми) біжать до виходу — і програма завершується.
У Kotlin винятки представлені об’єктами — нащадками Throwable. У загальних рисах: Throwable → Exception / Error. Зазвичай у прикладному коді ми стикаємося з Exception, а Error — це радше історія про серйозні проблеми на рівні середовища виконання (наприклад, нестача пам’яті).
Що таке виняток
Уявіть, що ваша програма — це потяг, який їде рейками рядків коду. Звичайний потік виконання — потяг рухається за маршрутом: рядок 1, рядок 2, рядок 3… Виняток — це ситуація, коли на рейках раптово опинився бетонний блок: далі їхати не можна, і потяг не буде «акуратно» переїжджати перешкоду. Він зупиняється, а керування переходить до спеціального механізму обробки помилок.
Формально (і це дуже важливо запам’ятати): коли викидається виняток, нормальне виконання переривається. Буквально: «наступний рядок після проблемного не виконається», якщо виняток не перехоплено.
Мініприклад — класика жанру «ділення на нуль»:
fun main() {
println("start")
val a = 10
val b = 0
println(a / b) // ArithmeticException
println("end") // не виконається
}
Зверніть увагу: рядок println("end") виглядає невинно, але він не виконається. Тому що програма «вилетіла» зі звичного маршруту на рядку a / b.
2. Виняток перериває виконання і не дає «йти далі»
Із винятками новачків найчастіше бентежить одне: ми звикли, що програма — це «список команд», і вона виконує їх по черзі. А потім раптом виявляється, що цей порядок може різко обірватися. Тож повторю думку ще раз, але іншими словами: виняток — це не «повідомлення про помилку», а зміна керування.
Коли виникає виняток, поточна функція завершується не через return, а аварійно. Далі Kotlin/JVM намагається знайти місце, яке готове цей виняток обробити. Якщо такого місця немає, програма завершується, а ви бачите повідомлення про помилку та стек викликів.
Ще одна важлива деталь: виняток може виникнути не там, де ви написали «небезпечний» код, а «глибоко» всередині функцій, які ви викликали. Наприклад, ви викликаєте toInt(), а всередині нього Kotlin намагається перетворити рядок на число і, якщо не виходить, викидає NumberFormatException. Цей виняток «вилітає» назовні — до вашого коду.
Приклад:
fun main() {
val text = "12a"
val n = text.toInt() // NumberFormatException
println("n = $n") // не виконається
}
Тобто ви його не створювали, але виняток усе одно виник, бо бібліотечна функція «вирішила»: продовжувати не можна.
3. Звідки взагалі беруться винятки
На цьому етапі корисно не намагатися завчити сотню назв винятків, а побачити «типи ситуацій», які до них призводять. У простих навчальних програмах найчастіше трапляються три сімейства проблем: арифметика, перетворення рядків і індексація масивів.
Арифметика: ділення на нуль
З арифметикою все інтуїтивно: деякі операції математично не визначені. JVM не буде «здогадуватися», що ви мали на увазі, і викине виняток.
fun main() {
val total = 100
val days = 0
val perDay = total / days
println(perDay) // сюди не дійдемо
}
Перетворення рядка на число: «це не число»
Ви вже знаєте безпечний шлях toIntOrNull(). Але toInt() — «суворий» варіант. Якщо рядок не є коректним цілим числом, виникає NumberFormatException.
fun main() {
val raw = "сорок два"
val n = raw.toInt() // NumberFormatException
println(n)
}
Індекси масиву: вийшли за межі
Масиви мають фіксований розмір, а індекси мають бути в діапазоні 0..lastIndex. Якщо ні — ви отримаєте виняток часу виконання (на JVM це часто ArrayIndexOutOfBoundsException).
fun main() {
val xs = intArrayOf(10, 20, 30)
println(xs[2]) // 30
println(xs[5]) // ArrayIndexOutOfBoundsException
}
Психологічна пастка тут така: «ну індекс же число, я ж його перетворив!». Так, число. Але не будь-яке число — а припустиме. І виняток якраз повідомляє: «правило порушено».
4. Ієрархія винятків: Throwable, Exception, Error
Коли ви бачите повідомлення про помилку, там майже завжди є назва типу винятку. Щоб не сприймати це як набір випадкових заклять, корисно розуміти верхній рівень ієрархії.
Корінь ієрархії — Throwable. У нього є два важливі «нащадки першого рівня»: Exception і Error. Ідея така: Exception — це те, що в принципі може бути частиною нормального життя програми (помилка введення, недоступність ресурсу тощо), а Error — це зазвичай серйозні проблеми середовища, які «лагодити» на рівні вашої логіки безглуздо (наприклад, OutOfMemoryError).
На цьому етапі нам достатньо такої схеми:
flowchart TD
T[Throwable]
E[Exception]
ER[Error]
T --> E
T --> ER
E --> NFE[NumberFormatException]
E --> AE[ArithmeticException]
E --> AIOOBE[ArrayIndexOutOfBoundsException]
Важливо: це не означає, що ви зобов’язані знати всю ієрархію напам’ять. Але коли ви бачите ArithmeticException, варто думати так: «ага, це Exception, отже проблема на рівні виконання мого сценарію».
Причина і місце: назва винятку та stack trace
Коли програма падає, у новачка часто виникає відчуття: «зламалося десь». Але механізм винятків спеціально влаштований так, щоб дати вам дві підказки: що сталося (причина) і де сталося (місце).
Причина найчастіше «зашита» в назві типу та повідомленні. Наприклад, NumberFormatException буквально означає: «рядок не підходить за форматом числа». Місце зазвичай видно в stack trace — це ланцюжок викликів функцій, який привів до помилки. Kotlin/JVM у разі необробленого винятку друкує stack trace автоматично.
Мініприклад, який спеціально створює помилку:
fun main() {
throw ArithmeticException("Приклад винятку")
}
Приблизне виведення (вигляд може відрізнятися, але сенс буде той самий):
Exception in thread "main" java.lang.ArithmeticException: Приклад винятку
at MainKt.main(Main.kt:2)
Поки що не потрібно вміти читати stack trace ідеально (ми вчитимемося робити це окремо). Але вже зараз корисно знати: якщо програма впала, вона зазвичай залишила «квитанцію» про те, де саме це сталося.
5. Практичний приклад
Щоб винятки не залишалися абстракцією, давайте продовжимо писати невеликий консольний застосунок. Нехай він називається BudgetBuddy: користувач вводить витрати за кілька днів, а програма рахує суму та «середнє за день».
Поки що пишемо максимально просто — без обробки винятків (це буде в наступній лекції). Наша мета тут — побачити, як саме ламається потік виконання.
Наївна версія: «усе за планом»
Почнемо з простого сценарію: читаємо кількість днів, потім читаємо витрати за днями в масив, а далі рахуємо суму.
fun main() {
print("Скільки днів? ")
val days = readln().toInt()
val expenses = IntArray(days)
for (i in 0 until days) {
print("Витрата за день ${i + 1}: ")
expenses[i] = readln().toInt()
}
var total = 0
for (x in expenses) total += x
println("Разом: $total")
}
Якщо користувач вводить коректні числа, усе добре. Але «коректні числа» — це нездійсненна мрія.
Сценарій падіння: користувач увів не число
Нехай користувач вводить так:
- «Скільки днів?» → 3
- «Витрата за день 1» → 100
- «Витрата за день 2» → сто (або 100р)
На рядку readln().toInt() станеться NumberFormatException, і програма завершиться. Причому це станеться усередині циклу, тож ми навіть не дійдемо ні до суми, ні до фінального println("Разом...").
Сценарій падіння: користувач увів 0 днів (а ми захотіли середнє)
Додаймо середнє значення. Наївно:
fun main() {
print("Скільки днів? ")
val days = readln().toInt()
print("Сума витрат: ")
val total = readln().toInt()
val avg = total / days
println("Середнє за день: $avg")
}
Якщо days = 0, на рядку total / days буде ArithmeticException. І знову: рядок println(...) не виконається.
Що тут особливо підступно: навіть якщо days успішно перетворилося на число (тобто це справді число), значення все одно може бути неприйнятним для вашої логіки. Винятки часто виникають не під час розбору, а під час використання значення.
Сценарій падіння: неправильна робота з індексом
Уявімо, що ми хочемо показати «останню введену витрату», але помилилися й узяли індекс days, а не days - 1. (Це біль, який пережили покоління програмістів, і традиція триватиме.)
fun main() {
val expenses = intArrayOf(100, 200, 300)
val days = expenses.size
println(expenses[days]) // days == 3, а індекси 0..2
}
Це дасть ArrayIndexOutOfBoundsException. Програма знову аварійно завершиться.
Що відбувається під час винятку: мінісхема
Щоб закріпити модель, корисно уявити поведінку програми у вигляді схеми. Програма виконує рядки, доки все добре. Якщо якийсь рядок викидає виняток, відбувається «аварійний вихід» із поточної функції та підйом угору ланцюжком викликів.
flowchart TD
A[Виконуємо рядок за рядком] --> B{Сталася помилка?}
B -->|ні| A
B -->|так: виняток| C[Перериваємо поточну функцію]
C --> D[Шукаємо обробник вище по стеку]
D --> E{Знайшли обробник?}
E -->|ні| F[Друкуємо stack trace і завершуємо програму]
E -->|так| G[Передаємо керування обробнику]
Сьогодні вам достатньо запам’ятати гілку «не знайшли обробник → програма завершилася». Про гілку «знайшли обробник» ми детально говоритимемо в наступній лекції, коли з’явиться try/catch.
6. Типові помилки
Помилка № 1: плутати помилку компіляції та виняток часу виконання.
Якщо IDE підкреслює код ще до запуску — це помилка компіляції, програма навіть не стартує. Якщо ж програма запустилася, щось встигла вивести, а потім раптово «впала» й показала довгий текст — це виняток, тобто проблема часу виконання.
Помилка № 2: очікувати, що код після «проблемного рядка» все одно виконається.
Це трапляється найчастіше. Здається, що println("end") зобов’язаний надрукуватися, адже він стоїть «після» ділення. Але виняток ламає лінійність: виконання переривається. У голові потрібно перемкнути тумблер: «виняток = аварійний вихід».
Помилка № 3: вважати, що якщо ми використали toIntOrNull(), то винятків більше не буде.
toIntOrNull() справді рятує від NumberFormatException, але не захищає від ділення на нуль, виходу за межі масиву та логічних помилок. Це не «броня від усього», а інструмент проти конкретної причини.
Помилка № 4: ігнорувати назву винятку та читати stack trace як «страшне простирадло».
Навіть без навичок налагодження назва винятку часто дуже інформативна: NumberFormatException — не число, ArithmeticException — арифметика, ArrayIndexOutOfBoundsException — індекс повз касу. Kotlin/JVM спеціально дає ці підказки.
Помилка № 5: думати, що виняток — це «кінець світу», і тому краще «не чіпати».
Насправді винятки — нормальний робочий механізм. Проблема не в їх існуванні, а в тому, що ми поки не вміємо грамотно їх обробляти й перетворювати на зрозумілу поведінку програми. Цим якраз і займатимемося далі: навчимося ловити очікувані винятки та продовжувати роботу, не перетворюючи консоль на драмтеатр.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ