Различные статьи по Qt и c++

fmt: безопасное и быстрое форматирование строк вместо QString::arg/sprintf

Введение

Помимо использования внутри spdlog, fmt можно применять самостоятельно как замену QString::arg()/sprintf в любом месте кода, где требуется построение форматированных строк — не только для логирования. В статье разберём более продвинутые возможности форматирования fmt — точность чисел, выравнивание, и пользовательские типы.

Концепция

Синтаксис fmt поддерживает спецификаторы формата внутри фигурных скобок после двоеточия ({:.2f} для числа с двумя знаками после запятой, {:>10} для выравнивания по правому краю с шириной поля 10 символов), концептуально похожие на спецификаторы printf, но проверяемые компилятором (начиная с C++20 и fmt::format в режиме constexpr-проверки — некорректный формат относительно типов аргументов даёт ошибку компиляции, а не ошибку/неопределённое поведение во время выполнения). Для пользовательских типов (собственных классов и структур) fmt поддерживает специализацию fmt::formatter, позволяющую форматировать их тем же единым синтаксисом {}, что и встроенные типы.

Пример кода: fmt

#include <fmt/core.h>
#include <fmt/format.h>

void demonstrateFormatting()
{
    // Точность чисел
    double temperature = 23.456789;
    fmt::print("Температура: {:.2f}°C\n", temperature); // "Температура: 23.46°C"

    // Выравнивание и ширина поля — полезно для табличного вывода в консоли
    fmt::print("{:<15}{:>10}\n", "Устройство", "Температура");
    fmt::print("{:<15}{:>10.1f}\n", "esp32_01", 23.5);
    fmt::print("{:<15}{:>10.1f}\n", "esp32_02", 19.8);

    // Числа с разделителями разрядов и в разных системах счисления
    fmt::print("{:x}\n", 255);      // "ff" — в шестнадцатеричной системе
    fmt::print("{:08b}\n", 42);     // "00101010" — в двоичной, с дополнением нулями до 8 знаков

    // Позиционные аргументы — переиспользование одного аргумента несколько раз
    fmt::print("{0} подключился. Добро пожаловать, {0}!\n", "esp32_01");
}
// Форматирование пользовательского типа через специализацию fmt::formatter
#include <fmt/core.h>

struct SensorReading {
    std::string deviceId;
    double temperature;
    double humidity;
};

// Специализация шаблона fmt::formatter для SensorReading — после этого
// fmt::format("{}", reading) работает напрямую, как для встроенных типов
template <>
struct fmt::formatter<SensorReading> : fmt::formatter<std::string> {
    auto format(const SensorReading &reading, format_context &ctx) const {
        return fmt::format_to(ctx.out(), "[{}] T={:.1f}°C H={:.1f}%",
                               reading.deviceId, reading.temperature, reading.humidity);
    }
};

void demonstrateCustomFormatter()
{
    SensorReading reading{"esp32_01", 23.456, 45.2};
    std::string message = fmt::format("Показание: {}", reading);
    // "Показание: [esp32_01] T=23.5°C H=45.2%"

    spdlog::info("Получено: {}", reading); // работает и со spdlog, поскольку тот использует fmt внутри
}

Пояснения к коду

Спецификаторы {:.2f}, {:<15}, {:>10} следуют единому, последовательному синтаксису fmt — двоеточие отделяет имя/индекс аргумента от спецификатора формата, символы </>/^ задают выравнивание (влево/вправо/по центру), число после них — минимальную ширину поля, а .N после типа — точность для чисел с плавающей точкой; это значительно компактнее эквивалентных манипуляций с std::setw()/std::setprecision() у std::ostringstream. Специализация fmt::formatter<SensorReading> показывает, как сделать собственный тип форматируемым через тот же унифицированный синтаксис {} — метод format() получает объект для форматирования и контекст вывода (ctx.out()), и реализация просто строит нужное текстовое представление через рекурсивный вызов fmt::format_to(); после такой специализации SensorReading можно передавать в fmt::format() или непосредственно в spdlog::info() без какого-либо дополнительного преобразования в строку на стороне вызывающего кода.

Подводные камни

  • Написание специализации fmt::formatter для каждого мелкого пользовательского типа без реальной необходимости, если этот тип форматируется лишь в одном-двух местах кода — для редко форматируемых типов проще и понятнее явно вызвать fmt::format() на отдельных полях прямо в месте использования, а полноценную специализацию fmt::formatter стоит писать для типов, которые форматируются часто, во многих разных местах кода, где унификация синтаксиса даёт реальную выгоду в читаемости.
  • Несоответствие спецификатора формата фактическому типу аргумента, например попытка применить числовой спецификатор {:x} (шестнадцатеричное представление) к аргументу типа std::string — в отличие от printf, где подобное несоответствие приводит к неопределённому поведению во время выполнения, fmt в большинстве случаев обнаруживает такие ошибки на этапе компиляции (при использовании constexpr-проверки формата, доступной в современных версиях fmt) — что одновременно является ключевым преимуществом fmt, но требует понимания, что ошибка появится как ошибка компиляции, а не сразу очевидный сбой во время работы программы.
  • Чрезмерное использование позиционных аргументов ({0}, {1}) в длинных, сложных строках формата без явных комментариев, что снижает читаемость кода по сравнению с простым последовательным использованием {} — позиционные аргументы оправданы прежде всего когда один и тот же аргумент действительно используется несколько раз в одной строке (как {0} в примере с приветствием), и не стоит применять их просто «на всякий случай» для строк с несколькими разными аргументами в естественном порядке.
  • Путаница между fmt::print() (выводит непосредственно в стандартный поток вывода) и fmt::format() (возвращает строку без вывода) — использование fmt::format() там, где нужен немедленный вывод, требует дополнительного, легко забываемого шага передачи результата в std::cout/fmt::print, тогда как использование fmt::print() там, где нужна именно строка для дальнейшей обработки (например, для передачи в QString или сохранения в переменную), требует ненужного промежуточного вывода — выбор правильной функции для конкретной задачи стоит делать осознанно, а не по привычке использовать одну и ту же функцию везде.