Базовый CS с нуля
Отладка как рассуждение
Программа делает не то. Инстинкт новичка — начать менять строки: перевернуть сравнение,
добавить + 1, передвинуть оператор — запустить снова и надеяться. Это угадывание. Оно
иногда кажется работающим и ничему тебя не учит, потому что ты так и не узнал, почему
баг существовал.
Есть совершенно другой способ это делать, и он опирается на один факт, который ты знаешь с Блока 03: машина детерминирована. Одна и та же инструкция при одном и том же состоянии всегда делает ровно одно и то же. Компьютер никогда не делает что-то «случайно» или «иногда». Один этот факт превращает отладку из угадывания в рассуждение — дисциплинированный процесс, на который ты действительно можешь положиться.
После этого урока ты сможешь объяснить, почему детерминированность машины делает отладку вопросом рассуждения, а не угадывания, определить отладочную гипотезу как конкретное проверяемое утверждение о том, какой шаг отклонился от ожидаемого, и описать три основных приёма проверки гипотезы: сузить диапазон, прочитать трассировку и проверить состояние.
Машина детерминирована. Вспомни цикл выборка-декодирование-исполнение из Блока 03: CPU выбирает инструкцию, на которую указывает счётчик команд, декодирует её и исполняет. У этого цикла есть свойство, которое стоит сформулировать остро: он детерминирован. При одной и той же инструкции и одном и том же состоянии машины — тех же значениях регистров, тех же ячейках памяти — он всегда выдаёт ровно один и тот же результат. Каждый раз. В нём нет везения.
Это значит, что баг не случаен. Если твоя программа делает не то, она делает не то по причине — некоторая конкретная инструкция при некотором конкретном состоянии выдала результат, которого ты не ожидал. И поскольку машина детерминирована, эта причина фиксирована: она будет выдавать тот же неправильный результат каждый раз, когда ты запускаешь с теми же входами. Баг сидит неподвижно, ожидая, чтобы его нашли. Он не прячется; он просто там, куда ты ещё не посмотрел.
Поэтому отладка — это рассуждение, а не угадывание. Угадывание меняет программу и надеется, что симптом исчезнет. Оно обращается с багом как с загадкой. Но баг не загадочен — он определённое следствие кода и входа. Поэтому отладка должна быть противоположностью угадывания: процессом выяснения, рассуждением, какой именно шаг пошёл не так.
Модель для использования — та, что из урока Блока 07 о трассировке программы. Там ты следовал за программой шаг за шагом, зная счётчик команд и каждое значение переменной на каждой строке, и мог точно предсказать каждый следующий шаг. Отладка применяет тот же навык к сломанной программе. Ты знаешь, что программа должна делать на каждом шаге. Ты выясняешь, что она фактически сделала. Баг — на первом шаге, где эти два расходятся.
Гипотеза — это конкретное проверяемое утверждение. Рассуждению нужно что-то конкретное, о чём рассуждать. Это конкретное — гипотеза: конкретное проверяемое утверждение о том, какой шаг отклонился от того, что ты ожидал.
«Что-то не так с циклом» — это не гипотеза, она слишком расплывчата, чтобы её проверить. «Цикл выполняется на один раз больше, поэтому последняя итерация использует индекс за концом массива» — это гипотеза: она называет шаг, предсказывает конкретное наблюдение, и её можно подтвердить или опровергнуть, посмотрев. Хорошая гипотеза всегда делает предсказание достаточно острым, чтобы одна проверка либо поддержала её, либо убила. Если гипотеза не может быть неправильной, она не может тебе помочь.
Приём проверки один: сузить диапазон. Ты редко знаешь, какая строка неправильна, в начале. Но ты обычно можешь разделить программу на часть, которую ты проверил, и часть, которую нет. Выбери точку посередине. Спроси: в этой точке состояние всё ещё правильное?
Если да, баг после этой точки — первая половина очищена. Если нет, баг в этой точке или до неё — вторая половина очищена. В любом случае половина оставшегося кода исключается одной проверкой. Повтори, и подозреваемый диапазон быстро сжимается: программа из сотен шагов сужается до единственного виновного шага за горстку проверок. Это та же идея деления пополам, с которой ты встретишься снова как с двоичным поиском — здесь она применена к поиску бага.
Приём проверки два: прочитать трассировку. Когда баг — это возбуждённое исключение, тебе не нужно сужать вслепую — рантайм уже сделал работу. Трассировка стека из урока 02 — это снимок стека вызовов при выбросе. Её верхняя строка называет точную функцию и строку, где исключение проявилось; строки ниже называют цепочку вызовов, что туда привела.
Чтение трассировки мгновенно сворачивает поиск: вместо «где-то в программе» ты начинаешь с «эта функция, эта строка». Трассировка — это улика, которую машина вручила тебе бесплатно. Рассуждение всё равно продолжается оттуда — место выброса это там, где ошибка проявилась, не всегда там, где живёт причина — но трассировка даёт рассуждению точную отправную точку вместо пустого листа.
Приём проверки три: проверить состояние. Гипотеза предсказывает конкретный факт о состоянии программы — значение переменной, содержимое массива, какая ветвь была взята. Чтобы проверить её, ты идёшь и смотришь на этот факт в этот точный момент выполнения. Ты осматриваешь значение.
Здесь отладка становится экспериментом. Гипотеза говорит «на строке 20 i должно быть
3». Ты проверяешь: на строке 20, чему i фактически равно? Если 3, гипотеза опровергнута
— этот шаг был в порядке, ищи в другом месте. Если 4, гипотеза поддержана — ты нашёл шаг,
где реальность разошлась с ожиданием, и баг в нём или прямо перед ним. Каждая проверка
либо убивает гипотезу, либо обостряет её. Ты не меняешь программу; ты допрашиваешь её.
Только когда рассуждение определило точный виновный шаг, ты меняешь строку — и теперь ты
чинишь известный баг, а не угадываешь.
Отладка суммы, которая выходит слишком маленькой.
Функция должна сложить 4 числа в prices = [10, 20, 30, 40] и вернуть 100. Она
возвращает 60. Никакое исключение не возбуждается — программа работает и выдаёт
неправильный ответ.
1. Сформировать гипотезу. Результат 60 — это ровно 10 + 20 + 30 — первые три числа,
с пропущенным последним. Гипотеза: цикл останавливается на одну итерацию рано и никогда
не прибавляет prices[3]. Это конкретно и проверяемо: предсказывает, что цикл
выполняется 3 раза, а не 4.
2. Сузить диапазон. У функции есть подготовка, цикл и возврат. Подготовка
(total = 0) тривиально правильна. Возврат просто отдаёт total. Цикл — единственное
место, где меняется total — подозреваемый диапазон это цикл. Рассуждение исключило две
трети функции, не меняя ни строки.
3. Проверить состояние. Проверь гипотезу, осмотрев переменную цикла. Предсказание —
«цикл выполняется 4 раза, индекс принимает значения 0, 1, 2, 3». Ты проверяешь индекс на
каждой итерации и наблюдаешь: 0, 1, 2 — затем цикл выходит. Он выполнился 3 раза. Гипотеза
поддержана: индекс никогда не достиг 3, поэтому prices[3] никогда не был прибавлен.
4. Определить точный шаг и починить. Расхождение — это условие остановки цикла. Оно
говорит index < 3, когда должно говорить index < 4 (или index < prices.length).
Теперь — и только теперь — ты меняешь одну строку. Ты чинишь баг, который доказал, что
он там, а не угадываешь. Запусти снова: цикл выполняется 4 раза, результат — 100.
Ни одна строка не была изменена, пока рассуждение не пригвоздило точный виновный шаг. В этом весь метод.
Почему это работает
Почему угадывание так заманчиво — и так плохо? Угадывание заманчиво, потому что изменить строку быстро, и изредка симптом исчезает. Но «симптом исчез» — это не «баг починен». Угадывание может спрятать баг вместо того, чтобы убрать его — заставить видимый отказ уйти, пока настоящий дефект остаётся, готовый снова всплыть с другим входом. Рассуждение стоит больше вначале, но заканчивается тем, что ты понимаешь баг — а это единственное состояние, из которого ты можешь быть уверен, что он действительно ушёл.
Частая ошибка
Распространённая ошибка — обращаться с верхней строкой трассировки стека как с самим багом и «чинить» его там. Верх трассировки — это место, где ошибка проявилась, но причина может быть раньше. Функция может выбросить «не число», потому что вызывающая передала ей плохие данные, и настоящий дефект — в вызывающей. Трассировка даёт рассуждению отправную точку; она не заканчивает рассуждение. Следуй по цепочке, пока не найдёшь шаг, где состояние впервые пошло не так.
Машина детерминирована: одна и та же инструкция при одном и том же состоянии всегда даёт один и тот же результат. Запусти баговую программу дважды с одним и тем же входом. Сколько разных результатов может выдать баг?
Что из этого настоящая гипотеза? Введи 1 за «что-то не так с циклом» или 2 за «цикл останавливается на одну итерацию рано, поэтому prices[3] никогда не прибавляется».
У программы 16 шагов. Ты отлаживаешь сужением диапазона, деля подозреваемую область пополам каждый раз. После скольких проверок подозреваемый диапазон сужается до единственного шага? (16 -> 8 -> 4 -> 2 -> 1)
Возбуждено исключение. Какая строка трассировки стека даёт функцию и строку, где исключение проявилось — отправную точку для твоего рассуждения? Введи 1 за верхнюю строку, 2 за нижнюю строку.
Твоя гипотеза предсказывает, что на строке 20 переменная i должна быть 3. Ты проверяешь фактическое выполнение и находишь, что i равно 3 на строке 20. Гипотеза поддержана или опровергнута? Введи 1 за поддержана, 0 за опровергнута.
Почему отладка — это вопрос рассуждения, а не угадывания?
Отладка — это рассуждение, а не угадывание — и причина в том, что машина детерминирована: цикл выборка-декодирование-исполнение всегда выдаёт один и тот же результат для одного и того же состояния, поэтому баг — это фиксированное, повторяемое следствие кода и входа, а не случайное событие. Поскольку баг сидит неподвижно, ты можешь найти его рассуждением. Единица рассуждения — это гипотеза: конкретное проверяемое утверждение о том, какой шаг отклонился от того, что ты ожидал — достаточно острое, чтобы одна проверка подтвердила или опровергла его. Ты проверяешь гипотезу тремя приёмами: сузить диапазон (раздели программу, проверь середину, отбрось очищенную половину), прочитать трассировку (верхняя строка трассировки стека вручает тебе место выброса бесплатно) и проверить состояние (осмотри фактические значения переменных в момент, для которого гипотеза предсказывает факт о них). Только когда рассуждение пригвоздило точный виновный шаг, ты меняешь строку — чиня известный баг, с пониманием, а не угадывая.