JavaRush /Курси /Kotlin SELF /Stack trace і базовий debugger

Stack trace і базовий debugger

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

1. Stack trace: що це і як читати

Коли новачок бачить червоний текст у консолі, перша реакція часто така: «Усе зламалося, нічого не зрозуміло, компʼютер мене ненавидить». Насправді червоний текст — це майже завжди підказка, причому доволі детальна. А найкорисніша його частина — stack trace: ланцюжок викликів функцій, який приводить до місця, де сталася помилка.

Важливо прийняти просту модель: програма виконується рядок за рядком, але часто не «лінійно», а стрибаючи між функціями. Коли виняток не оброблено, середовище виконання друкує звіт: який тип помилки стався і якими функціями ми йшли. Kotlin на JVM друкує stack trace саме так: рядок із типом винятку, а далі — кілька рядків вигляду at ... (File.kt:line).

Stack trace як «маршрут» до місця падіння

Якщо сказати зовсім просто, stack trace — це «маршрут», яким програма дійшла до місця падіння. У документації Kotlin це пояснюють як звіт, що показує послідовність викликів функцій до точки, де сталася помилка. Це зручно: навіть якщо помилка трапилася «глибоко» всередині допоміжної функції, ви побачите, хто її викликав, і хто викликав того, хто викликав… майже як родовід, тільки замість бабусь — функції.

На JVM stack trace зазвичай друкується автоматично, якщо виняток ніхто не перехопив. Наприклад, якщо ви просто кинули виняток, то побачите тип, повідомлення й рядки at .... Давайте розберемося, з чого складається один рядок stack trace, щоб ви не дивилися на нього як на давнє заклинання.

Анатомія рядка stack trace

Приклад рядка Як читати Навіщо це потрібно
at MainKt.main(Main.kt:3)
Упало/викликано у функції main, файл Main.kt, рядок 3 Це майже завжди «координати» проблеми
Exception in thread "main" ...
Виняток у потоці "main" (у консольних завданнях зазвичай так і буде) Радше контекст; головне — тип і координати
java.lang.ArithmeticException: ...
Тип винятку та повідомлення Тип часто одразу підказує причину

Як читати 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

Зараз зробимо приклад, який спеціально «падає», щоб подивитися на stack 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).

Чому stack 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 trace.

На нашому рівні висновок простий: якщо побачили дивні $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 потрібні якраз для того, щоб знайти точку проблеми й виправити причину, а не зробити вигляд, що все нормально.

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