Когда-то давным-давно (то есть до C++20) мы форматировали вывод либо по-старинке через printf, либо используя громоздкие стримы ввода-вывода из <iostream>. Оба подхода, мягко говоря, не очень. printf работал шустро и лаконично, но требовал строгого соответствия типов, забудешь правильный %d или %s в формате, и получишь неопределённое поведение вплоть до падения программы. Компиляторы иногда предупреждают о несоответствиях, но полностью проблему не решают (особенно если форматируемая строка не литерал). Кроме того, printf не умеет выводить пользовательские классы, только примитивы.

А что насчёт iostream и std::cout? С ними типобезопасность полная, но цена в многословности и сложности. Чтобы, например, вывести число в шеснадцатеричном формате с ведущими нулями, приходилось писать подобное:

#include <iostream>
#include <iomanip>

void printOldWay(unsigned squishiness) {
    std::cout << "Invalid squishiness: "
              << std::setfill('0') << std::setw(2) << std::hex 
              << squishiness << "\n";
}

Супер, подключили <iomanip>, нагромоздили манипуляторов std::setfill/setw/hex и всё это ради форматирования одной строчки.

Сейчас ситуация изменилась. В C++20 завезли библиотеку <format>, современный подход к форматированию строк, сочетающий лаконичность printf с безопасностью iostream. Инструмент называется std::format и объявлен в заголовке <format>. По сути, это адаптация популярной библиотеки fmt.

Для начала

Всё довольно просто: подключаем заголовок <format> и вызываем функцию std::format(fmt_string, args...). Она очень похожа на printf, только вместо % используется фигурные скобки {}. Функция возвращает готовую отформатированную строку (std::string), а не выводит её сама. То есть вы можете распечатать результат через std::cout, записать в файл или куда душе угодно. Пример программки:

#include <format>
#include <iostream>
#include <string>

int main() {
    std::string name = "Habr";
    int year = 2025;
    std::string msg = std::format("Привет, {}, с любовью из {}!", name, year);
    std::cout << msg << std::endl;
}

Запустив, получим вывод: Привет, Habr, с любовью из 2025!. Фигурные скобки {} в шаблоне строки автоматически заменились на наши аргументы, cначала name, потом year, в том же порядке. Компилятор сам знает типы name и year и подставит их как строки и числа соответственно. Если вы передадите лишний или неподходящий аргумент, программа даже не соберётся (так называемая типобезопасность). А если форматируемая строка формируется во время выполнения, то при несоответствии будет брошено исключение std::format_error, никаких тихих вылетов или повреждения памяти.

Поскольку std::format возвращает строку, для вывода в консоль мы по старинке используем std::cout. Впрочем, в C++23 проблему чуть упростили, добавив функции std::print и std::println, которые сразу печатают в stdout без лишних движений.

Placeholder и спецификаторы формата

Итак, базовый синтаксис такой: в форматируемой строке пишем {} placeholder, а следом передаём аргументы. По дефолту первый {} заменится на первый аргумент, второй на второй и так далее. Но что, если нужно поменять порядок или повторно вставить один и тот же аргумент? Не проблема, внутри фигурных скобок можно указать индекс (начиная с 0). Например:

std::string repl = std::format("{1}, {0}!", "мир", "Привет");
std::cout << repl << std::endl;  // Выведет: "Привет, мир!"

Здесь {1} взял второй аргумент ("Привет"), а {0} первый ("мир"). Можно использовать один аргумент несколько раз, ссылаясь на него по индексу, удобно и никаких дубликатов в списке аргументов. Либо все placeholder без индексов, либо все с индексами. Иначе формат не пройдёт проверку.

Кстати, именованных placeholder стандартный std::format пока не поддерживает. Такая возможность была в библиотеке {fmt}, но в С++20 её не включили напрямую. Впрочем, при большом желании можно извернуться через std::vformat, но это тема для отдельного разговора.

С placeholders разобрались. Теперь спецификаторы формата. После аргумента внутри {} можно поставить двоеточие : и написать особые символы, задающие, как именно форматировать значение. Синтаксис этих спецификаторов позаимствован из питона, что приятно, если знакомы с f-string или str.format() в Python, разберётесь быстро.

Скажем, у нас есть разные данные:

#include <format>
#include <iostream>
#include <string>
#include <numbers>  // для константы pi

int main() {
    int i = 42;
    double pi = std::numbers::pi;       // 3.14159...
    std::string who = "Habr";

    // 1. Простейшее форматирование по умолчанию
    std::cout << std::format("Int: {}, Double: {}, String: {}\n", i, pi, who);
    
    // 2. Задаём число знаков после запятой для double 
    std::cout << std::format("Pi ~ {:.3f}\n", pi);          // "Pi ~ 3.142"
    
    // 3. Шестнадцатеричный вывод с префиксом 0x и шириной поля 6 символов
    std::cout << std::format("Hex: {:#06x}\n", i);          // "Hex: 0x002a"
    
    // 4. Вывод числа с заполнением нулями до 5 символов
    std::cout << std::format("Zero-padded: {:05}\n", i);    // "Zero-padded: 00042"
    
    // 5. Выравнивание текста в поле шириной 10: влево, вправо, по центру
    std::cout << std::format("[{:<10}]\n", who);            // "[Habr      ]"
    std::cout << std::format("[{:>10}]\n", who);            // "[      Habr]"
    std::cout << std::format("[{:^10}]\n", who);            // "[   Habr   ]"
    
    // 6. Пользовательский символ заполнения при выравнивании
    std::cout << std::format("{:*^20}\n", "C++20");         // "***C++20*********"
}

Пройдёмся по этим примерам.

  • (1) Простая вставка без спецификаторов: {} по дефолту выведет int как число, double как число в привычном формате, std::string как текст. Ничего хитрого, результат был бы, например, Int: 42, Double: 3.14159, String: Habr

  • (2) Управление точностью для чисел с плавающей точкой: спецификатор .3f означает "форматировать как float с 3 знаками после десятичной точки". Поэтому 3.14159 превратится в 3.142. Если хотим экспоненциальный формат, используем e или E, а общийg. Например, {:.2e} даст два знака в научной нотации.

  • (3) Шестнадцатеричный формат с префиксом. Тут сразу несколько фишек: %#x решётка # включает префикс 0x для Hex (для восьмеричного # дал бы префикс 0), а 06 задаёт минимальную ширину 6 символов, заполняя недостающее нулями. В результате 42 (0x2A) выводится как 0x002a. Заглавная X дала бы буквы A-F в верхнем регистре. Кстати, можно выводить и в двоичном виде: спецификатор b (или B) – бинарный формат. Например, {:#b} для 42 выведет 0b101010.

  • (4) Принудительное дополнение ноликами, часто используемое в старом printf (через %05d например). Здесь это достигается просто указанием ширины и 0 как флага: {:05}. Мы не писали тип, значит для int подставится десятичный формат по умолчанию. Получаем 00042.

  • (5) Выравнивание текста. По умолчанию строки и числа правого выравнивания не имеют (строка просто выводится как есть). Но можно задать ширину поля и способ выравнивания: < влево, > вправо, ^ по центру. В примере я взял ширину 10 символов. Для строки "Habr" слева получится "Habr " (6 пробелов справа), справа – " Habr", центр – " Habr " (по 3 пробела с каждой стороны). Очень простое форматирование таблиц, колонок и т.д. Раньше для этого надо было играться с std::setw и считать длину строк вручную.

  • (6) Символ заполнения. По умолчанию заполняются пробелами, но ведь можно и любым другим символом. Для этого указываем символ сразу перед ключом выравнивания. В шаблоне {:*^20} звёздочка означает заполнять вместо пробелов, ^ выравнивать по центру, а 20 ширина. В итоге "C++20" окружат звёздочками до общей длины 20: ***C++20*********.

Это мы рассмотрели лишь самые базовые вещи. Есть ещё флаги для указания знака числа (+ всегда печатает знак, даже у положительных, а пробел резервирует место под знак), флаг локализации L , да и вообще почти всё, что умеет Python-форматирование, доступно и тут. Например, для целых типов больше не нужно мучиться с суффиксами вроде ll или макросами PRId64, длина определяется автоматически по типу аргумента.

Ещё есть атомарность вывода. Форматируемая строка собирается полностью, прежде чем вы её куда-то выводит. Т.е сли два потока выводят что-то одновременно через std::format + std::cout, они не перемешают текст построчно. С printf такое тоже было, а вот iostream без супер ухищрений могли дать вперемешку, если несколько потоков пишут в один cout. В C++20, правда, для потоков придумали osyncstream, но всё равно форматирование одной строкой проще и надёжнее для многопоточного вывода.

Пользовательские типы и std::formatter

А что если хочется форматировать свой собственный тип? Раньше, для std::cout, достаточно было перегрузить оператор << для вашего класса, и вывод стал бы возможен. В случае std::format такой приемчик не пройдёт, он не использует operator<< для своих нужд. Вместо этого предусмотрен механизм специализации структуры std::formatter.

Допустим, есть простой структуральный тип:

#include <format>
#include <string>
#include <iostream>

struct Point {
    int x;
    int y;
};

// Специализация шаблона std::formatter для нашего типа Point
template <>
struct std::formatter<Point> : std::formatter<std::string> {
    // Метод format будет вызываться для форматирования Point
    auto format(const Point& p, std::format_context& ctx) const {
        // Сформируем строку через std::format для пары координат
        std::string out = std::format("[{}, {}]", p.x, p.y);
        // И воспользуемся formatter<std::string> (наш базовый класс) 
        // чтобы применить возможные формат-флаги (выравнивание, ширину и т.п.)
        return std::formatter<std::string>::format(out, ctx);
    }
};

Специализировали std::formatter<Point> и унаследовали её от std::formatter<std::string>чтобы автоматически поддержать все спецификаторы, которые обычно применимы к строкам, выравнивание, ширину поля, заполнение и т.д.

Остаётся только реализовать метод format(), который получает на вход наш объект Point и контекст форматирования. Внутри просто используем std::format рекурсивно: формируем строковое представление точки вида "[x, y]", а затем делегируем форматирование этой строки базовому классу. В итоге Point сможет форматироваться в любом виде: как обычный {}, с выравниванием {:<8} или даже с центрированием и заполнением: {:~^10} и т.п. Всё, что может строка, может и Point через такую специализацию.

Проверим, работает ли. Добавим функцию main:

int main() {
    Point pt{3, 4};
    std::string result = std::format("Point: {}\n", pt);
    std::cout << result;
    std::cout << std::format("Centered: {:~^12}\n", pt);
}

Если мы всё сделали правильно, первый вывод даст: Point: [3, 4]. А второй применит центрирование в поле ширины 12 с заполнением ~.

К слову о датах, библиотека <format> умеет дружить с типами <chrono>. Можно напрямую форматировать время и даты. Например:

using namespace std::chrono;
auto now = system_clock::now();
std::cout << std::format("Текущее время: {:%Y-%m-%d %H:%M:%S}\n", now);

Выводит текущую дату и время в указанном формате (здесь формат строки похож на strftime, например %Y – год, %m – месяц), получится что-то вроде Текущее время: 2025-11-20 21:54:50. Учтите, по дефолту используется UTC-время.

Производительность

Новый std::format зачастую быстрее потокового ввода-вывода, который славится небыстротой, и примерно не уступает printf. Где-то может быть чуть медленнее на простых форматах, где-то быстрее, но в целом уровень сопоставимый.

Конечно, надо понимать, что если вы гонитесь за каждым наносекундом, printf без всяких проверок может быть чуток быстрее, а ещё низкоуровневый std::to_chars (из C++17) для простых чисел работает сверхбыстро без аллокаций.


Пользуйтесь на здоровье! Подключаете <format>, пишете std::format("Ваш шаблон {} {}", arg1, arg2), и наслаждаетесь чистым выводом. Счастливого вам форматирования!

Если хочется глубже освоить сам язык — от базовой модели памяти до многопоточности, корутин и экосистемы STL/Boost — в OTUS есть специалиазация C++ Developer, где весь этот фундамент разбирается системно и с упором на практику. Такой путь помогает понимать не только синтаксис, но и инженерную логику, на которой держится современная разработка на C++.

Для знакомства с форматом обучения и экспертами приходите на бесплатные демо-уроки:

  • 25 ноября в 19:00 — «Заглядывая за абстракцию памяти: базовый минимум для C++ разработчика». Записаться

  • 9 декабря в 20:00 — «Санитайзеры в С++». Записаться

  • 18 декабря в 20:00 — «Эволюция callable-объектов в C++. От указателей на функции к лямбда-выражениям». Записаться

Комментарии (1)


  1. Jijiki
    25.11.2025 17:10

    есть еще include <print> поидее квинтессенция формат и iostream через удобный интерфейс, очень удобно получается всё спрятали