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, "App started");      // [INFO] App started
log(LogLevel::warn, "Suspicious input"); // [WARN] Suspicious input
log(LogLevel::error, "Bad arguments");   // [ERROR] Bad arguments

И да, вы заметили: вывод показан в комментариях — это помогает сверять ожидания и реальность, особенно новичкам.

6. std::ostringstream: когда строку неудобно “склеивать руками”

С логами быстро возникает бытовая проблема: сообщение часто состоит из кусочков. Например:

  • "unknown option: " + arg
  • "mode=" + modeName
  • "cannot open file: " + 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 << "unknown option: " << arg;
    log(LogLevel::error, oss.str()); // [ERROR] unknown option: --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, "empty command"); // [WARN] empty command
        continue;
    }

    log(LogLevel::info, "command accepted"); // [INFO] command accepted
}

Здесь 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, "...").

1
Задача
C++ SELF, 67 уровень, 3 лекция
Недоступна
Единый лог
Единый лог
1
Задача
C++ SELF, 67 уровень, 3 лекция
Недоступна
Разбор уровня
Разбор уровня
1
Задача
C++ SELF, 67 уровень, 3 лекция
Недоступна
Команды с логом
Команды с логом
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ