JavaRush /Курси /C++ SELF /Логування: рівні info/warn/error і єдиний формат

Логування: рівні info/warn/error і єдиний формат

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

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 — помилка, через яку дію виконати не можна (погані аргументи, невідома команда, відсутній обовʼязковий параметр).

Важливо: рівень — це не «краса». Це сенс. А сенс впливає на два рішення: куди друкувати і що робити далі.

Зафіксуймо це в невеликій таблиці. Інколи таблиця рятує очі краще, ніж маркований список.

Рівень Про що повідомлення Програма продовжує? Типовий потік
INFO
«Що робимо» Так частіше std::cerr (діагностика) або вимикається
WARN
«Дивно, але терпимо» Так std::cerr
ERROR
«Неможливо виконати» Зазвичай ні (або пропускаємо команду в інтерактивному режимі) 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';
}

Це вже корисно, тому що:

  1. формат в одному місці;
  2. рівень не забудете — він параметр;
  3. 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, "...").

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ