Как отлаживать маленькие программы

4.05.2018

Пусть у вас есть небольшая программа, которая… не работает. Причем не просто как-то не работает, а у вас есть конкретный тест, конкретный пример, на котором она не работает. (Если у вас такого примера нет, то у меня есть отдельный текст про то, что делать в таком случае.) Как понять, что в программе не так, и как это исправить?

На самом деле, на эту тему есть знаменитый текст «How to debug small programs» и его русский перевод, но на мой взгляд рекомендации, приведенные там, — это излишнее усложнение, не нужное на самом деле в 90% действительно простых программ.

Итак, у вас есть тест, но вы не понимаете, почему программа на нем выдает не тот результат, который нужен. Ну, во-первых, по возможности уменьшите тест. Если в вашей задаче вводится какой-то массив или т.п., то не надо разбираться с программой на массиве длины 10. Попробуйте найти тест длины 3-4, на котором программа тоже не будет работать. Если вводится одно число, но дальше будет цикл до этого числа, то подберите число поменьше. И т.п.

Дальше есть несколько подходов.

Представьте себе, что вы компьютер

Основной, самый главный подход, когда программа действительно небольшая, строк 10-20 максимум, состоит в том, чтобы представить себя на месте компьютера и в уме выполнить программу. Возьмите листочек бумажки (или откройте «Блокнот»), выпишите на нем список переменных, которые есть в вашей программе, оставив у каждой переменной место, куда вы будете записывать их значения. Это будет оперативная память вашего компьютера. (В дальнейшем, когда вы освоитесь, бумажка вам не будет нужна, вы будете держать все нужные значения в уме, и все это выполнение будет происходить весьма быстро.)

Далее выполняйте программу пошагово, с самого начала (ну можете пропустить ввод данных, если вы в нем на 200% уверены). При каждом изменении значения каждой переменной выписывая измененное значение на бумажке. Самое важное тут — это подробно и тщательно делать именно то, что написано в программе. Забудьте (точнее лучше задвиньте на задний план) вашу задачу, забудьте, зачем вы писали этот код. Вы работаете за компьютер, компьютер ничего не знает про то, какая у вас задача, он просто тупо выполняет написанный код. Полезно тщательно проговаривать каждую выполняемую операцию. Не забывайте, что операции — это не только присваивания, это еще и все управляющие конструкции (if’ы, циклы и т.д.); не забывайте, что в циклах на каждой итерации выполняются действия, относящиеся собственно к циклу (проверка условия в while, увеличение индекса цикла в for). Все изменения переменных отражайте на бумажке, каждый раз, когда вам нужно значение какой-то переменной, сверяйтесь с бумажкой.

При этом надо где-то глубоко в уме все-таки помнить, какую задачу вы решаете, и какой код зачем написан, чтобы, как только реальное выполнение кода отойдет от того, что вы имели в виду, сразу это и заметить.

Пример. Задача «Переставить элементы в обратном порядке». Типичный код, который тут многие пишут, примерно такой:

# a - массив, который вы считали
for i in range(len(a)):
    t = a[i]
    a[i] = a[len(a) - i]
    a[len(a) - i] = t

Вы запускаете программу на тесте 1 2 3, и она падает. Хорошо, давайте представим, что мы выполняем эту программу за компьютер. У нас есть массив a, в котором записано 1 2 3 (и это записано у нас на бумажке), и переменные i и t. Поехали.

Начинается цикл for, переменная i становится равна 0 (на бумажке рядом с именем переменной i пишем 0).

Команда t = a[i]. Чему у нас равно i? Смотрим на бумажку, i равно 0. Чему равно a[i]? Смотрим на бумажку, a[i] это a[0] это 1. Это значение записывается в t. Записываем на бумажке рядом с t единицу.

Команда a[i] = a[len(a)-i]. Чему у нас равно i? Нулю. Чему равно len(a)? Трем. Чему равно len(a)-i? Трем. Чему равно a[3]? Ой, выход за пределы массива (не забываем, что элементы в массиве нумеруются с нуля; полезно на бумажке под значениями элементов массива подписать их индексы).

Вот собственно мы нашли первую ошибку. Обратите внимание, что мы специально тщательно и подробно все проговаривали; если бы вы действовали поверхностно, то вы могли бы сразу сказать: «a[len(a)-i] — это последний элемент массива (ведь я именно для этого писал этот код), поэтому это просто 3». И вы не заметили бы ошибку. Именно поэтому и надо по максимуму забыть, что обозначает этот код, а вместо этого просто четко и подробно выполнять то, что написано, постоянно сверяясь с бумажкой.

Хорошо, давайте исправим ошибку, теперь код такой:

# a - массив, который вы считали
for i in range(len(a)):
    t = a[i]
    a[i] = a[len(a) - i - 1]
    a[len(a) - i - 1] = t

Запускаем программу — и она выдает 1 2 3, т.е. как будто массив не изменился. Это все равно неправильно, поэтому поехали еще раз.

Начинается цикл for, переменная i становится равна 0 (на бумажке рядом с именем переменной i пишем 0).

Команда t = a[i]. Чему у нас равно i? Смотрим на бумажку, i равно 0. Чему равно a[i]? Смотрим на бумажку, a[i] это a[0] это 1. Это значение записывается в t. Записываем на бумажке рядом с t единицу.

Команда a[i] = a[len(a)-i-1]. Чему у нас равно i? Нулю. Чему равно len(a)? Трем. Чему равно len(a)-i-1? Двум. Чему равно a[2]? Трем. Это значение записывается в a[i]. Что такое a[i]? У нас i равно 0, поэтому это нулевой элемент массива. Зачеркиваем единичку, которая записана на бумажке на нулевом месте в массиве, записываем туда 3.

Команда a[len(a) - i - 1] = t. Чему у нас равно t? Единице. Что такое a[len(a) - i - 1]? Выражение в квадратных скобках мы только что считали (но надо как минимум внимательно проверить, что выражение тут написано то же, а лучше пересчитать), поэтому это a[2]. Значит, в a[2] записываем 1. Зачеркиваем число 3, которое раньше было написано в a[2], записываем туда 1.

Продолжаем. Итерация цикла закончилась, начинается новая итерация цикла. i становится равно 1.

Продолжаем все делать так же тщательно и подробно. Я не буду дальше все это расписывать, но (особенно если вы еще не видите ошибки в коде выше) можете продолжить и все-таки найти ошибку.

Добавьте отладочный вывод

Второй полезный подход — добавить в программу вывод на экран промежуточных значений переменных в ключевых местах программы. Тут надо суметь понять, что такое «ключевые места» и какие переменные выводить. Обычно, например, если у вас в программе есть какие-то циклы, то полезно добавить вывод как минимум в конце каждой итерации, и выводить те переменные, которые меняются в цикле, в том числе для циклов for — переменную цикла. Если у вас сложная конструкция из if’ов, то добавить вывод внутрь каждого if’а, чтобы видеть, в какой именно if зашла программа. Если у вас несколько функций или тем более рекурсия, то добавить отладочный вывод типа «вошли в такую-то функцию» (зачастую с указанием параметров функции) и «вышли из такой-то функции». Если у вас просто сложные вычисления, длинная формула или несколько формул — то добавить вывод промежуточных результатов вычислений; возможно, для этого придется длинную формулу разбить на части (и это заодно сделает ее понятнее).

После этого запускаете программу на том тесте, на котором она не работает, смотрите на отладочный вывод, проверяете все значения и находите первое значение, которое неверно. Таким образом вы довольно хорошо локализуете проблему, вы будете знать, что ошибка, например, в вычислении той или иной переменной. Дальше уже действуйте по ситуации: если программа не очень сложная, то эта переменная меняется в одном-двух местах, и вы сразу можете посмотреть, как она считается и почему значение получается неправильным. Тем более что вы, наверное, вывели на экран не только значение этой переменной, но и значения других переменных, от которых она зависит, поэтому сразу можете подставить эти значения в формулу и проверить.

Если же программа более сложная, эта переменная меняется в нескольких местах, или вы вывели на экран недостаточно информации, чтобы легко расследовать проблему, то добавьте еще отладочного вывода, чтобы более подробно видеть, откуда взялось это значение. И повторите.

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

Рассмотрим в качестве примера тот же код, который мы разбирали выше. Добавим в него отладочный вывод примерно такой:

# a - массив, который вы считали
for i in range(len(a)):
    t = a[i]
    a[i] = a[len(a) - i - 1]
    a[len(a) - i - 1] = t
    print("i=", i, "a=", a)

Запустите и посмотрите на вывод. И вы сразу увидете, что у вас происходит с массивом и почему он так меняется.

В примере выше обратите внимание на то, что я подписываю все переменные при выводе. Как только у вас больше пары переменных, это сразу становится удобнее. Также бывает удобно добавить какие-нибудь еще знаки или слова, чтобы различать вывод из разных мест, и т.д.

Выполните программу пошагово в среде разработки

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

Поговорите с уточкой

Еще одна стандартная рекомендация — это взять резиновую уточку (ну на самом деле любую игрушку, или даже можно и без игрушки), и подробно по порядку объяснить ей, что делает ваша программа, что делает в ней каждая строчка и почему всё написано так, а не иначе.

Вот прямо так и говорите: мне надо переставить элементы массива в обратном порядке. Для этого мне надо взять первый элемент массива и поменять местами с последним, потом второй с предпоследним и так далее. Как я это пишу в программе? Мне надо менять много пар элементов, поэтому я делаю цикл for. В цикле for переменная i обозначает номер элемента, который я буду менять местами с симметричным. Она должна меняться от 0 до… и вот тут вы понимаете, где ошибка в программе.

Это весьма полезный прием, если вы сумеете его освоить. Ключевой момент, что тут надо делать — надо подробно и про каждый элемент кода объяснить, зачем вы это делаете и почему код написан именно так. В частности, в примере выше фраза «она должна меняться от 0 до…» возникла потому, что вы видите, что в коде написано range(len(a)), значит, надо объяснить уточке, почему написано именно так.

Важно все тщательно и подробно проговаривать; точно так же, как и при выполнении программы в уме вы должны последовательно выполнять все действия, а не «срезать углы» словами «а, это будет последний элемент массива», так и здесь не надо «срезать углы», пропуская объяснения тех или иных моментов и полагая, что они понятны и очевидны.

Помимо собственно поиска ошибок, метод уточки еще помогает вам обнаружить и осознать места в программе, которые вы не до конца понимаете, и самим в них подробнее разобраться (это близко и связано с поиском ошибок, но бывает полезно и в других ситуациях). Но про это и вообще про осознание кода я, пожалуй, напишу когда-нибудь еще один пост.


Мой курс по алгоритмическому программированию (и подготовке к олимпиадам) для школьников, студентов и всех желающих — algoprog.ru