1. Stack trace: что это и как читать
Когда новичок видит красный текст в консоли, первая реакция часто такая: «Всё сломалось, ничего не понятно, компьютер меня ненавидит». На самом деле красный текст — это почти всегда подсказка, причём довольно подробная. И самая полезная его часть — stack trace: цепочка вызовов функций, которая приводит к месту, где произошла ошибка.
Важно принять простую модель: программа исполняется по строкам, но часто не «линейно», а прыгая между функциями. Когда исключение не обработано, среда выполнения печатает отчёт: какой тип ошибки случился и по каким функциям мы шли. Kotlin на JVM печатает stack trace именно так: строка с типом исключения, а дальше несколько строк вида at ... (File.kt:line).
Stack trace как «маршрут» к месту падения
Если очень по-человечески, stack trace — это «маршрут», по которому программа пришла к месту падения. В документации Kotlin это объясняют как отчёт, который показывает последовательность вызовов функций до точки, где случилась ошибка. Это удобно: даже если ошибка произошла «глубоко» внутри вспомогательной функции, вы увидите, кто её вызвал, и кто вызвал того, кто вызвал… почти как родословная, только вместо бабушек — функции.
На JVM stack trace обычно печатается автоматически, если исключение никто не поймал. Например, если вы просто бросили исключение, то увидите тип, сообщение и строки at .... Давайте разберём, из чего состоит одна строка trace’а, чтобы вы не смотрели на неё как на древнее заклинание.
Анатомия строки stack trace
| Пример строки | Как читать | Зачем это нужно |
|---|---|---|
|
Упало/вызвано в функции main, файл Main.kt, строка 3 | Это почти всегда «координаты» проблемы |
|
Исключение в потоке "main" (в консольных задачах обычно так и будет) | Скорее контекст; главное — тип и координаты |
|
Тип исключения и сообщение | Тип часто сразу объясняет причину |
Как читать stack trace по шагам
Очень легко утонуть в stack trace, если пытаться читать его как роман. Он не роман. Он ближе к GPS-логу: «где был, куда свернул, где врезался в забор». Поэтому есть дисциплина чтения: сначала определяем что случилось, потом где случилось, а потом уже разбираемся почему.
Сначала смотрим на тип исключения: NumberFormatException почти всегда значит «строку пытались сделать числом, но строка была не числом». ArrayIndexOutOfBoundsException почти всегда значит «индекс вышел за границы массива». Дальше ищем строку с (...kt:NN) — это номер строки в файле.
Чтобы закрепить, нарисуем очень простую схему того, что происходит при необработанном исключении:
flowchart TD
A["Выполнение main()"] --> B["Вызов функции A()"]
B --> C["Вызов функции B()"]
C --> D["Внутри B() возникло исключение"]
D --> E{"Есть catch в B()?"}
E -->|да| F[Обработка и продолжение]
E -->|нет| G["Исключение 'поднимается' в A()"]
G --> H{"Есть catch в A()?"}
H -->|нет| I["Исключение 'поднимается' в main()"]
I --> J{"Есть catch в main()?"}
J -->|нет| K[Печать stack trace и завершение программы]
Мини-пример: цепочка вызовов в stack trace
Сейчас сделаем пример, который специально «падает», чтобы посмотреть на trace. Он будет коротким, но важным: мы увидим, что в stack trace отражается цепочка функций, а не только одна строка.
fun level1() = level2()
fun level2() = level3()
fun level3() {
println(10 / 0) // тут будет ArithmeticException
}
fun main() {
level1()
}
Что здесь важно понять: деление на ноль происходит в level3, но запустили мы всё из main, и между ними есть level1, level2. В stack trace обычно будет видно, что вызовы шли сверху вниз, пока не дошли до проблемной операции.
2. Практика: ExpenseBuddy и падение на вводе
Чтобы отладка была похожа на жизнь, а не на лабораторную по делению на ноль, давайте продолжим одно маленькое приложение, которое мы можем развивать по курсу: ExpenseBuddy — консольный учёт расходов. Пока что без коллекций и классов (они будут позже), поэтому храним данные простым способом: массивом фиксированного размера и счётчиком.
Идея такая: у нас есть массив Array<Pair<String, Int>?>, где пара — это (категория, сумма). Массив фиксированный, например на 5 записей, чтобы не усложнять. Наша задача сегодня — научиться находить, где именно программа упала, если пользователь ввёл что-то неожиданное.
Версия 0: добавление расхода, которое может упасть
fun readAmount(): Int {
print("Сумма (целое число): ")
return readln().trim().toInt() // может упасть на "12a"
}
fun addExpense(expenses: Array<Pair<String, Int>?>, index: Int) {
print("Категория: ")
val category = readln().trim()
val amount = readAmount()
expenses[index] = category to amount
println("Сохранено: ${expenses[index]}")
}
fun main() {
val expenses = arrayOfNulls<Pair<String, Int>>(5)
addExpense(expenses, 0)
}
Если ввести сумму 12a, то toInt() выбросит NumberFormatException. И если мы не ловим это исключение, JVM выведет stack trace.
Смысл в том, что stack trace поможет вам ответить на два вопроса:
Первый — что случилось: NumberFormatException (строка была не числом).
Второй — где случилось: строка с readAmount() и указанием файла/строки. У вас будет что-то вроде ...readAmount(ВашФайл.kt:NN).
Почему trace понятный, но причина всё равно не ясна
На этом месте обычно случается такая ситуация: stack trace показывает, где упало, но вы смотрите на строку и думаете: «Да, тут toInt(). Но почему оно не число? Я же вводил число… кажется…».
Вот тут и появляется второй инструмент: debugger. Он полезен, когда вам надо увидеть фактические значения переменных прямо перед проблемной строкой. Например: что реально лежит в raw после readln(), как отработал trim(), не прилетел ли пустой ввод, не подставили ли вы где-то 0 через ?:, и так далее.
Debugger нужен не потому, что вы «плохой программист», а потому что человек плохо симулирует выполнение программы в голове. Особенно если в голове ещё параллельно крутится мысль «а что поесть».
3. Debugger: базовые приёмы в IntelliJ IDEA
Debugger — это режим запуска, где программа может остановиться на выбранной строке, и вы можете посмотреть, что происходит внутри: значения переменных, какой сейчас шаг, какая функция выполняется. Самый базовый приём — breakpoint (точка останова): вы ставите её на строке, и при запуске в режиме Debug выполнение остановится до выполнения этой строки.
Дальше вы идёте маленькими шагами. Обычно вас интересуют три кнопки:
- Step Over — выполнить текущую строку, не заходя внутрь вызванной функции.
- Step Into — зайти внутрь функции, которая вызывается на текущей строке.
- (Иногда) Step Out — выйти из текущей функции на строку, которая её вызвала.
На этом уровне нам достаточно первых двух: «не заходить» и «зайти».
Отлаживаем ExpenseBuddy: breakpoint перед опасной строкой
Возьмём readAmount() и сделаем его чуть более «дебажным»: сохраним введённую строку в переменную, чтобы нам было что смотреть в окне Variables.
fun readAmount(): Int {
print("Сумма (целое число): ")
val raw = readln()
val prepared = raw.trim()
return prepared.toInt() // breakpoint сюда
}
Что делать в IDE (словами, без магии):
Вы ставите breakpoint на строку return prepared.toInt(). Запускаете программу в режиме Debug. Вводите, например, 12a. Программа остановится на breakpoint, и вы увидите значения:
- raw (например "12a" или " 12a "),
- prepared (например "12a").
После этого вы можете нажать Step Over — и увидеть, как программа падает на этой строке. Главное: вы теперь видели своими глазами, что prepared не число.
Step Over vs Step Into: как не «улететь» в сторону
Очень частая путаница новичка: «Я нажал какую-то кнопку, и меня куда-то унесло». Чтобы этого не было, давайте потрогаем Step Into на маленьком примере.
Сделаем функцию-помощник:
fun parseAmountOrZero(text: String): Int {
return text.trim().toIntOrNull() ?: 0
}
fun main() {
print("Сумма: ")
val raw = readln()
val amount = parseAmountOrZero(raw) // breakpoint сюда
println("amount = $amount") // amount = 0 (если ввели "12a")
}
Если поставить breakpoint на строку вызова parseAmountOrZero(raw) и нажать Step Into, вы попадёте внутрь parseAmountOrZero. Это полезно, когда результат неожиданен (например, вы ожидали 12, а получили 0). Step Into помогает увидеть, где именно «свернули не туда»: trim() не то сделал, toIntOrNull() вернул null, Elvis ?: подставил 0.
Где смотреть текущее место: Variables и Call Stack
Окно Variables показывает значения переменных в текущей точке. Это первое, что стоит смотреть, потому что большинство багов новичка — это «значение не то». Но есть ещё одна вещь, очень похожая на stack trace: Call Stack (стек вызовов) прямо в debugger.
И это красиво замыкает круг: stack trace — это «посмертный отчёт», когда программа уже упала и рассказывает, по каким функциям шла. Call Stack в debugger — это «живой стек», прямо сейчас, пока программа стоит на паузе.
Если вы остановились внутри readAmount(), Call Stack покажет, что readAmount() вызвали из addExpense(), а addExpense() — из main(). Это почти то же самое, что вы бы увидели в stack trace при падении, только без падения.
Нюанс Kotlin: странные имена в debugger
Иногда при отладке Kotlin-кода в Variables можно встретить переменные с именами вроде $i$f$... или похожие «шпионские псевдонимы». Это не заговор компилятора против вас (хотя звучит правдоподобно), а технические маркеры, которые Kotlin-компилятор генерирует для отладки inline-функций и лямбд, чтобы корректнее строить stepping и inline stack traces.
На нашем уровне вывод простой: если увидели странные $i$f$... — не пугайтесь и не пытайтесь «починить код». Смотрите на свои переменные (raw, prepared, amount), а служебные маркеры просто игнорируйте.
Что выбрать: stack trace или debugger
Stack trace хорош, когда исключение уже случилось и вам нужно быстро понять «где и что». Он особенно полезен, если вы видите файл и строку, и там очевидная проблема: деление на ноль, toInt() на мусорной строке, индекс массива вне диапазона.
Debugger лучше, когда «где» вы примерно знаете, но не понимаете «почему именно так». Например: откуда взялся 0, почему строка пустая, почему индекс стал 5, кто изменил переменную, почему ветка if сработала не так, как вы думали. Там нужен взгляд на значения переменных прямо перед ключевыми действиями.
4. Типичные ошибки со stack trace и debugger
Ошибка №1: читать stack trace снизу вверх и искать «последнюю строку».
Часто внизу действительно есть строка at ..., но новичок начинает копаться в самых последних строчках, хотя самое важное обычно ближе к началу: тип исключения и первые «ваши» фреймы с файлами .kt. Лучше сначала найти тип (NumberFormatException и т.п.), потом первую строку, где указан ваш файл и номер строки.
Ошибка №2: смотреть только на сообщение исключения и игнорировать тип.
Сообщение (e.message) иногда полезное, но тип почти всегда полезнее. «For input string: ...» — это хорошо, но NumberFormatException сразу говорит класс проблемы: строка не распарсилась в число. Это экономит время.
Ошибка №3: ставить breakpoint после опасной строки.
Breakpoint после prepared.toInt() бесполезен, если toInt() падает: туда выполнение просто не дойдёт. Точка останова должна быть до подозрительной операции, чтобы вы успели увидеть значения переменных.
Ошибка №4: использовать Step Into везде подряд и теряться в чужом коде.
Если вы нажимаете Step Into на toInt() или на println(), вы рискуете улететь в библиотечный код (или в Java-код), и новичку там становится грустно. Хорошая привычка: Step Over — по умолчанию, Step Into — только когда вы хотите зайти в свою функцию-помощник.
Ошибка №5: пытаться дебажить без ожиданий.
Debugger показывает факты, но чтобы понять, что факт странный, нужно иметь ожидание. Перед тем как нажимать кнопки, полезно хотя бы мысленно сказать: «Я ожидаю, что prepared будет "123"». Если видите "12a" — всё, причина ясна. Без ожиданий вы будете просто смотреть на числа как на курс валют.
Ошибка №6: лечить падение огромным try/catch вокруг всей программы.
Это не отладка, а ковёр, под который вы замели мусор (и потом споткнулись о ковёр). Debugger и stack trace нужны как раз для того, чтобы найти точку проблемы и починить причину, а не сделать вид, что всё нормально.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ