Поиск ошибок: читать след, сужать, объяснять
- Зачем это нужно
- След вызовов: где смотреть
- Caused by: причина лежит внизу
- Четыре исключения, которые встретятся чаще всего
- Ошибка без исключения
- Порядок, который экономит часы
- Проверки, которые ловят ошибку раньше
- Печать против отладчика
- Что делать, когда ничего не понятно
- Типичные ошибки
- Куда углубиться
- Куда дальше: проверенные ресурсы
Зачем это нужно
Писать код учат все. Искать в нём ошибки — почти никто, хотя на это уходит больше времени, чем на само написание.
На чемпионате это видно особенно ясно. Умение за пять минут понять, почему экран пустой, отличает того, кто закроет восемь критериев, от того, кто закроет четыре и весь день будет менять строчки наугад. Причём разница не в знании 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)
Читается это так:
- Первая строка — что за ошибка и с какими данными: просили элемент номер 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
Сверху — то, что решили сказать вслух: «не удалось обновить экран». Это симптом. А настоящая причина ниже, после слова Caused by: пытались превратить в число строку «сорок два».
Caused by может быть несколько, вложенных друг в друга. Читать надо до самого нижнего — именно там лежит то, что случилось на самом деле. А строка ... 4 more не прячет ничего интересного: это повтор тех же вызовов, что уже показаны выше.
Четыре исключения, которые встретятся чаще всего
Все тексты ниже сняты с настоящих падений.
Обращение за пределы списка:
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 пропускают чаще всего, и это дорого обходится. Правка «наугад, вдруг поможет» опасна даже когда помогает: причина осталась неизвестной, а значит ошибка вернётся — скорее всего, на демонстрации.
Если объяснить не получается, значит вы ещё на шаге 2 и сузили недостаточно.
Проверки, которые ловят ошибку раньше
Чем раньше программа заметит неладное, тем короче след и понятнее причина. В 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("тут!") в готовом коде выглядит ровно так, как выглядит.
Что делать, когда ничего не понятно
Несколько приёмов на случай, когда след прочитан, а яснее не стало.
Минимальный пример. Выкиньте из падающего кода всё, что можно выкинуть, пока падение не исчезнет. Последнее выкинутое и было причиной. Часто ответ находится посреди этой работы, ещё до конца.
Сравнение с рабочим. Если один экран работает, а похожий нет — сравните их построчно. Разница и есть ошибка.
Рассказать вслух. Объяснить проблему человеку рядом — а если некому, то вслух самому себе. Приём известен как «метод резинового утёнка» и работает неприлично хорошо: половина ошибок находится на середине объяснения.
Откатиться. git stash или git checkout на последний работающий коммит. Работает? Значит дело в правках, и их можно возвращать по частям. Это тот самый шаг 2, но на уровне истории.
Отдельно про чемпионат: если ошибка не поддаётся за пятнадцать минут — отложите её и сделайте другой критерий. Один незакрытый пункт стоит меньше, чем полдня, потраченные на него в ущерб остальным. К отложенной ошибке потом возвращаются со свежей головой и часто видят причину сразу.
Типичные ошибки
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 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.
Куда дальше: проверенные ресурсы
- Kotlin — исключения —
require,check,errorи когда бросать исключение. - Отладка приложений в Android Studio — точки останова, пошаговое выполнение, просмотр переменных.
- Logcat: просмотр журналов — фильтры по тегу и уровню.
- Kotlin — null-безопасность — чем заменить
!!, чтобы причина была видна.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.