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 и 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: лечить симптомы вместо причины.
Если программа падает на выходе за границы, можно попытаться «подправить индекс», но правильнее сначала выяснить, почему индекс стал неправильным. Хорошая диагностика сначала показывает путь данных: откуда пришёл индекс, как он преобразовался, какой размер контейнера в этот момент, и только потом вы меняете код.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ