1. Логування — це не те саме, що std::cout
Якщо раніше ви писали програми «для себе», дуже легко звикнути друкувати все підряд у std::cout: «прочитав аргумент», «зайшов у if», «вийшов із циклу», «мамо, я програміст». Працює? Начебто так. Але щойно ви робите CLI‑утиліту, зʼясовується одна неприємна річ: std::cout часто читають не люди, а інші програми.
Уявіть, що ваша утиліта виводить список задач, а користувач хоче перенаправити виведення у файл: app --list > tasks.txt. Якщо в цей самий потік ще й писати «DEBUG: entered parse_args», у файлі користувача опиниться сміття. Навряд чи він зрадіє. Та й ви теж. Задоволений буде хіба що хаос.
Тому варто дотримуватися простої дисципліни: результат роботи йде в std::cout, а діагностика (зокрема й логи) — у std::cerr. cerr спеціально для цього й існує: він відділяє «нормальне виведення» від «повідомлень про проблеми або хід роботи». Саме тому в реальному житті багато утиліт пишуть хід виконання та попередження в stderr, а дані — у stdout.
Можна запамʼятати просту аналогію: std::cout — це «що ви пообіцяли користувачу за контрактом». std::cerr — це «як ви при цьому бурчите собі під ніс, коли щось іде не так».
Невелика схема потоків, щоб закріпити ідею візуально:
flowchart LR
A["Ваш код"] -->|результат| OUT["std::cout (stdout)"]
A -->|діагностика/логи| ERR["std::cerr (stderr)"]
OUT --> F["перенаправлення > file.txt"]
ERR --> T["термінал / журнал помилок"]
2. Рівні логів: info, warn, error
Коли йдеться про логи, новачки часто впадають у дві крайнощі: або не логують нічого, або логують геть усе. Обидва підходи зазвичай закінчуються однаково: ви все одно нічого не знаходите. У першому випадку — тому що інформації немає. У другому — тому що її надто багато, і потрібний рядок тоне в океані «entered function».
Тому ми беремо мінімальний, але корисний набір рівнів:
- info — нейтральні повідомлення про хід роботи програми (запустилися, розібрали режим, почали операцію).
- warn — щось дивне, але програма продовжує працювати (наприклад, користувач передав опцію, але значення підозріле; або частину введення проігноровано).
- error — помилка, через яку дію виконати не можна (погані аргументи, невідома команда, відсутній обовʼязковий параметр).
Важливо: рівень — це не «краса». Це сенс. А сенс впливає на два рішення: куди друкувати і що робити далі.
Зафіксуймо це в невеликій таблиці. Інколи таблиця рятує очі краще, ніж маркований список.
| Рівень | Про що повідомлення | Програма продовжує? | Типовий потік |
|---|---|---|---|
|
«Що робимо» | Так | частіше std::cerr (діагностика) або вимикається |
|
«Дивно, але терпимо» | Так | std::cerr |
|
«Неможливо виконати» | Зазвичай ні (або пропускаємо команду в інтерактивному режимі) | std::cerr |
У цій лекції ми не будуємо складну систему фільтрації логів, не додаємо «debug/trace» і не пишемо у файли. Наша мета простіша: єдиний формат і мінімальна структура, щоб ваші повідомлення були схожі на повідомлення серйозної програми, а не на крик душі в cout.
3. Єдиний формат лога: стабільність важливіша за красу
Якщо логи не мають формату, то щоразу, коли ви хочете щось знайти очима, витрачаєте час на гру «вгадай, як сьогодні я вирішив друкувати помилки». Формат розвʼязує дві практичні задачі: допомагає швидко сканувати текст очима і швидко шукати в ньому — навіть звичайним Ctrl+F.
Ми візьмемо простий формат:
[LEVEL] message
Наприклад:
[INFO] mode=interactive
[WARN] unknown option ignored: --colour=neon
[ERROR] --name requires a value
Чому саме квадратні дужки? Це не принципово. Можна було б писати «INFO:» або «(INFO)». Але коли формат один, ви перестаєте сперечатися із собою — і просто починаєте писати код.
Ще один важливий момент: логер не має бути «розмазаний» по всьому коду. Тобто не варто в кожному місці вручну писати std::cerr << "[ERROR]" ... Інакше за тиждень половина повідомлень опиниться без ], чверть — без переведення рядка, а якесь одне повідомлення випадково поїде в cout. Ми робимо одну функцію log(...), і формат живе в одному місці.
4. Мікрошаблон switch: enum class → текст
Зараз нам потрібно вибрати зручне подання рівнів. Найпростіший і цілком хороший спосіб — enum class. Він безпечніший, ніж «int‑рівні», тому що компілятор не дасть вам випадково передати 42 замість LogLevel::warn.
Почнімо з визначення:
enum class LogLevel {
info,
warn,
error
};
Тепер хочемо отримати текст "INFO", "WARN", "ERROR". Робити це через if можна, але читати такий код менш зручно. Тут найдоречніший switch: його якраз і створено для вибору за enum.
Ось мінімальний, «канонічний» шаблон:
#include <string_view>
enum class LogLevel { info, warn, error };
std::string_view to_string(LogLevel lvl) {
switch (lvl) {
case LogLevel::info: return "INFO";
case LogLevel::warn: return "WARN";
case LogLevel::error: return "ERROR";
}
return "UNKNOWN";
}
Зверніть увагу на останній рядок return "UNKNOWN";. Формально може здаватися: «та він не потрібен, у нас же всього три значення». Але практика показує, що захисна гілка — це як запасний ключ. Зазвичай він не потрібен, доки одного разу раптом не виявиться, що потрібен.
5. Пишемо log(...): одна точка формату, один стиль
Тепер зберемо логер. Найпростіша версія: друкуємо в std::cerr, бо лог — це діагностика.
#include <iostream>
#include <string_view>
enum class LogLevel { info, warn, error };
std::string_view to_string(LogLevel lvl) {
switch (lvl) {
case LogLevel::info: return "INFO";
case LogLevel::warn: return "WARN";
case LogLevel::error: return "ERROR";
}
return "UNKNOWN";
}
void log(LogLevel lvl, std::string_view msg) {
std::cerr << '[' << to_string(lvl) << "] " << msg << '\n';
}
Це вже корисно, тому що:
- формат в одному місці;
- рівень не забудете — він параметр;
- std::string_view дає змогу передавати і рядкові літерали, і std::string без копіювання (за умови, що рядок існує достатньо довго; у межах виклику функції це зазвичай не проблема).
Невеликий приклад використання:
log(LogLevel::info, "Застосунок запущено"); // [INFO] Застосунок запущено
log(LogLevel::warn, "Підозріле введення"); // [WARN] Підозріле введення
log(LogLevel::error, "Некоректні аргументи"); // [ERROR] Некоректні аргументи
І так, ви помітили: результат показано в коментарях — це допомагає звіряти очікування з реальністю, особливо новачкам.
6. std::ostringstream: коли рядок незручно «склеювати руками»
Із логами швидко виникає проста побутова проблема: повідомлення часто складається зі шматочків. Наприклад:
- "невідома опція: " + arg
- "mode=" + modeName
- "не вдалося відкрити файл: " + path
Новачки зазвичай обирають один із двох шляхів. Перший — конкатенація +, другий — багато << просто в std::cerr. Обидва варіанти інколи доречні, але мають свої мінуси.
Конкатенація + потребує std::string, і якщо у вас частина даних — не рядки (наприклад, int argc), доводиться танцювати зі std::to_string. А ще дуже легко отримати кашу з дужок і плюсів.
Друкувати прямо в std::cerr теж можна, але тоді формат повідомлення розповзається по коду: частина в логері, частина — у місці виклику. Хочеться мати змогу спочатку «зібрати повідомлення», а потім одним викликом відправити його в log().
Тут і стає у пригоді std::ostringstream — потік для виводу в рядок. Це частина сімейства класів stringstream (сюди ж належить std::stringstream, яким ви вже користувалися).
Невеликий приклад: зібрати рядок «як через <<», а не через +.
#include <sstream>
#include <string>
std::string build_argc_message(int argc) {
std::ostringstream oss;
oss << "argc=" << argc;
return oss.str();
}
Використання:
log(LogLevel::info, build_argc_message(argc)); // [INFO] argc=3
Виглядає трохи довше, ніж std::to_string(argc), але цей підхід добре масштабується: додавати нові шматочки зручно, типи автоматично форматуються потоком, а ви не перетворюєте код на «павука з плюсів».
7. Вбудовуємо логування в навчальний CLI‑застосунок
Щоб приклади не були абстрактними, уявімо, що ми вже пишемо маленьку утиліту TaskBox (умовна назва), яка вміє працювати у двох режимах:
- одноразово: taskbox --add "Buy milk"
- інтерактивно: taskbox --interactive, далі команди у stdin
Сьогодні ми не переписуватимемо весь парсер аргументів з нуля — це тема попередніх лекцій, — а лише покажемо, як акуратно вбудувати в нього логування.
Логуємо розбір --interactive без фанатизму
Якщо ми знайшли --interactive, корисно зафіксувати, що програма справді розпізнала цей режим.
#include <string_view>
bool is_interactive = false;
for (int i = 1; i < argc; ++i) {
std::string_view arg = argv[i];
if (arg == "--interactive") {
is_interactive = true;
log(LogLevel::info, "mode=interactive"); // [INFO] mode=interactive
}
}
Чому це info, а не warn? Тому що це нормальний шлях роботи: ми просто хочемо бачити його в діагностиці, коли налагоджуємо програму або коли користувач скаржиться, що «воно не працює».
Невідома опція: це error чи warn?
Ось тонкий момент. Якщо за контрактом ви вирішили: «будь‑яка невідома опція — помилка аргументів», то це error, і програма завершиться. Якщо ж ви вирішили: «ми ігноруємо невідомі опції й продовжуємо», це warn.
У навчальних CLI зазвичай краще бути суворими: невідома опція — помилка. Тоді користувач швидше вчиться правильного використання.
#include <sstream>
#include <string_view>
if (arg.starts_with("-")) {
std::ostringstream oss;
oss << "невідома опція: " << arg;
log(LogLevel::error, oss.str()); // [ERROR] невідома опція: --kek
return 2;
}
Зверніть увагу: ostringstream тут потрібен, тому що arg — це string_view, і нам зручно зібрати рядок без ручної конкатенації.
Інтерактивний цикл: помилки команди не завжди означають «завершуємо роботу»
В інтерактивному режимі логіка часто така: одна команда може бути помилковою, але ми не хочемо закривати всю програму. Тоді error означає «помилка команди», але не обовʼязково означає «завершуємо програму».
#include <iostream>
#include <string>
std::string line;
while (std::getline(std::cin, line)) {
if (line == "quit") break;
if (line.empty()) {
log(LogLevel::warn, "порожня команда"); // [WARN] порожня команда
continue;
}
log(LogLevel::info, "команду прийнято"); // [INFO] команду прийнято
}
Тут warn для порожньої команди — рішення дискусійне, але в навчальних цілях воно корисне: ви бачите, що відбувається, коли користувач просто натискає Enter.
8. Практика: шум, info і мінімальний рівень
Поки що ми завжди друкуємо все в std::cerr. Для навчальної версії це нормально. Але в реальних утилітах часто хочеться, щоб info можна було вимкнути (наприклад, за замовчуванням друкувати тільки warn/error).
Ми поки не вводимо повноцінні прапорці --verbose (це вже тема «розширеного CLI»), але можемо закласти маленький механізм: мінімальний рівень.
LogLevel g_min_level = LogLevel::info;
bool enabled(LogLevel lvl) {
return static_cast<int>(lvl) >= static_cast<int>(g_min_level);
}
І в log(...):
void log(LogLevel lvl, std::string_view msg) {
if (!enabled(lvl)) return;
std::cerr << '[' << to_string(lvl) << "] " << msg << '\n';
}
Тут є один нюанс: ми використали static_cast<int>, тому що enum class не приводиться до int автоматично. Це нормально, але важливо памʼятати: порядок значень в enum задає сенс порівняння, тож значення info/warn/error мають стояти саме в цьому порядку.
Дуже легко, відчувши силу логів, почати вставляти log(info, "...") у кожен рядок коду. За день ви отримаєте 500 рядків на один запуск, а головне — перестанете сприймати їх як сигнал.
Практичне правило просте: логуйте події, а не рядки. Подія — це «вибрали режим», «прочитали файл», «отримали некоректну команду», «не змогли розпарсити аргумент». Рядок — це повідомлення на кшталт «зайшли в if» (зазвичай таке не потрібне).
Якщо хочеться покроково дивитися виконання — для цього існує відладчик. Логи — це спосіб зрозуміти поведінку програми, не заходячи в неї щоразу через відладчик.
9. Типові помилки
Помилка № 1: писати логи в std::cout і ламати «чисте виведення».
Це найчастіша проблема в CLI‑утилітах: користувач очікує дані в stdout (щоб перенаправити їх у файл або в іншу програму), а натомість отримує впереміш «результат» і «діагностику». Тримайте в голові просте правило: std::cout — для результату, std::cerr — для логів і помилок.
Помилка № 2: не мати єдиного формату й збирати «зоопарк повідомлень».
Коли частина повідомлень виглядає як Error: ..., частина як [ERROR] ..., а частина як !!! ..., шукати очима стає боляче. Виправляється це просто: одна функція log(), і формат живе всередині неї. У місцях виклику ви передаєте лише сенс, а не оформлення.
Помилка № 3: робити рівень логів рядком, а не enum class.
Якщо рівень — рядок, ви легко напишете "ERORR" і не помітите. Якщо рівень — enum class LogLevel, компілятор змушує вас обирати тільки з допустимих варіантів. А перетворення enum → текст робиться через маленький switch, який читається простіше за будь‑який набір if.
Помилка № 4: намагатися склеювати повідомлення через + і потонути в std::to_string.
Конкатенація рядків швидко перетворюється на кашу, особливо коли ви додаєте числа й різні типи. У таких місцях std::ostringstream дає акуратніший і читабельніший код: ви пишете повідомлення так само, як друкували б його в cout, а потім берете oss.str().
Помилка № 5: логувати «все підряд» і знецінити логи.
Коли логів занадто багато, вони перестають бути інструментом діагностики й перетворюються на шум. Намагайтеся логувати ключові події, а не кожен крок алгоритму. Якщо вам потрібне покрокове трасування — найчастіше це робота для відладчика, а не для log(info, "...").
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ