JavaRush /Курсы /C++ SELF /Стратегия отладки

Стратегия отладки

C++ SELF
34 уровень , 4 лекция
Открыта

1. Стратегия отладки важнее магии дебаггера

Когда программа ведёт себя неправильно, в голове легко включается режим «паника и шаманство»: поменять пару строк, перезапустить, вдруг помогло. Иногда помогает. Иногда вы случайно делаете хуже, но баг «прячется» и возвращается на зачёте или на проде (то есть в момент, когда вам уже психологически нельзя).

Стратегия отладки нужна не потому, что «так делают взрослые», а потому что она экономит время. Дебаггер — это лупа и фонарик, но если вы не знаете, что искать, вы будете светить фонариком в потолок и удивляться, почему таракан не обнаружен. Мы будем отлаживать как инженеры: зафиксировать проблему, сузить область поиска, проверить конкретную гипотезу.

Наша рабочая формула сегодня:

flowchart TD
    A[Воспроизвести баг] --> B[Изолировать место/условия]
    B --> C[Сформулировать гипотезу]
    C --> D[Проверить гипотезу в дебаггере]
    D --> E{Подтвердилась?}
    E -- да --> F[Исправить минимальным изменением]
    E -- нет --> C

2. Шаг 1: воспроизвести баг

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

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

Представим, что мы развиваем учебное консольное приложение ExpenseTracker (трекер расходов). Оно хранит расходы в std::vector, умеет добавлять траты и считать среднее.

Мини-версия «ядра» (пока без сложного интерфейса):

#include <iostream>
#include <vector>

int main() {
    std::vector<int> expenses{100, 50, 150};

    int sum = 0;
    for (int x : expenses) sum += x;

    std::cout << "Sum=" << sum << '\n'; // Sum=300
}

Теперь представим, что студент добавил «среднее» и получил странность: Average=100, хотя ожидается 100.0, и вроде всё совпадает — но на другом наборе данных будет больнее (например, 100 и 101 дадут 100 вместо 100.5). И вот здесь важно: вы должны иметь маленький сценарий, который гарантированно показывает ошибку.

Хороший воспроизводящий пример именно для целочисленного деления:

#include <iostream>
#include <vector>

int main() {
    std::vector<int> expenses{100, 101};

    int sum = 0;
    for (int x : expenses) sum += x;

    double avg = sum / expenses.size();
    std::cout << "Average=" << avg << '\n'; // Average=100 (а хотелось 100.5)
}

Обратите внимание: мы не «примерно заметили», что что-то не так. Мы создали вход, где ошибка очевидна. Это половина победы.

В отладке воспроизведение обычно упирается в дисциплину: вы сохраняете точный ввод, точный набор шагов, фиксируете «ожидалось/получилось». Если программа интерактивная, полезно временно заменить ввод на заранее заданные данные прямо в коде, чтобы не вводить руками десять раз подряд одно и то же.

3. Шаг 2: изолировать причину

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

Изолировать означает уменьшить область поиска: найти, в какой части программы баг рождается. Часто симптом проявляется в одном месте (например, неправильный вывод), а причина — в другом (например, неправильный расчёт или неверный ввод).

Есть два очень практичных способа изоляции.

Первый способ — превратить большую программу в маленькую: временно оставить только кусок, который нужен для воспроизведения. Если баг «Average=100 вместо 100.5», нам не нужен весь CLI, меню, обработка команд и красивые таблицы. Нам нужен расчёт среднего.

Вынесем расчёт в функцию:

#include <vector>

double average_expense(const std::vector<int>& v) {
    int sum = 0;
    for (int x : v) sum += x;
    return sum / v.size(); // подозрительное место
}

И в main оставим только минимальный вызов:

#include <iostream>
#include <vector>

double average_expense(const std::vector<int>& v);

int main() {
    std::vector<int> expenses{100, 101};
    std::cout << average_expense(expenses) << '\n'; // 100 (ожидали 100.5)
}

В этот момент у вас появляется маленькая «лабораторная» сцена для дебаггера: вход маленький, код короткий, брейкпоинтов будет немного, и вы не утонете в деталях.

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

4. Шаг 3: проверить гипотезу

Теперь самая взрослая часть процесса: гипотеза. Гипотеза в отладке — это утверждение, которое можно проверить наблюдением.

Плохая гипотеза звучит так: «дебаггер тупит», «vector сломался», «C++ странный». Хорошая гипотеза звучит так: «у нас происходит целочисленное деление, потому что sum и v.size() — целые типы».

Чтобы гипотеза была полезной, она должна отвечать на два вопроса: «что именно неверно?» и «где я это увижу?».

Небольшая табличка, которая помогает быстро превращать симптомы в проверяемые гипотезы:

Симптом Частая гипотеза Как проверить в дебаггере
Неправильное среднее/проценты Целочисленное деление Watch: sum, v.size(), выражение sum / v.size()
Пропали элементы/не все обработались Ошибка границ цикла (< vs <=) Watch: индекс и size(), брейкпоинт внутри цикла
Иногда падение Выход за границы vector Breakpoint на доступе, watch i и size()
Ветка if «не работает» Условие не такое, как кажется Watch выражения условия, step по веткам
«Зависло» Ждёт ввод Посмотреть текущую строку: часто это std::cin >> ...

Ключевой момент: мы не пытаемся «угадать исправление». Мы пытаемся подтвердить причину. Исправление — это уже следующий шаг, и обычно он становится очевидным, когда причина подтверждена.

5. Мини-кейс: среднее в ExpenseTracker

Представим, что у нас в приложении появилась команда avg, которая печатает среднюю трату. Мы написали код и получили странные результаты.

Вот упрощённая версия, как мог выглядеть наш расчёт:

#include <vector>

double average_expense(const std::vector<int>& v) {
    int sum = 0;
    for (int x : v) sum += x;
    return sum / v.size(); // тут рождается баг
}

Воспроизведение

Мы уже нашли минимальный ввод: {100, 101}. Он должен давать 100.5, а даёт 100. Это стабильная сцена.

#include <iostream>
#include <vector>

double average_expense(const std::vector<int>& v);

int main() {
    std::vector<int> expenses{100, 101};
    std::cout << average_expense(expenses) << '\n'; // 100
}

Изоляция

Изоляция уже сделана: у нас одна функция и один вызов. Никаких меню, строк, парсинга, «красивого форматирования».

Гипотеза

«Гипотеза: sum / v.size() выполняется как целочисленное деление, потому что оба операнда целые, и дробная часть отбрасывается ещё до присваивания в double».

Проверка гипотезы в дебаггере

Что делаем (в терминах действий, не горячих клавиш):

Вы ставите breakpoint на строку return sum / v.size()();, запускаете под дебаггером, доходите до этой строки и смотрите значения.

В watches/locals вас интересуют три вещи: sum, v.size(), и результат выражения sum / v.size() прямо «как есть».

На остановке вы увидите примерно такое:

  • sum == 201
  • v.size() == 2
  • sum / v.size() == 100 (а не 100.5)

Это и есть подтверждение гипотезы: деление уже стало целочисленным.

Исправление минимальным изменением

Мы не переписываем функцию «на всякий случай». Мы делаем минимальный фикс, который убирает именно подтверждённую причину: заставляем деление стать вещественным.

#include <vector>

double average_expense(const std::vector<int>& v) {
    int sum = 0;
    for (int x : v) sum += x;

    return static_cast<double>(sum) / v.size(); // теперь деление double
}

И теперь тестовый запуск:

#include <iostream>
#include <vector>

double average_expense(const std::vector<int>& v);

int main() {
    std::vector<int> expenses{100, 101};
    std::cout << average_expense(expenses) << '\n'; // 100.5
}

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

6. Граничные случаи: пустой список и уменьшение входа

Иногда ошибка не в математике, а в граничных случаях. Например, avg вызвали, когда расходов ещё нет. И программа падает. Это классика: вы о ней не думали, пользователь о ней не думал, но реальность — думала.

Вот типичный «наивный» код:

#include <vector>

double average_expense(const std::vector<int>& v) {
    int sum = 0;
    for (int x : v) sum += x;
    return static_cast<double>(sum) / v.size(); // если size()==0 → деление на ноль
}

Воспроизведение

Нам нужен нулевой ввод. Прямо в main:

#include <iostream>
#include <vector>

double average_expense(const std::vector<int>& v);

int main() {
    std::vector<int> expenses;
    std::cout << average_expense(expenses) << '\n'; // аварийное завершение/мусор
}

Изоляция

Она уже изолирована: снова одна функция.

Гипотеза

«Гипотеза: v.size() равно 0, и мы делим на ноль».

Проверка

Breakpoint на строке деления, watch v.size(). На остановке видим 0. Гипотеза подтверждена.

Исправление

Решение на текущем этапе курса обычно простое: явно обработать пустой случай (в реальном проекте можно спорить, что лучше возвращать, но сейчас нам важна предсказуемость).

#include <vector>

double average_expense(const std::vector<int>& v) {
    if (v.empty()) return 0.0;

    int sum = 0;
    for (int x : v) sum += x;
    return static_cast<double>(sum) / v.size();
}

И снова воспроизводим сценарий: пустой список → теперь печатается 0.0 (или просто 0, если вывод форматируется без дробной части).

Изоляция через уменьшение входа

Часто вы не можете так легко вырезать половину программы: например, баг проявляется только при определённой последовательности команд или на определённых данных. Тогда изоляция делается не столько «удалением кода», сколько уменьшением входа.

Практическая логика такая: если баг проявляется на 10 000 элементов, попробуйте найти минимальный размер, на котором он ещё проявляется. Это очень похоже на настройку громкости: вы убавляете шум до тех пор, пока проблема ещё слышна.

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

#include <vector>

int max_expense(const std::vector<int>& v) {
    int m = v[0];
    for (std::size_t i = 1; i < v.size(); ++i) {
        if (v[i] > m) m = v[i];
    }
    return m;
}

Если вам сказали «иногда неверно», вы сначала делаете воспроизведение: находите набор, где неверно. Потом изолируете: начинаете сокращать набор, пока неверность остаётся. Часто оказывается, что проблема проявляется на конкретном угловом случае: например, при отрицательных значениях, при повторяющихся максимумах, при пустом vector. И тогда гипотеза становится конкретной: «мы обращаемся к v[0], когда v пуст».

Отладка в дебаггере тут будет почти формальной: breakpoint на int m = v[0];, watch v.size() — и вы уже понимаете, почему всё взорвалось.

7. Типичные ошибки

Ошибка №1: пытаться чинить баг, который вы не умеете воспроизводить.
Если вы не можете стабильно получить неправильное поведение, то каждое «исправление» превращается в гадание на кофейной гуще. Иногда кажется, что вы починили, но на самом деле баг просто не проявился. Правильнее сначала придумать минимальный сценарий, который ломает программу каждый раз.

Ошибка №2: «изолировать» через переписывание половины проекта.
Когда проблема непонятна, рука тянется к радикальным изменениям: «давайте перепишем на другой цикл» или «сделаем по-другому структуру данных». Это уничтожает следы причины и создаёт новые баги. Изоляция — это уменьшение: входа, пути выполнения, количества функций, которые участвуют в воспроизведении.

Ошибка №3: гипотеза в стиле «всё сломалось».
Фразы вроде «vector ведёт себя странно» или «условие не работает» не являются проверяемыми. Хорошая гипотеза должна быть наблюдаемой: «индекс стал равен size()», «в sum / n оба операнда целые», «в эту ветку if мы вообще не попадаем». Тогда вы можете поставить breakpoint и посмотреть ровно то, что нужно.

Ошибка №4: проверять гипотезу не там (не тот frame / не та точка остановки).
Если вы остановились внутри маленькой функции, но причина в том, что ей передали неправильные аргументы, смотреть нужно не только локальные переменные. Нужно подняться по call stack, переключить stack frame и увидеть, где именно значение стало неправильным. Иначе легко «обвинить» функцию, которая просто честно работала с тем, что ей дали.

Ошибка №5: смотреть v[i] без контроля границ прямо в watch.
Watches — мощная штука, но они не обязаны вас спасать от логики. Если i уже вышел за границы, попытка посмотреть v[i] может дать мусор или привести к странным побочным эффектам наблюдения. Хорошая привычка — сначала следить за i и v.size(), и только когда уверены, что i < v.size(), смотреть элемент.

1
Задача
C++ SELF, 34 уровень, 4 лекция
Недоступна
Два деления
Два деления
1
Задача
C++ SELF, 34 уровень, 4 лекция
Недоступна
Отчёт делимости
Отчёт делимости
1
Задача
C++ SELF, 34 уровень, 4 лекция
Недоступна
Среднее под лупой
Среднее под лупой
1
Задача
C++ SELF, 34 уровень, 4 лекция
Недоступна
Следы максимума
Следы максимума
1
Опрос
Отладчик, 34 уровень, 4 лекция
Недоступен
Отладчик
Отладчик
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ