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: не лишати після розслідування артефактів — сценарію та перевірки.
Якщо після «виправлення» не лишилося ні мінімального сценарію, ні коментаря «як відтворити», ні маленької регресної перевірки, то розслідування було разовим подвигом. Подвиг красивий, але дорогий: наступного разу ви героїчно страждатимете знову. Регресне мислення якраз і полягає в тому, щоб страждати один раз, а не за розкладом.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ