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: запуск -> падіння / ASan скаржиться після push_back
// Сценарій: зберегти вказівник на tasks[0], потім зробити ще один push_back, потім прочитати старий вказівник.

Це не бюрократія. Це страховка від ситуації: «учора в мене падало, а сьогодні ні, і я не памʼятаю як».

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
Опитування
View‑типи і triage, рівень 47, лекція 4
Недоступний
View‑типи і triage
View‑типи і triage
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ