JavaRush /Курсы /C++ SELF /Мини‑toolbox диагностики: печать, assert, лог‑сообщения

Мини‑toolbox диагностики: печать, 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 и Web‑IDE они часто отображаются вместе, но логически это два канала: один «для пользователя», другой «для разработчика».

4. Полезная печать: контекст важнее количества

Печать становится реально полезной, когда она отвечает на вопрос «что я сейчас проверяю?» и «какие значения важны?». Если вы просто печатаете «тут был Вася», а потом «тут был Петя», вы быстро утонете в своих же сообщениях и начнёте ненавидеть отладку (и немного человечество).

Хороший отладочный вывод обычно содержит контекст: имя команды, размер контейнера, индекс, промежуточные значения. Давайте представим, что мы добавили команду 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() — и желательно ещё до доступа к элементу.

5. Проверки и ранний выход: 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 тоже будет делать проверки, но у него другая философия. Сейчас разберём.

6. 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 — нет. Такое мы не любим (а баги — обожают).

7. 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 — для внутренней логики, которая «не должна ломаться».

8. Логи: функции и флажок

Простые 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++‑смысл. Здесь мы используем их строго дозированно: для простого включения/выключения диагностики.

9. 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, но исключения как механизм мы подробно будем рассматривать в отдельном дне). Важно другое: вы уже видите «скелет» диагностики, который можно брать почти в любую учебную программу.

10. Как думать при отладке: короткий цикл

Когда всё ломается, очень хочется начать делать хаотичные правки: «а давайте тут +1», «а давайте тут <=». Иногда это даже случайно чинит… но чаще создаёт баг №2, чтобы баг №1 не скучал.

Процесс отладки полезно держать в голове как короткий цикл:

flowchart TD
    A[Наблюдаем проблему] --> B[Формулируем гипотезу]
    B --> C[Ставим точечную печать в std::cerr]
    C --> D[Проверяем инвариант через assert или if]
    D --> E[Находим место, где ожидание ломается]
    E --> F[Правим код]
    F --> G[Убираем/выключаем debug-логи]
    G --> A

Ключевое слово здесь — «точечную». Диагностика хороша, когда она как хирургический инструмент, а не как ведро краски по всей комнате.

11. Типичные ошибки

Ошибка №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
Задача
C++ SELF, 30 уровень, 4 лекция
Недоступна
Отладочный чек
Отладочный чек
1
Задача
C++ SELF, 30 уровень, 4 лекция
Недоступна
Управляемый логгер
Управляемый логгер
1
Задача
C++ SELF, 30 уровень, 4 лекция
Недоступна
Безопасный символ
Безопасный символ
1
Задача
C++ SELF, 30 уровень, 4 лекция
Недоступна
Консольный планер
Консольный планер
1
Опрос
Что делает компилятор, 30 уровень, 4 лекция
Недоступен
Что делает компилятор
Что делает компилятор
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ