Пользовательские типы и std::format в C++20

от автора

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()] до первого несопоставленного символа. Выдает format_error, если только не распаршен весь диапазон или несопоставленный символ не “}”. Примечание: позволяет форматтерам выдавать осмысленные сообщения об ошибках. Сохраняет распаршенные спецификаторы формата в *this и возвращает итератор за концом распаршенного диапазона.

f.format(t, fc)

FC::iterator

Форматирует t в соответствии со спецификаторами, хранящимися в *this, записывает вывод в fc.out() и возвращает итератор за концом диапазона вывода. Вывод должен зависеть только от t, fc.locale() и диапазона [pc.begin(), pc.end()] последнего вызова f.parse(pc).

Тут больше кода, чем нам нужно было написать для оператора <<, и все это звучит чуть более сложно, так что давайте попробуем разобраться с тем, что от нас требует Стандарт.

Одно значение     

Для начала возьмем простой тип-обертку с одним значением:

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 имеет экспериментальную внутреннюю реализацию, но она еще не раскрыта.

Ссылки


Хоть и модно критиковать ООП-подход к разработке кода, он остаётся самым популярным во многих и многих сферах. Поэтому не знать и не уметь использовать данную парадигму разработки для настоящего профессионала просто не вежливо. Приглашаем всех желающих на открытое занятие «ООП глазами C++», на котором поговорим и посмотрим на примерах, как термины ООП реализуются в синтаксисе языка C++.

Регистрация на занятие.


ссылка на оригинал статьи https://habr.com/ru/company/otus/blog/686900/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *