JavaRush /Курсы /C++ SELF /Чек‑лист расследования багов

Чек‑лист расследования багов

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

1. Зачем нужен чек‑лист расследования

Когда программа падает, мозг очень быстро начинает играть в две игры: «сейчас я угадаю причину» и «сейчас я это быстро “починю”». Обе игры приятные, потому что дают ощущение контроля, но в реальности часто приводят к тому, что вы лечите симптом, а не первопричину. Чек‑лист нужен, чтобы вы действовали как инженер: повторяемо, с фиксацией фактов и с минимальным количеством магии.

Самый полезный сдвиг мышления в расследовании — перестать воспринимать баг как «событие» и начать воспринимать его как процесс. Процесс состоит из маленьких шагов, и каждый шаг должен оставлять после себя что‑то проверяемое: воспроизводимость, минимальный сценарий, конкретную точку в коде, конкретный фикс и короткую проверку, что это не вернётся.

Ниже — общий маршрут, который мы будем разбирать и «приземлять» на код:

flowchart TD
    A[Воспроизвести] --> B[Зафиксировать сигнал: ASan/assert/краш]
    B --> C[Минимизировать сценарий]
    C --> D[Локализовать первопричину]
    D --> E[Исправить]
    E --> F[Перепроверить под диагностикой]
    F --> G[Регресс‑проверка: сценарий не должен вернуться]

Чтобы это было совсем «по‑рабочему», держите в голове маленькую таблицу‑шпаргалку:

Шаг Что вы хотите получить Что сохраняете (артефакт) Как понять, что шаг завершён
Воспроизвести Баг повторяется одинаково вход/команда/условия падает/срабатывает в 3–5 запусках подряд
Минимизировать Самый короткий сценарий, который всё ещё ломается короткий вход/кусок кода убрали лишнее, но поломка осталась
Локализовать место, где «впервые стало неправильно» конкретная строка/функция/контракт можете сформулировать первопричину словами
Исправить устранение первопричины diff / изменение кода диагностика молчит, поведение корректно
Регресс‑проверка баг не вернётся тихо assert/самопроверка если вернуть старый баг — проверка падает

2. Воспроизведение: превращаем «иногда падает» в «падает всегда»

Слово «воспроизвести» звучит как будто мы ставим музыку, но на деле это самая недооценённая часть расследования. Пока у вас нет стабильного воспроизведения, вы не можете отличить реальную причину от совпадения. Вы меняете код, баг «пропадает», и мозг радостно делает вывод «починил». А через два дня баг возвращается, потому что вы всего лишь перестали попадать в нужный тайминг или расклад памяти.

Фиксируем, «как именно запустить»

Начните с простого: одна команда / один вход / один сценарий. В учебных CLI‑программах это обычно значит: либо фиксированный ввод в консоль, либо чтение из строки, либо специальная ветка --demo.

Продолжим наше учебное приложение — консольный мини‑менеджер задач TaskBook, где задачи лежат в std::vector. Представим, что мы однажды добавили «удобную оптимизацию»: хранить указатель на выбранную задачу, чтобы быстро печатать её детали. А потом внезапно получили загадочное падение при добавлении новых задач.

Вот минимальный демонстрационный фрагмент плохой идеи, который мы хотим уметь стабильно воспроизводить:

#include <iostream>
#include <string>
#include <vector>

struct Task { int id; std::string title; };

int main() {
    std::vector<Task> tasks{{1, "Write report"}};
    Task* selected = &tasks[0];          // запомнили адрес элемента vector

    tasks.push_back({2, "Drink tea"});   // может перевыделить память и “переехать”
    std::cout << selected->title << '\n';// UB: selected мог стать висячим
}

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

Делаем воспроизведение детерминированным

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

Например, можно специально создать условия, при которых vector почти наверняка перевыделит память: ограничить capacity.

#include <iostream>
#include <string>
#include <vector>

struct Task { int id; std::string title; };

int main() {
    std::vector<Task> tasks;
    tasks.reserve(1);                    // хотим capacity = 1 (или близко к этому)
    tasks.push_back({1, "A"});
    Task* selected = &tasks[0];

    tasks.push_back({2, "B"});           // с высокой вероятностью будет reallocation
    std::cout << selected->title << '\n';// UB (может “повезти”, но это уже сигнал)
}

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

Что записывать в «паспорт воспроизведения»

Супер‑практика: буквально в комментарии (или в отдельной заметке) зафиксировать три вещи: какой вход, какая команда, какой ожидаемый сигнал (ASan/краш/assert).

Внутри кода это может выглядеть так:

// Repro: run -> crashes/ASan complains after push_back
// Scenario: store pointer to tasks[0], then push_back one more task, then read old pointer.

Это не бюрократия. Это страховка от ситуации «у меня вчера падало, а сегодня нет, и я не помню как».

3. Минимизация: выкидываем всё, кроме причины

Минимизация — это когда вы берёте большой, страшный проект, где «падает где‑то при закрытии», и превращаете его в 15 строк, где «падает при втором push_back». Этот шаг экономит часы жизни, потому что уменьшает площадь поиска и количество возможных причин. И да: минимизация почти всегда важнее, чем первая попытка «сразу починить».

Минимизация входа и минимизация кода — разные вещи

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

Для TaskBook удобнее минимизировать код: нам не нужен весь CLI, меню, парсер команд. Нам нужно только: vector, указатель, push_back, чтение через старый указатель.

Техника «срезай по одному»

Если вы выкидываете сразу половину программы, вы можете случайно убрать и сам баг, и потом не понять, что именно было важно.

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

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

#include <iostream>
#include <string>

struct Task { int id; std::string title; };

void print_selected(const Task* t) {
    std::cout << t->title << '\n'; // если t dangling — здесь и хлопнет/пожалуется ASan
}

Теперь в минимальном сценарии у вас остаются только важные роли: хранение указателя и момент его использования.

Минимизация как способ поймать ложную причину

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

Психологически это полезно: когда код маленький, мозг перестаёт фантазировать и начинает видеть причинно‑следственную связь.

4. Фиксация: делаем расследование повторяемым

Фиксация — это когда вы говорите: «вот конкретно этот маленький сценарий — наш эталонный баг». Он нужен не только чтобы «сейчас починить», но и чтобы через неделю не спорить с самим собой: «а что именно было сломано?»

Здесь важно различать две фиксации: фиксация сценария и фиксация ожидаемого поведения.

Фиксируем сценарий в коде: demo_*

В учебном проекте удобно держать такие вещи как отдельные функции, которые можно включать/выключать. Например:

#include <iostream>
#include <string>
#include <vector>

struct Task { int id; std::string title; };

void demo_dangling_pointer() {
    std::vector<Task> tasks{{1, "A"}};
    Task* selected = &tasks[0];

    tasks.push_back({2, "B"});
    std::cout << selected->title << '\n'; // UB (демо-баг)
}

int main() {
    demo_dangling_pointer();
}

Сейчас это «демо‑баг». После исправления эта же функция может стать «демо‑проверкой»: она должна не ломаться и вести себя корректно (или падать по вашему assert, если вы решили, что сценарий недопустим по контракту).

Фиксируем ожидаемое поведение

Здесь начинается взрослая инженерная часть: вы должны ответить, что в этой ситуации должно делать приложение.

Варианты (на уровне принципа, без превращения в огромный дизайн‑док):

  • Если ваш контракт говорит «мы не храним сырой указатель на элемент vector», то правильное поведение — не иметь такой возможности в коде. Тогда фиксация будет: «после рефакторинга указатель исчез, а выбранная задача хранится иначе».
  • Если ваш контракт говорит «мы храним selectedIndex и каждый раз берём элемент заново», то правильное поведение — печатать выбранную задачу через индекс, и перед печатью проверять границы.
  • Если ваш контракт говорит «выбранная задача должна жить независимо от контейнера», то правильное поведение — хранить копию (или владение), а не ссылку на внутренность.

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

5. Регресс‑мышление: не даём багу вернуться

Регресс‑мышление — это привычка думать не «как починить сейчас», а «как сделать так, чтобы это не сломали снова». Это не про паранойю, а про экономию времени. Если баг был однажды найден, он уже доказал, что реалистичен. Значит, вероятность его возвращения ненулевая — особенно если команда растёт или вы сами через месяц забыли детали.

Мини‑регресс‑проверка без тест‑фреймворков: self_check() на assert

По плану дня мы не подключаем тест‑фреймворки, поэтому используем самый простой «гвоздь в стену» — маленькую самопроверку на assert.

Например, если вы решили хранить выбранную задачу как индекс, то регресс‑проверка может выглядеть так:

#include <cassert>
#include <string>
#include <vector>

struct Task { int id; std::string title; };

void self_check_selected_index() {
    std::vector<Task> tasks{{1, "A"}};
    std::size_t selected = 0;

    tasks.push_back({2, "B"});               // reallocation нам уже не страшен
    assert(tasks.at(selected).title == "A"); // проверяем ожидаемое поведение
}

Обратите внимание на деталь: at() здесь полезен как «контрольный выстрел» по границам. Если индекс станет неправильным, вы получите явный сигнал (исключение или диагностику, в зависимости от окружения), а не молчаливое UB.

Регресс‑мышление как барьер в дизайне

Иногда лучший регресс — не assert, а запрет неправильного состояния.

Например, если в вашем проекте есть правило «не хранить Task* на элементы vector<Task>», то вы можете поддержать его стилем API: функции возвращают TaskId или индекс, но не «адрес задачи». Да, это звучит как архитектура, но даже в маленьком проекте можно сделать шаг: перестать отдавать наружу адреса элементов.

В учебном TaskBook простой шаг выглядит так: вместо функции find_task(...), возвращающей указатель, сделать find_task_index(...) и дальше работать через контейнер.

Памятка на один экран

Когда вы в середине расследования, голова часто занята эмоциями: «почему оно снова упало», «я же ничего не менял», «компьютер меня ненавидит». В этот момент полезно иметь короткую памятку, которая возвращает вас на рельсы.

Ниже — тот же процесс, но более операционно, с формулировками в стиле «сделай → получи → проверь».

flowchart TD
    A[1 Воспроизвести] --> A1[Записать вход/команду/условия]
    A1 --> A2[Добиться стабильности: 3–5 раз подряд]
    A2 --> B[2 Минимизировать]
    B --> B1[Укоротить вход / выкинуть лишний код]
    B1 --> B2["Оставить минимальный сценарий (MRE)"]
    B2 --> C[3 Локализовать]
    C --> C1[Найти первую точку вашего кода]
    C1 --> C2[Сформулировать первопричину словами]
    C2 --> D[4 Исправить]
    D --> D1[Правка, устраняющая причину, а не симптом]
    D1 --> E[5 Перепроверить]
    E --> E1[Прогон под диагностикой/с assert]
    E1 --> F[6 Регресс‑проверка]
    F --> F1[Короткий self_check на сценарий]

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

6. Практический пример: от бага к регресс‑проверке

Соберём историю в компактную последовательность: было UB из‑за висячего указателя; мы выбрали решение «храним индекс»; добавили самопроверку.

Плохая версия

#include <iostream>
#include <string>
#include <vector>

struct Task { int id; std::string title; };

int main() {
    std::vector<Task> tasks{{1, "A"}};
    Task* selected = &tasks[0];

    tasks.push_back({2, "B"});
    std::cout << selected->title << '\n'; // UB
}

Исправленная версия и регресс‑проверка

#include <cassert>
#include <string>
#include <vector>

struct Task { int id; std::string title; };

void self_check() {
    std::vector<Task> tasks{{1, "A"}};
    std::size_t selected = 0;

    tasks.push_back({2, "B"});
    assert(tasks.at(selected).title == "A");
}

int main() {
    self_check();
}

Это не «вся архитектура мира». Это минимальный пример того, как мыслит регресс‑подход: вы не просто правите код, вы оставляете маленький маяк, который скажет «сломали снова».

7. Типичные ошибки расследования

Ошибка №1: начинать “исправлять”, не добившись стабильного воспроизведения.
Когда баг проявляется “через раз”, очень хочется немедленно вносить правки — кажется, что это ускорит путь к победе. На практике вы получаете рулетку: поменяли код, баг не проявился, вы решили, что победили, а на следующий день он вернулся. Стабильное воспроизведение — это ваш нулевой километр, без него вы просто бегаете по лесу без карты.

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

Ошибка №3: “чинить место падения”, игнорируя первопричину.
Очень соблазнительно увидеть падение на строке std::cout << ... и подумать «значит, проблема в выводе». На деле вывод часто просто первый, кто тронул испорченную память. Если вы лечите симптом, баг может сменить маску и начать падать в другом месте, а вы будете думать, что «появился новый баг». Обычно это всё тот же старый, просто вы поменяли декорации.

Ошибка №4: считать “перестало падать” доказательством исправления.
Памятные баги особенно коварны тем, что они могут «молчать» долго. Вы слегка поменяли порядок операций — и UB перестал проявляться. Но UB не обязан проявляться каждый раз, он вообще никому ничего не должен (в этом его токсичность). Поэтому после исправления нужна перепроверка под диагностикой и нужна регресс‑фиксация сценария, пусть даже в виде простого self_check().

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

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