1. Проблема
Якщо компілятор уже зміг зібрати ваш код, це ще не означає, що програма робить саме те, що ви задумали. Іноді вона друкує неправильну відповідь, іноді зависає, а іноді працює лише час від часу. І ось тут починається доросле життя програміста: ви не просто пишете код, а перевіряєте гіпотези про те, що в ньому відбувається. Гарна новина: навіть без дебагера й складніших інструментів можна значно підвищити шанси швидко спіймати помилку.
На цьому рівні ми зберемо міні‑набір прийомів, який майже завжди допомагає: акуратний друк, розділення звичайного виводу й діагностики, перевірка ключових припущень через assert і найпростіші лог‑повідомлення, щоб не плодити хаос із десяти std::cout.
Щоб усе це не лишалося абстракцією, розвиватимемо один невеликий консольний застосунок: MiniTasks — список завдань, у якому можна додати завдання, вивести список і позначити завдання як виконане.
Навчальний приклад: MiniTasks
Зараз нам потрібен максимально простий каркас програми, щоб на ньому показати діагностику. Важливо: ми навмисно пишемо код напряму, без архітектурних вишуканостей, — сьогодні тренуємо уміння бачити, що програма робить насправді.
Почнемо із заготовки: зберігаємо завдання в std::vector<std::string>, читаємо команди рядком через std::getline і розбираємо команду як «перше слово + решта рядка». Це не ідеальний парсер, але він чесний і прозорий.
#include <iostream>
#include <string>
#include <vector>
int main() {
std::vector<std::string> tasks;
while (true) {
std::cout << "cmd> ";
std::string line;
if (!std::getline(std::cin, line)) {
break; // EOF / помилка введення
}
if (line == "quit") {
break;
} else if (line.rfind("add ", 0) == 0) { // починається з "add "
std::string text = line.substr(4);
tasks.push_back(text);
std::cout << "added\n"; // added
} else if (line == "list") {
for (std::size_t i = 0; i < tasks.size(); ++i) {
std::cout << i << ": " << tasks[i] << '\n';
}
} else {
std::cout << "unknown command\n"; // unknown command
}
}
}
Поки тут немає done, перевірок і краси, — зате це чудовий полігон для діагностики: ви легко можете припуститися помилки в індексах, парсингу чи логіці розгалужень, а потім навчитися швидко її спіймати.
2. Діагностичний друк: std::cout vs std::cerr
Коли програма поводиться дивно, перша реакція новачка — «а я зараз усе роздрукую через std::cout». Це нормальний інстинкт — як ліхтарик у підвалі, — але є нюанс: std::cout часто є частиною очікуваного виводу. Якщо ваша програма розвʼязує задачу на платформі перевірки або просто повинна друкувати строго за форматом, діагностика в std::cout псує результат.
Тому правило просте: звичайний вивід — у std::cout, діагностика — у std::cerr. Тоді «нормальна» відповідь програми лишається чистою, а повідомлення про те, що відбувається всередині, потрапляють в окремий потік помилок.
Порівняймо це у вигляді таблиці:
| Інструмент | Для чого | Хто це читає | Що буде, якщо «насипати» туди налагодження |
|---|---|---|---|
|
результат роботи програми | користувач / система перевірки | можна зламати формат виводу |
|
діагностика, помилки, налагодження | ви (як розробник) | формат результату не страждає |
|
перевірка внутрішніх припущень | ви (як розробник) | програма зупиниться, якщо припущення порушено |
Додаймо в MiniTasks діагностику читання рядка, але в std::cerr:
#include <iostream>
#include <string>
int main() {
std::string line;
std::getline(std::cin, line);
std::cerr << "[debug] got line: '" << line << "'\n";
std::cout << "ok\n"; // ok
}
Якщо ви запускаєте програму вручну, то побачите два різні потоки. У більшості IDE і веб‑IDE їх часто показують разом, але логічно це два канали: один «для користувача», інший «для розробника».
3. Корисний друк: контекст важливіший за кількість
Друк стає справді корисним, коли відповідає на запитання «що я зараз перевіряю?» і «які значення важливі?». Якщо ви просто друкуєте «тут був Вася», а потім «тут був Петя», ви швидко потонете у власних повідомленнях, почнете ненавидіти налагодження — і трохи людство.
Хороший налагоджувальний вивід зазвичай містить контекст: назву команди, розмір контейнера, індекс, проміжні значення. Уявімо, що ми додали команду add, але іноді завдання додаються порожніми. Ми хочемо зрозуміти: рядок справді порожній чи ми неправильно беремо підрядок через substr.
Додамо один діагностичний рядок:
// ... всередині гілки add
std::string text = line.substr(4);
std::cerr << "[debug] add text length=" << text.size() << "\n";
tasks.push_back(text);
Тепер, якщо хтось введе add (а далі пробіл і нічого більше), ви чітко побачите length=0. А якщо десь помилитеся зі зсувом у substr, побачите неочікувану довжину або неочікуваний вміст.
І ось важливе просте правило: друк ставлять не «всюди», а за гіпотезою. Гіпотеза: «у text потрапляє щось не те». Отже, друкуємо text. Гіпотеза: «індекс виходить за межі». Отже, друкуємо i і tasks.size() — бажано ще до доступу до елемента.
4. Перевірки й ранній вихід: if проти хаосу
Багато помилок часу виконання, особливо у новачків, — це не «складна математика», а відсутність елементарної перевірки меж і порожніх випадків. І тут діагностика починається з простого запитання: «а що буде, якщо даних немає?».
Давайте додамо команду done N, яка видаляє завдання за індексом (умовно вважаємо, що «виконати» = прибрати зі списку). Наївна реалізація може мати такий вигляд:
// ПОГАНО: поки без перевірок
std::size_t index = /* якось розпарсили */;
tasks.erase(tasks.begin() + index);
Якщо index неправильний, наслідки будуть неприємні: від падіння до ситуації «дивно видалилося не те». Акуратна версія починається з перевірки та повідомлення про помилку:
#include <iostream>
#include <vector>
#include <string>
bool remove_task(std::vector<std::string>& tasks, std::size_t index) {
if (index >= tasks.size()) {
std::cerr << "[error] index out of range: index=" << index
<< " size=" << tasks.size() << "\n";
return false;
}
tasks.erase(tasks.begin() + index);
return true;
}
Зверніть увагу на стиль: ми не «падаємо», а кажемо, що саме не так, і повертаємо false. Це вже нагадує нормальну інженерну звичку: спочатку не дати програмі зробити небезпечний крок, а потім пояснити, чому ми відмовилися.
assert теж виконує перевірки, але в нього інша філософія. Зараз розберемося.
5. assert: перевіряємо внутрішні припущення
assert — це спосіб сказати: «якщо ця умова хибна, у програмі сталося щось, чого бути не повинно». За змістом це не про «користувач увів щось неправильно», а про «ми самі порушили внутрішній контракт». Тобто assert — це запобіжник від помилки програміста.
Підключається він так:
#include <cassert>
І використовують його як звичайну перевірку:
#include <cassert>
#include <vector>
int main() {
std::vector<int> v{10, 20, 30};
std::size_t i = 2;
assert(i < v.size()); // якщо хибно — аварійне завершення
int x = v[i];
}
Якщо умову порушено, програма аварійно зупиниться й повідомить, де саме це сталося (зазвичай файл і рядок). Це дуже зручно, коли ви хочете «впасти раніше», замість того щоб отримати незрозумілий крах на десять рядків далі.
Важливо розуміти дві особливості assert.
Перша: assert часто вимикається в релізному збиранні (зазвичай через певний макрос). Сьогодні ми не заглиблюємося в деталі конфігурації збирання, але сенс простий: assert — інструмент розробки, а не «ввічливе повідомлення користувачу».
Друга: усередині assert не повинно бути побічних ефектів. Якщо assert вимкнути, вони просто зникнуть — і програма почне поводитися інакше. Приклад поганого коду:
#include <cassert>
int main() {
int x = 0;
assert(++x == 1); // погано: змінюємо x всередині assert
}
Тут ви ніби збільшили x, але в збиранні, де assert вимкнено, x узагалі не зміниться. Виходить дуже хитра помилка: у debug працює, у release — ні. Такого ми не любимо (а баги — обожнюють).
6. assert у MiniTasks: ловимо помилки раніше
Тепер доведімо MiniTasks до команди done N. Нам знадобиться парсинг числа. На вашому поточному рівні для демонстрації можна використати std::stoi: ви вже бачили, що через некоректне введення тут можливі проблеми. Ми зробимо коротку, але доволі акуратну версію.
Ось фрагмент для обробки done:
#include <cassert>
#include <iostream>
#include <string>
#include <vector>
int main() {
std::vector<std::string> tasks{"learn C++", "drink tea"};
std::size_t index = 1;
assert(index < tasks.size()); // внутрішнє припущення
tasks.erase(tasks.begin() + index);
std::cout << tasks.size() << "\n"; // 1
}
Це ілюстрація «внутрішньої впевненості»: якщо index ми отримали там, де його коректність гарантовано, assert доречний. Але якщо index приходить від користувача, краще робити if та писати в std::cerr, тому що користувач має повне право помилитися (і ми не повинні «ображатися» падінням програми).
Комбінація виглядає так: спочатку if для користувацького введення, а assert — для внутрішньої логіки, яка «не повинна ламатися».
7. Логи: функції та прапорець
Прості log_debug і log_error
Коли проєкт трохи зростає, зʼявляється типова проблема: налагоджувальні повідомлення розмножуються, мов… ну, як кролі в підручнику з біології. Ви починаєте вручну копіювати [debug], десь забуваєте \n, змішуєте «помилку» і «просто подивитися», а потім половину цього сорому доводиться видаляти.
Тому навіть на початковому рівні корисно зробити дві маленькі функції: log_debug і log_error. Це не «справжня система логування», а просто спосіб дисциплінувати вивід.
Ось найпростіший варіант:
#include <iostream>
#include <string>
void log_debug(const std::string& msg) {
std::cerr << "[debug] " << msg << "\n";
}
void log_error(const std::string& msg) {
std::cerr << "[error] " << msg << "\n";
}
І використовувати так:
log_debug("start reading command");
log_error("unknown command");
Одразу стає простіше і читати, і прибирати: ви можете швидко знайти log_debug пошуком і видалити чи вимкнути ці виклики.
Вмикаємо й вимикаємо debug‑логи
Іноді ви хочете залишити log_debug у коді, але не хочете, щоб він постійно щось друкував. І тут дуже доречна ідея «прапорця»: увімкнули діагностику — отримали подробиці, вимкнули — тиша.
Оскільки ви вже знайомі з препроцесором, можна зробити просту річ: макрос ENABLE_DEBUG_LOGS. Коли він 1 — друкуємо, коли 0 — мовчимо.
#include <iostream>
#include <string>
#define ENABLE_DEBUG_LOGS 1
void log_debug(const std::string& msg) {
#if ENABLE_DEBUG_LOGS
std::cerr << "[debug] " << msg << "\n";
#else
(void)msg; // щоб компілятор не лаявся на unused
#endif
}
Тепер ви змінюєте одне число — і перемикаєте «балакучість» програми. Це дуже зручно, коли ви виправили баг, але хочете залишити можливість швидко ввімкнути трасування, якщо щось знову піде не так.
Важливо: не перетворюйте це на культ. Макроси — потужний інструмент, але вони текстові й не «розуміють» змісту C++‑коду. Тут ми використовуємо їх дуже дозовано: для простого вмикання й вимикання діагностики.
8. MiniTasks: команди й діагностика
Тепер обʼєднаємо ці ідеї в цілісніший міні‑фрагмент: читаємо рядок, розпізнаємо команди add, list, done, друкуємо помилки в std::cerr, а debug‑повідомлення вмикаємо прапорцем.
Код ще невеликий, але вже «схожий на програму»:
#include <cassert>
#include <iostream>
#include <string>
#include <vector>
#define ENABLE_DEBUG_LOGS 1
void log_debug(const std::string& msg) {
#if ENABLE_DEBUG_LOGS
std::cerr << "[debug] " << msg << "\n";
#else
(void)msg;
#endif
}
int main() {
std::vector<std::string> tasks;
while (true) {
std::cout << "cmd> ";
std::string line;
if (!std::getline(std::cin, line)) break;
log_debug("line='" + line + "'");
if (line == "quit") break;
if (line.rfind("add ", 0) == 0) {
std::string text = line.substr(4);
if (text.empty()) {
std::cerr << "[error] task text is empty\n";
continue;
}
tasks.push_back(text);
continue;
}
if (line == "list") {
for (std::size_t i = 0; i < tasks.size(); ++i) {
std::cout << i << ": " << tasks[i] << '\n';
}
continue;
}
if (line.rfind("done ", 0) == 0) {
std::string arg = line.substr(5);
int idx = std::stoi(arg); // демонстраційно: погане введення може "впустити"
if (idx < 0 || static_cast<std::size_t>(idx) >= tasks.size()) {
std::cerr << "[error] bad index: " << idx << "\n";
continue;
}
std::size_t index = static_cast<std::size_t>(idx);
assert(index < tasks.size()); // після if це вже "внутрішня впевненість"
tasks.erase(tasks.begin() + index);
continue;
}
std::cerr << "[error] unknown command\n";
}
}
Так, тут іще можна поліпшити парсинг, наприклад обробляти винятки stoi, але цей механізм ми детально розглянемо окремо. Важливо інше: ви вже бачите «скелет» діагностики, який можна взяти майже в будь‑яку навчальну програму.
9. Як думати під час налагодження: короткий цикл
Коли все ламається, дуже хочеться почати робити хаотичні правки: «а давайте тут +1», «а давайте тут <=». Іноді це навіть випадково все виправляє… але частіше народжується баг № 2, щоб баг № 1 не нудьгував.
Процес налагодження корисно тримати в голові як короткий цикл:
flowchart TD
A[Спостерігаємо проблему] --> B[Формулюємо гіпотезу]
B --> C[Додаємо точковий вивід у std::cerr]
C --> D[Перевіряємо інваріант через assert або if]
D --> E[Знаходимо місце, де руйнується очікування]
E --> F[Виправляємо код]
F --> G[Прибираємо або вимикаємо debug-логи]
G --> A
Ключове слово тут — «точковий». Діагностика добра тоді, коли вона схожа на хірургічний інструмент, а не на відро фарби, вилите по всій кімнаті.
10. Типові помилки
Помилка № 1: налагодження через std::cout і ламання формату виводу.
Коли ви друкуєте діагностику в std::cout, ви змішуєте «результат програми» і «внутрішні думки розробника». Якщо ваш вивід має відповідати формату, ви самі собі ставите підніжку: програма може стати «неправильною» лише тому, що ви додали налагоджувальний рядок. Звикайте: діагностика живе в std::cerr.
Помилка № 2: «давайте роздрукуємо взагалі все» й потонемо в шумі.
Багато друку не дорівнює хорошій діагностиці. Якщо повідомлень надто багато, ви перестаєте їх читати й починаєте ігнорувати навіть важливе. Корисніше поставити один‑два рядки з конкретним контекстом, наприклад index і size, поруч із місцем, де ви підозрюєте проблему.
Помилка № 3: використовувати assert для помилок користувацького введення.
assert — це про «так бути не повинно за логікою програми». Користувач же цілком може помилитися: ввести не те, пропустити аргумент, написати done котик. Для цього потрібні if і повідомлення про помилку, а не аварійна зупинка. assert лишайте для внутрішніх контрактів: індекси після перевірок, неможливі гілки, припущення про стан контейнера.
Помилка № 4: побічні ефекти всередині assert.
assert(++i < n) виглядає «зручно», але це пастка: у конфігураціях, де assert вимикається, ++i не виконається, і поведінка програми зміниться. У assert має бути чиста умова, без змін змінних, введення/виводу та викликів функцій з ефектами.
Помилка № 5: лікувати симптоми замість причини.
Якщо програма падає через вихід за межі, можна спробувати «підправити індекс», але правильніше спочатку зʼясувати, чому індекс став неправильним. Хороша діагностика спершу показує шлях даних: звідки прийшов індекс, як він перетворився, який розмір контейнера в цей момент, і лише потім ви змінюєте код.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ