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: думать, что исключение — это «конец света», и поэтому лучше «не трогать».
На самом деле исключения — нормальный рабочий механизм. Проблема не в их существовании, а в том, что мы пока не умеем их грамотно обрабатывать и превращать в понятное поведение программы. Этим как раз и займёмся дальше: научимся ловить ожидаемые исключения и продолжать работу, не превращая консоль в драмтеатр.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ