JavaRush /Курси /C++ SELF /Міні‑набір діагностики: друк, assert, лог‑повідомлення

Міні‑набір діагностики: друк, assert, лог‑повідомлення

C++ SELF
Рівень 30 , Лекція 4
Відкрита

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. Тоді «нормальна» відповідь програми лишається чистою, а повідомлення про те, що відбувається всередині, потрапляють в окремий потік помилок.

Порівняймо це у вигляді таблиці:

Інструмент Для чого Хто це читає Що буде, якщо «насипати» туди налагодження
std::cout
результат роботи програми користувач / система перевірки можна зламати формат виводу
std::cerr
діагностика, помилки, налагодження ви (як розробник) формат результату не страждає
assert
перевірка внутрішніх припущень ви (як розробник) програма зупиниться, якщо припущення порушено

Додаймо в 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: лікувати симптоми замість причини.
Якщо програма падає через вихід за межі, можна спробувати «підправити індекс», але правильніше спочатку зʼясувати, чому індекс став неправильним. Хороша діагностика спершу показує шлях даних: звідки прийшов індекс, як він перетворився, який розмір контейнера в цей момент, і лише потім ви змінюєте код.

1
Опитування
Что делает компилятор, рівень 30, лекція 4
Недоступний
Что делает компилятор
Что делает компилятор
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ