Краткое содержание
Программу, которая даёт неверный результат, исправляют не случайной заменой строк, а расследованием. Этот урок представляет отладку как экспериментальный метод: воспроизвести сбой, сформулировать гипотезу о его причине, придумать эксперимент, который её подтвердит или отвергнет, и сузить место дефекта; бисекция находит его за число шагов, растущее как логарифм размера программы. Урок разбирает инструмент (компиляцию с -g и без оптимизации, настройку Visual Studio Code в трёх системах и настоящий сеанс GDB, показывающий, что редактор делает за кулисами), простые и условные точки останова, шаги, стек вызовов и просмотр состояния на центральном примере: медиане, которая ошибается только при чётном числе данных. В конце идут стратегии, которые ценнее инструмента (сократить случай, найти последнюю правильную точку, проверять инварианты с помощью assert, читать отчёт санитайзера), и три случая: ошибка на единицу, рекурсия, которая не завершается, и недействительная ссылка.
Цели обучения
- Настроить компиляцию с отладочной информацией (
-g, без оптимизации) и запускать отладчик из Visual Studio Code в своей системе. - Пользоваться простыми и условными точками останова, пошаговым выполнением (шаг с заходом, с обходом, с выходом) и просмотром переменных, выражений и стека вызовов.
- Применять систематическую стратегию (воспроизвести, сократить, разделить пополам, сформулировать и испытать гипотезы), чтобы найти заданный дефект, документируя каждую гипотезу.
- Сочетать отладчик с санитайзерами и тестами, чтобы найти ошибку работы с памятью по её отчёту.
Отлаживать значит расследовать
Дефект и сбой
Сбой есть то, что наблюдается: неверный результат, программа, которая не завершается, отчёт санитайзера. Дефект (или ошибка) есть причина: строка, условие или проектное решение, не соответствующие спецификации. Сбой виден; дефект нужно найти, и он может быть далеко от сбоя. Отсюда следует, что исправление там, где виден сбой, например вычитание 2 из результата, потому что «всегда выходит на 2 больше», исправляет симптом и оставляет причину, которая вернётся с другими данными.
Если это так, отладка есть расследование причины, и её метод есть метод экспериментальных наук:
- Наблюдать и воспроизводить. Точно описать сбой (при каком вводе, что ожидалось, что получено) и добиться, чтобы он происходил всякий раз, когда нужно. Дефект, который не воспроизводится, нельзя исследовать, и нельзя убедиться, что он исправлен.
- Сформулировать гипотезу. Утверждение о причине, которое может оказаться ложным: «сортировка не сортирует», «индекс второго центрального элемента на единицу больше нужного».
- Придумать эксперимент. Выбрать точку программы и предсказать, какое состояние должно быть видно там, если гипотеза верна, и какое, если она ложна.
- Сделать вывод и сузить. Отвергнуть гипотезу или подтвердить её; в обоих случаях подозрительный участок уменьшается. Это повторяют, пока участок не станет одной строкой.
Однако метод требует дисциплины: менять только одно за раз, записывать каждую гипотезу с её результатом и не трогать код, пока не ясно, что ищется. Случайная замена строк, пока вывод не покажется правильным, может скрыть сбой, не устранив дефект.
Медиана списка данных есть центральный элемент упорядоченного списка, если их количество нечётно, и среднее двух центральных, если оно чётно. Урок Тестирование программ показал, что тесты обнаруживают, когда функция ошибается, но не где находится её дефект. Следующая функция проходит тест с пятью данными и не проходит с четырьмя.
#include <algorithm>#include <cstddef>#include <print>#include <vector>// Медиана списка данных.// Предусловие: values не пуст.// Постусловие: возвращает центральный элемент упорядоченного списка или среднее двух центральных.double median(std::vector<double> values){ std::ranges::sort(values); const std::size_t n{values.size()}; const std::size_t middle{n / 2}; if (n % 2 == 1) { return values[middle]; } return (values[middle] + values[middle + 1]) / 2.0;}int main(){ const std::vector<double> odd{7.0, 1.0, 5.0, 3.0, 9.0}; const std::vector<double> even{7.0, 1.0, 5.0, 3.0}; std::println("Медиана {}: {}", odd, median(odd)); std::println("Медиана {}: {}", even, median(even));}Медиана [7, 1, 5, 3, 9]: 5Медиана [7, 1, 5, 3]: 6Наблюдение точное: для , после сортировки , ожидалось , а получено 6; с пятью данными результат правильный. Возможны по меньшей мере три гипотезы: (H1) сортировка не сортирует; (H2) индексы двух центральных элементов неверны; (H3) сумма или деление записаны неверно. Каждая предсказывает своё на строке 17, до её выполнения: если верна H1, values не упорядочен; если верна H2, values[middle] и values[middle + 1] не равны 3 и 5; если верна H3, оба элемента равны 3 и 5, и всё же вычисление даёт 6. То, что нечётный случай работает, делает H1 маловероятной, но «маловероятная» не значит «отвергнутая»; эксперимент с отладчиком состоит в том, чтобы остановить программу на строке 17 и посмотреть.
Бисекция
Когда ясной гипотезы нет, остаётся метод, который не подводит: бисекция. Предполагается, что состояние программы правильно в начале и неверно в конце и что в каждой промежуточной точке можно решить, правильно ли оно (сравнив его со значением, вычисленным вручную, или проверив инвариант). Состояние исследуют в середине подозрительного участка: если оно правильно, дефект во второй половине; если нет, в первой. Это двоичный поиск из урока Поиск и сортировка, применённый к строкам программы (или к итерациям цикла, или к версиям файла).
Рисунок прослеживает бисекцию шестнадцати строк с дефектом в строке 11; каждая черта есть эксперимент над состоянием после этой строки: правильное (✓) или неверное (✗).
Доказательство
Вначале подозрительны все шестнадцать строк: известно, что состояние правильно до строки 1 и неверно после строки 16.
После строки 8 состояние правильно: строки с первой по восьмую оправданы.
После строки 12 состояние уже неверно: дефект находится между строками 9 и 12.
После строки 10 правильно: остаются строки 11 и 12.
После строки 11 неверно: последняя правильная точка и первая неверная соседствуют, и дефект находится в строке 11.
Подсчёт тот же, что у двоичного поиска: каждый эксперимент делит участок пополам, так что экспериментов хватает для строк. Для программы в тысячу строк хватает десяти, потому что . Отсюда преимущество метода: его стоимость растёт как логарифм размера, и он требует не понимать всю программу, а уметь решить в данной точке, такое ли состояние, каким оно должно быть. Однако именно это решение и есть трудная часть: спецификации и инварианты служат критерием, по которому судят о каждом промежуточном состоянии.
Загрузка содержимого…