Перейти к основному содержимому
Мобилка10ГЛАВА 10Поиск ошибок: след, сужение, причинаIndexOutOfBounds: 5/1at java.util.Collections…at Catalog.byIndex(Main.kt:6)at Screen.show(Main.kt:12)at MainKt.main(Main.kt:24)Caused by: …первая своя строкасузить и объяснить

Поиск ошибок: читать след, сужать, объяснять

Прочитано0%
Квизы0/5
Уровень 1 · Новичок

Зачем это нужно​

Писать код учат все. Искать в нём ошибки — почти никто, хотя на это уходит больше времени, чем на само написание.

На чемпионате это видно особенно ясно. Умение за пять минут понять, почему экран пустой, отличает того, кто закроет восемь критериев, от того, кто закроет четыре и весь день будет менять строчки наугад. Причём разница не в знании Kotlin: оба знают язык одинаково.

Отладка — это навык с понятной техникой, которой можно научиться. В этой главе — четыре приёма, и первый из них — просто внимательно прочитать то, что программа уже сказала.

След вызовов: где смотреть​

Когда программа падает, она печатает след вызовов (stack trace). Выглядит он пугающе, но устроен просто.

Вот настоящий след упавшей программы:

java.lang.IndexOutOfBoundsException: Index: 5, Size: 1
at java.base/java.util.Collections$SingletonList.get(Collections.java:4959)
at console.Catalog.byIndex(Main.kt:6)
at console.Screen.show(Main.kt:12)
at console.MainKt.main(Main.kt:24)
at console.MainKt.main(Main.kt)
СЛЕД ЧИТАЕТСЯ СВЕРХУ, НО СМОТРЕТЬ НАДО НА СВОЁjava.lang.IndexOutOfBoundsException: Index: 5, Size: 1at java.util.Collections$SingletonList.get(…)at console.Catalog.byIndex(Main.kt:6)at console.Screen.show(Main.kt:12)at console.MainKt.main(Main.kt:24)ниже — кто кого позвал, вплоть до запуска программычужой кодсюда не леземпервая СВОЯ строказдесь и открывают файлостальное — путь,по которому пришливерхняя строка почти всегда чужая: падает библиотека, а виноват тот, кто её так позвалискать нужно первую строку со своим пакетом и номером строки своего файла
Первая строка следа — чужая; искать надо первую свою

Читается это так:

  • Первая строка — что за ошибка и с какими данными: просили элемент номер 5, а размер списка 1.
  • Дальше — кто кого вызвал, от места падения вниз к началу программы.
  • Верхняя строка at почти всегда чужая: java.util.Collections — это стандартная библиотека. Туда лезть незачем, она работает правильно.
  • Первая строка со своим пакетом — console.Catalog.byIndex(Main.kt:6) — вот где открывать файл. Ошибка проявилась здесь.
  • Строки ниже показывают путь: Screen.show позвал byIndex, а его позвал main из строки 24.
Важно

Типичная ошибка новичка — начать читать след сверху и уйти в исходники чужой библиотеки. Библиотека не сломана: она честно сообщила, что её позвали с числом 5 при размере 1.

Правило: ищите первую строку, в которой стоит имя вашего файла и номер строки. Это и есть место, откуда начинать.

Проверить это можно прямо сейчас — программа ниже падает точно так же:

import java.io.PrintWriter import java.io.StringWriter data class Product(val title: String, val price: Int) class Catalog(private val items: List<Product>) { fun byIndex(i: Int): Product = items[i] } class Screen(private val catalog: Catalog) { fun show(i: Int) = println("показываю ${catalog.byIndex(i).title}") } /** Печатает след исключения целиком, как это сделала бы упавшая программа. */ fun trace(block: () -> Unit) { try { block() } catch (e: Throwable) { val sw = StringWriter() e.printStackTrace(PrintWriter(sw)) println(sw.toString().trim()) } } fun main() { val screen = Screen(Catalog(listOf(Product("Nike", 10500)))) screen.show(0) println() trace { screen.show(5) } }

Caused by: причина лежит внизу​

Второй важный случай — когда исключение обёрнуто. Так делают, чтобы добавить к технической ошибке понятное объяснение:

class ViewModel(private val repo: Repository) {
fun refresh(raw: String): Int =
try {
repo.load(raw)
} catch (e: Exception) {
throw IllegalStateException("не удалось обновить экран", e)
}
}
Нажми на часть выражения

Настоящий след такой ошибки:

java.lang.IllegalStateException: не удалось обновить экран
at console.ViewModel.refresh(Main.kt:12)
at console.MainKt.main(Main.kt:33)
Caused by: java.lang.NumberFormatException: For input string: "сорок два"
at java.base/java.lang.Integer.parseInt(Integer.java:668)
at console.Repository.load(Main.kt:4)
at console.ViewModel.refresh(Main.kt:10)
... 4 more
ПРИ ОБЁРТКЕ ПРИЧИНА ЛЕЖИТ ВНИЗУjava.lang.IllegalStateException: не удалось обновить экранat console.ViewModel.refresh(Main.kt:12)это симптомCaused by: java.lang.NumberFormatException: For input string: "сорок два"at console.Repository.load(Main.kt:4)это причиначитать надо до самого нижнего «Caused by» — там настоящая ошибка, «… 4 more» лишь прячет повтор
Сверху симптом, снизу настоящая причина

Сверху — то, что решили сказать вслух: «не удалось обновить экран». Это симптом. А настоящая причина ниже, после слова Caused by: пытались превратить в число строку «сорок два».

Совет

Caused by может быть несколько, вложенных друг в друга. Читать надо до самого нижнего — именно там лежит то, что случилось на самом деле. А строка ... 4 more не прячет ничего интересного: это повтор тех же вызовов, что уже показаны выше.

Отвечено вопросов: 0 из 3
С какой строки следа вызовов начинать поиск ошибки?
В следе есть Caused by. Где настоящая причина?
Что означает «… 4 more» в конце следа?
Отвечено верно: 0 из 3

Четыре исключения, которые встретятся чаще всего​

Все тексты ниже сняты с настоящих падений.

Обращение за пределы списка:

java.lang.IndexOutOfBoundsException: Index: 5, Size: 1

Сообщение исчерпывающее: просили шестой элемент, а их всего один.

Разыменование null:

java.lang.NullPointerException

И больше ничего. Это худший случай: сообщения нет вовсе.

Важно

Пустой NullPointerException — почти всегда след от оператора !!. Он означает «я ручаюсь, что здесь не null», и когда ручательство не оправдалось, объяснять уже нечего.

Поэтому !! и не любят: он превращает понятную проверку в исключение без текста. Там, где вместо него стоял бы ?: с осмысленным сообщением, вы бы сразу прочитали, что именно оказалось пустым.

Неверное приведение типа:

java.lang.ClassCastException: class java.lang.String cannot be cast to class java.lang.Integer
(java.lang.String and java.lang.Integer are in module java.base of loader 'bootstrap')

Здесь всё названо: был String, пытались считать Integer.

Деление на ноль:

java.lang.ArithmeticException: / by zero

Только для целых чисел. У Double деление на ноль не падает вовсе — и это иногда хуже. Живой прогон:

10.0 / 0.0 = Infinity
-10.0 / 0.0 = -Infinity
0.0 / 0.0 = NaN
isFinite? false
10 / 0 (Int) → ArithmeticException: / by zero
сумма с Infinity = Infinity

Последняя строка объясняет, почему это опаснее падения: Infinity спокойно участвует в дальнейших вычислениях и заражает результат. Экран покажет «Infinity ₽» или пустоту, и найти место, где всё пошло не так, будет негде — исключения не было. Проверять такие значения приходится самому: isFinite() или isNaN().

Ошибка без исключения​

Бывает хуже, чем падение: программа работает, не жалуется, а результат неверный. Падение хотя бы показывает место — здесь не показано ничего.

Вот такая ошибка. Функция считает сумму корзины:

fun total(lines: List<Line>): Int = lines.sumOf { it.price }

Забыли умножить на количество. Живой прогон:

── ошибка, которую видно только на данных ──
сломанный расчёт: 17400
верный расчёт: 38400
на корзине из одного товара по 1 шт. обе версии совпадают:
сломанный=10500 верный=10500
Интересный факт

Обратите внимание на последнюю строку. На корзине из одного товара в одном экземпляре обе версии дают одинаковый ответ. Именно поэтому такая ошибка доживает до демонстрации: разработчик проверял на одном товаре, а эксперт положил в корзину три.

Отсюда практическое правило: проверяйте на данных, где значения разные и больше единицы. Ошибки умножения, сложения и порядка на единицах не видны.

Как её ловят? Печатают промежуточные значения и сравнивают с тем, что должно получиться:

Nike: 10500 × 3 = 31500
Puma: 6900 × 1 = 6900
сумма должна быть 38400, а получили 17400

Разница видна сразу: 17400 — это 10500 + 6900, то есть сумма цен без количеств. Ответ найден раньше, чем открыт отладчик.

Порядок, который экономит часы​

Четыре шага, и порядок в них важнее каждого по отдельности.

ПОРЯДОК, КОТОРЫЙ ЭКОНОМИТ ЧАСЫ1воспроизвестиошибка по команде, а не «иногда»2сузитьделим данные и код пополам3объяснитьпочему именно так — до правки4починитьи проверить тем же шагом 1шаг 3 пропускают чаще всего — и чинят симптом, оставив причину на местеправка «наугад, вдруг поможет» опасна вдвойне: иногда помогает, и тогда причина остаётся неизвестнойневоспроизводимую ошибку нельзя ни починить, ни проверить — поэтому шаг 1 первый
Воспроизвести, сузить, объяснить, починить

1. Воспроизвести. Добиться, чтобы ошибка появлялась по команде, а не «иногда». Пока она случайна, её нельзя ни изучить, ни проверить, что починили. Это самый недооценённый шаг: часто на нём и выясняется, что «ошибка» была опечаткой в тестовых данных.

2. Сузить. Делим пополам — и данные, и код. Корзина из десяти товаров ломается? Попробуйте пять. Потом два. Потом один. Не работает экран целиком? Отключите половину и посмотрите.

3. Объяснить. Сказать вслух, почему происходит именно это. Не «наверное, из-за сети», а «список пуст, потому что фильтр применяется до загрузки».

4. Починить — и проверить тем же способом, которым воспроизводили в шаге 1.

Важно

Шаг 3 пропускают чаще всего, и это дорого обходится. Правка «наугад, вдруг поможет» опасна даже когда помогает: причина осталась неизвестной, а значит ошибка вернётся — скорее всего, на демонстрации.

Если объяснить не получается, значит вы ещё на шаге 2 и сузили недостаточно.

Отвечено вопросов: 0 из 3
Почему воспроизведение — первый шаг, а не последний?
Какой шаг пропускают чаще всего?
Сумма корзины считается неверно, но на одном товаре в одном экземпляре всё сходится. Почему?
Отвечено верно: 0 из 3

Проверки, которые ловят ошибку раньше​

Чем раньше программа заметит неладное, тем короче след и понятнее причина. В Kotlin для этого есть четыре короткие функции. Их настоящие сообщения:

fun show(label: String, block: () -> Unit) { print("$label → ") try { block(); println("прошло") } catch (e: Throwable) { println("${e::class.simpleName}: ${e.message}") } } fun main() { show("require(false)") { require(1 > 2) { "количество должно быть больше нуля" } } show("requireNotNull(null)") { requireNotNull(null as String?) { "товар не найден" } } show("check(false)") { check(false) { "корзина уже оформлена" } } show("error(...)") { error("сюда попадать не должны") } show("require без сообщения") { require(1 > 2) } }

Настоящий вывод:

require(false) → IllegalArgumentException: количество должно быть больше нуля
requireNotNull(null) → IllegalArgumentException: товар не найден
check(false) → IllegalStateException: корзина уже оформлена
error(...) → IllegalStateException: сюда попадать не должны
require без сообщения → IllegalArgumentException: Failed requirement.
Нажми на часть выражения
Совет

Последняя строка вывода объясняет, зачем писать сообщение: без него получается Failed requirement. — текст, который не говорит ничего. Секунда на осмысленное сообщение экономит десять минут на площадке.

Печать против отладчика​

Два способа заглянуть внутрь работающей программы.

Печать значений (println) — грубо, но мгновенно. Годится, когда нужно увидеть, как значение меняется по ходу цикла, или когда падение происходит в месте, куда отладчиком не добраться.

Отладчик — точка останова (breakpoint) в поле слева от номера строки, запуск через Debug вместо Run (Shift+F9 в Android Studio на Windows и Linux, ⌃D на Mac). Программа замирает, и видны все переменные сразу, без единой строчки лишнего кода.

Интересный факт

Ошибка не в том, чтобы выбрать «неправильный» способ, — оба нормальны. Ошибка в том, чтобы не пользоваться отладчиком вовсе и на каждый вопрос добавлять новый println, пересобирая проект по кругу. На третьем круге отладчик уже быстрее.

Обратная крайность тоже бывает: разбираться отладчиком там, где хватило бы одной печати в цикле.

Про отладочные печати важно помнить одно: они не должны доехать до сдачи. Требования чемпионата прямо говорят про логирование — печать через println логированием не считается, а забытый println("тут!") в готовом коде выглядит ровно так, как выглядит.

Отвечено вопросов: 0 из 3
Когда печать значений уместнее отладчика?
В чём главная ошибка при выборе способа отладки?
Что делать с отладочными println перед сдачей?
Отвечено верно: 0 из 3

Что делать, когда ничего не понятно​

Несколько приёмов на случай, когда след прочитан, а яснее не стало.

Минимальный пример. Выкиньте из падающего кода всё, что можно выкинуть, пока падение не исчезнет. Последнее выкинутое и было причиной. Часто ответ находится посреди этой работы, ещё до конца.

Сравнение с рабочим. Если один экран работает, а похожий нет — сравните их построчно. Разница и есть ошибка.

Рассказать вслух. Объяснить проблему человеку рядом — а если некому, то вслух самому себе. Приём известен как «метод резинового утёнка» и работает неприлично хорошо: половина ошибок находится на середине объяснения.

Откатиться. git stash или git checkout на последний работающий коммит. Работает? Значит дело в правках, и их можно возвращать по частям. Это тот самый шаг 2, но на уровне истории.

Совет

Отдельно про чемпионат: если ошибка не поддаётся за пятнадцать минут — отложите её и сделайте другой критерий. Один незакрытый пункт стоит меньше, чем полдня, потраченные на него в ущерб остальным. К отложенной ошибке потом возвращаются со свежей головой и часто видят причину сразу.

Отвечено вопросов: 0 из 3
Чем require отличается от check?
Зачем сообщение в require пишут в фигурных скобках, а не в круглых?
Ошибка не поддаётся пятнадцать минут во время спринта. Что правильнее?
Отвечено верно: 0 из 3

Типичные ошибки​

1. Читать след сверху и уходить в чужую библиотеку. Смотреть надо первую свою строку.

2. Остановиться на верхнем исключении при наличии Caused by. Наверху симптом, причина внизу.

3. !! вместо проверки. Даёт NullPointerException без единого слова объяснения.

4. Проверять на данных из одного элемента. Ошибки умножения и порядка на единицах не видны.

5. Править наугад. Иногда помогает — и это худший исход: причина осталась неизвестной.

6. Чинить, не воспроизведя. Нечем проверить, что починили.

7. require без сообщения. Failed requirement. не говорит ничего.

8. Не пользоваться отладчиком вовсе. Десятый println и десятая пересборка — дороже одной точки останова.

9. Забытые отладочные печати в готовом коде. Логированием не считаются, а видны сразу.

10. Сидеть на одной ошибке полдня. На спринте это стоит других критериев.

Куда углубиться​

  • Условные точки останова: остановиться только когда i == 42, а не на каждом круге цикла.
  • Logcat и фильтры по тегу и уровню — когда программа работает на устройстве, а не в консоли.
  • Evaluate Expression в отладчике: выполнить произвольный код прямо в момент остановки.
  • Тест как способ воспроизведения: падающий тест — это воспроизведение, которое не забудется.
  • git bisect — поиск коммита, который сломал работавшее, делением пополам по истории.

Сводная проверка по всей главе: 8 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.

Куда дальше: проверенные ресурсы​

Видео по теме

ВидеоУбийца багов: Как читать Stack Trace и чинить код в Idea. Ты больше не будешь бояться ошибок!Ilya Lisov - Backend Development
ВидеоExceptions, Try-Catch, Stack Trace - Основи програмування KotlinRoman Andrushchenko
ВидеоНеделя 2: 9 Ошибки выполнения; исключительные ситуации; Stack TraceWeb Applications Development
Видео#180 Kotlin Tutorial | Stack Trace with Default HandlerMIHIR MODI
ВидеоKotlin Newbie To Pro - NULL VALUES AND USER INPUT - Part 10Philipp Lackner

Комментарии

Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.