std::format
— очень полезное (и серьезное) нововведение C++20, позволяющее нам форматировать текст в строки удобным и эффективным образом. Оно привносит в язык форматирование в стиле Python в сочетании с безопасностью и простотой использования.
В этой статье я расскажу, как реализовать пользовательские средства форматирования (форматтеры) в соответствии с новой std::format
архитектурой.
Краткое введение в std::format
Давайте взглянем на этот Hello World:
#include <format>
#include <iostream>
#include <chrono>
int main() {
auto ym = std::chrono::year { 2022 } / std::chrono::July;
std::string msg = std::format("{:*^10}\n{:*>10}\nin{}!", "hello", "world", ym);
std::cout << msg;
}
Вы можете посмотреть этот код в Compiler Explorer.
Вывод:
**hello***
*****world
in2022/Jul!
Мы здесь видим местозаполнители для аргументов, которые разворачиваются и форматируются в объект std::string
. Кроме того, у нас есть различные спецификаторы для управления выводом (тип, длина, точность, заполняющие символы и т. д.). Мы также можем использовать пустой местозаполнитель {}
, который даст нам дефолтный вывод конкретного типа (к слову, поддерживается даже std::chrono
!). Позже мы можем вывести эту строку в объект-поток.
Подробнее о архитектуре и фичах вы можете узнать в этой статье.
Существующие форматтеры
По умолчанию std::format
поддерживает следующие типы:
char
,wchar_t
;строковые типы, включая
std::basic_string
,std::basic_string_view
, массивы символов и строковые литералы;арифметические типы;
указатели:
void*
,const void*
иnullptr_t
.
Это определено в стандарте formatter
, вы можете увидеть это в спецификации [format.formatter.spec]:
Давайте вызовем:
std::cout << std::format("10 = {}, 42 = {:10}\n", 10, 42);
Вызов создаст два форматтера, по одному для каждого аргумента. На них лежит задача распарсить спецификатор формата и отформатировать значение в вывод.
Специализации форматтеров:
template<> struct formatter<char, char>;
template<> struct formatter<char, wchar_t>;
template<> struct formatter<wchar_t, wchar_t>;
Все специализации для строковых типов и charT
:
template<> struct formatter<charT*, charT>;
template<> struct formatter<const charT*, charT>;
template<size_t N> struct formatter<const charT[N], charT>;
template<class traits, class Allocator>
struct formatter<basic_string<charT, traits, Allocator>, charT>;
template<class traits>
struct formatter<basic_string_view<charT, traits>, charT>;
Для каждого charT
, а также для каждого cv-неквалифицированного арифметического типа ArithmeticT, не являющегося char
, wchar_t
, char8_t
, char16_t
или char32_t
, есть специализация:
template<> struct formatter<ArithmeticT, charT>;
Специализации для типов указателей и charT
:
template<> struct formatter<nullptr_t, charT>;
template<> struct formatter<void*, charT>;
template<> struct formatter<const void*, charT>;
Например, если вы хотите вывести указатель:
int val = 10;
std::cout << std::format("val = {}, &val = {}\n", val, &val);
Этот код не будет работать, и вы получите ошибку компилятора (не самую лаконичную, но зато понятную):
auto std::make_format_args<std::format_context,int,int*>(const int &,int *const &)'
was being compiled and failed to find the required specializations (в процессе компиляции не смог найти нужных специализаций)...
Это потому, что мы пытались вывести int*
, но библиотека поддерживает только void*
. Мы можем это обойти, написав:
int val = 10;
std::cout << std::format("val = {}, &val = {}\n", val, static_cast<void*>(&val));
И тогда вывод будет (MSVC, x64, Debug):
val = 10, &val = 0xf5e64ff2c4
В библиотеке {fmt}
даже реализована такая вспомогательная функция, но в Стандарте, увы, нет.
template<typename T> auto fmt::ptr(T p) -> const void*
Хорошо, а что насчет пользовательских типов?
Для потоков мы можем переопределить оператор <<,
и такой код будет работать. Но так же ли это просто для std::format
?
Давайте разберемся.
Пользовательские форматтеры
С std::format
основная идея заключается в том, чтобы предоставить пользовательскую специализацию formatter‘а для вашего типа.
Чтобы создать форматтер, можно использовать следующий код:
template <>
struct std::formatter<MyType> {
constexpr auto parse(std::format_parse_context& ctx) {
return /* */;
}
auto format(const MyType& obj, std::format_context& ctx) {
return std::format_to(ctx.out(), /* */);
}
};
Вот основные требования к такому типу функций (из Стандарта):
Выражение |
Тип возврата |
Требование |
f.parse(pc) |
PC::iterator |
Парсит спецификаторы формата ([format.string]) для типа T в диапазоне [pc.begin( ), pc.end()] до первого несопоставленного символа. Выдает |
f.format(t, fc) |
FC::iterator |
Форматирует t в соответствии со спецификаторами, хранящимися в |
Тут больше кода, чем нам нужно было написать для оператора <<
, и все это звучит чуть более сложно, так что давайте попробуем разобраться с тем, что от нас требует Стандарт.
Одно значение
Для начала возьмем простой тип-обертку с одним значением:
struct Index {
unsigned int id_{ 0 };
};
И для него мы можем написать следующий форматтер:
template <>
struct std::formatter<Index> {
// только для дебагга
formatter() { std::cout << "formatter<Index>()\n"; }
constexpr auto parse(std::format_parse_context& ctx) {
return ctx.begin();
}
auto format(const Index& id, std::format_context& ctx) {
return std::format_to(ctx.out(), "{}", id.id_);
}
};
Пример использования:
Index id{ 100 };
std::cout << std::format("id {}\n", id);
std::cout << std::format("id duplicated {0} {0}\n", id);
Мы получим следующий вывод:
formatter<Index>()
id 100
formatter<Index>()
formatter<Index>()
id duplicated 100 100
Как видите, даже для дублируемого аргумента {0}
создаются два форматтера, а не один.
Функция parse()
принимает контекст и получает спецификатор формата для данного аргумента.
Например:
"{0}" // ctx.begin() указывает на `}`
"{0:d}" // ctx.begin() указывает на `d`, а begin-end — "d}"
"{:hello}" // ctx.begin() указывает на 'h', а begin-end — "hello}"
Функция parse()
должна вернуть итератор на закрывающую скобку, поэтому нам нужно найти ее или предположить, что она находится в позиции ctx.begin()
.
В случае {:hello}
возврат begin()
не будет указывать на }
и, таким образом, мы получите ошибку времени выполнения — будет сгенерировано исключение. Так что будьте внимательны!
Для простого случая с одним значением мы можем положиться на стандартную реализацию и попросту задействуем этот код:
template <>
struct std::formatter<Index> : std::formatter<int> {
auto format(const Index& id, std::format_context& ctx) {
return std::formatter<int>::format(id.id_, ctx);
}
};
Теперь наш код будет работать, распаршивая стандартные спецификаторы:
Index id{ 100 };
std::cout << std::format("id {:*<11d}\n", id);
std::cout << std::format("id {:*^11d}\n", id);
Вывод:
id 100********
id ****100****
Несколько значений
А что насчет случаев, когда мы хотели бы показать несколько значений:
struct Color {
uint8_t r{ 0 };
uint8_t g{ 0 };
uint8_t b{ 0 };
};
Чтобы создать создать форматтер, мы можем написать что-то вроде этого:
template <>
struct std::formatter<Color> {
constexpr auto parse(std::format_parse_context& ctx) {
return ctx.begin();
}
auto format(const Color& col, std::format_context& ctx) {
return std::format_to(ctx.out(), "({}, {}, {})", col.r, col.g, col.b);
}
};
Этот код поддерживает только фиксированный формат вывода и никаких дополнительных спецификаторов формата.
Однако мы можем задействовать уже готовый форматтер string_view
:
template <>
struct std::formatter<Color> : std::formatter<string_view> {
auto format(const Color& col, std::format_context& ctx) {
std::string temp;
std::format_to(std::back_inserter(temp), "({}, {}, {})",
col.r, col.g, col.b);
return std::formatter<string_view>::format(temp, ctx);
}
};
Нам не нужно реализовывать функцию parse()
с приведенным выше кодом. Внутри format()
мы выводим значения цвета во временный буфер, а затем повторно используем исходный форматтер для вывода окончательной строки.
Точно так же, если ваш объект содержит контейнер значений, вы можете написать следующий код:
template <>
struct std::formatter<YourType> : std::formatter<string_view> {
auto format(const YourType& obj, std::format_context& ctx) {
std::string temp;
std::format_to(std::back_inserter(temp), "{} - ", obj.GetName());
for (const auto& elem : obj.GetValues())
std::format_to(std::back_inserter(temp), "{}, ", elem);
return std::formatter<string_view>::format(temp, ctx);
}
};
Приведенный выше форматтер выведет obj.GetName()
, а затем элементы из obj.GetValues()
. Поскольку мы наследуемся от класса string_view
форматтера, здесь также применимы стандартные спецификаторы формата.
Расширение форматтера с помощью функции parse()
Но как насчет пользовательской функции для парсинга?
Основная идея заключается в том, что мы можем распарсить строку формата, а затем сохранить некоторое состояние в *this
, после чего мы можем использовать информацию в вызове format.
Давайте попробуем:
template <>
struct std::formatter<Color> {
constexpr auto parse(std::format_parse_context& ctx){
auto pos = ctx.begin();
while (pos != ctx.end() && *pos != '}') {
if (*pos == 'h' || *pos == 'H')
isHex_ = true;
++pos;
}
return pos; // В этой позиции ожидается `}`, иначе
// это ошибка! Генерируется исключение!
}
auto format(const Color& col, std::format_context& ctx) {
if (isHex_) {
uint32_t val = col.r << 16 | col.g << 8 | col.b;
return std::format_to(ctx.out(), "#{:x}", val);
}
return std::format_to(ctx.out(), "({}, {}, {})", col.r, col.g, col.b);
}
bool isHex_{ false };
};
И тест:
std::cout << std::format("col {}\n", Color{ 100, 200, 255 });
std::cout << std::format("col {:h}\n", Color{ 100, 200, 255 });
Вывод:
col (100, 200, 255)
col #64c8ff
Резюме
Чтобы обеспечить поддержку пользовательских типов в std::format
, мы должны реализовать специализацию для std::formatter
. Этот класс должен предоставлять функции parse()
и format()
. Первая отвечает за парсинг спецификатора формата и сохранение дополнительных данных в *this
, если это необходимо, а вторая выводит значения в выходной буфер out, предоставленный контекстом форматирования.
Хотя реализация форматтера может быть и сложнее, чем с оператором <<
, он дает нам больше возможностей и гибкости. В самых простых случаях мы также можем полагаться на наследование и повторное использование функционала уже существующих форматтеров.
Вы можете изучить код, используемый в этой статье в Compiler Explorer.
В Visual Studio 2022 версии 17.2 и Visual Studio 2019 версии 16.11.14 вы можете использовать std:c++20
, но в более ранних версиях используйте /std:latest
(поскольку он все еще находился в разработке). По состоянию на июль 2022 года GCC еще не реализовал эту фичу. Clang 14 имеет экспериментальную внутреннюю реализацию, но она еще не раскрыта.
Ссылки
Форматирование определяемых пользователем типов с помощью библиотеки {fmt} — во многих случаях мы можем просто заменить
fmt::
наstd::
, поэтому ознакомление с документацией по fmt не будет лишним.Внеземное руководство по форматированию текста в C++20 — C++ Stories
C++20 — Полное руководство от Nicolai M. Josuttis — трюки с наследованием существующих типов и повторным использованием их функционала форматирования.
MSVC’s STL Completes /std:c++20 - Блог команды разработчиков C++
Хоть и модно критиковать ООП-подход к разработке кода, он остаётся самым популярным во многих и многих сферах. Поэтому не знать и не уметь использовать данную парадигму разработки для настоящего профессионала просто не вежливо. Приглашаем всех желающих на открытое занятие «ООП глазами C++», на котором поговорим и посмотрим на примерах, как термины ООП реализуются в синтаксисе языка C++.
Комментарии (9)
Albert2009ru
07.09.2022 13:49Если я правильно понял, фичами из статьи можно пользоваться только в VS? В Eclipse даже с последним GCC пока ничего не получится? Я в этой области профан, так что прошу извинить, если что не так...
psycha0s
07.09.2022 13:57+3Можно использовать fmtlib с GCC, который умеет хотя бы C++11. Собственно эта библиотека и послужила основой
std::format
. fmtlib имеет некоторые отличия, но в целом очень похожа.PS: а кто-нибудь знает причину, почему
std::format
до сих пор отсутствует в реализации стандартной библиотеки от GCC?Albert2009ru
07.09.2022 14:23Ок. Спасибо за пояснения. Я именно стандартный GCC и имел ввиду. Но если что, буду пользоваться Вашим советом :)
KivApple
07.09.2022 14:23+2В последних примерах происходит временная аллокация памяти std::string, которая потом скопируется уже в правильное место. Интересно примеры форматирования нескольких значений сразу куда надо.
gru74ik
08.09.2022 01:48-1Штука хорошая, но под линуксами в стандартном виде в продакшене ещё года три такое не увидишь, если не дольше. Как и другие хорошие нововведения C++20, как те же модули.
IgorsVolohovs2003
08.09.2022 01:51-1А как же перегрузка операторов? Какие плюсы и минусы format перед операторами. А то я не совсем вникаю зачем это придумали...
BirdShapely
08.09.2022 02:05+2божечки, кто то еще работает со строчками в с++? Долго еще будет эта разножопица с char/wchar?…
sergegers
А как насчёт переиспользования манипуляторов?