Статическая рефлексия в C++

от автора

Статическая рефлексия обсуждается в грядущем C++26. Wu Yongwei демонстрирует, как применять рефлексию уже сейчас, и показывает примеры того, что может быть станет возможным в C++26.

Статическая рефлексия станет важной частью генерации программы на C++ во время компиляции, как я рассказывал в октябрьском выпуске Overload. Здесь мы детально рассмотрим статическую рефлексию, включая то, как эмулировать её прямо сейчас до того, как она будет добавлена в Стандарт.

Истоки

Многие языки программирования поддерживают рефлексию (например, Python и Java). C++ уступает им.

Сейчас это так, но в C++26 всё, вероятно, изменится. И то, что станет доступно в C++, будет сильно отличаться от того, что есть в таких языках, как Java или Python. Ключевое отличие — слово «статическая».

Andrew Sutton определяет «статическую рефлексию» так:

Статическая рефлексия — это неотъемлемая способность метапрограммы наблюдать свой собственный код и, в какой-то степени, генерировать новый код во время компиляции.

«Время компиляции» — это особенность C++, позволяющая нам делать вещи, невозможные в других языках:

  • Абстракция без издержек. Как сказал Бьярне Страуструп: «Вы не платите за то, чем не пользуетесь. То, что вы используете, вряд ли закодируете вручную лучше.» Наличие статической рефлексии в языке не сделает вашу программу жирнее или медленнее, если она вам не нужна. Но она будет у вас под рукой, когда она вам понадобится.

  • Высокая производительность. Благодаря рефлексии во время компиляции можно достичь нераспределённой производительности, недостижимой в таких языках, как Java или Python.

  • Универсальность как во время компиляции, так и во время выполнения. Информация, доступная во время компиляции, может быть использована программой во время выполнения, но не наоборот. Статическая рефлексия C++ может делать вещи, которые возможны в таких языках, как Java, и есть вещи в С++, которые просто невозможны в других языках.

Что нам нужно от рефлексии

Когда мы говорим о статической рефлексии, чего мы на самом деле желаем? Мы действительно хотим знать то, что видит компилятор, и мы хотим иметь возможность пользоваться этой информацией в коде. Наиболее яркими примерами являются пользовательские типы enum и struct. Мы хотим иметь возможность перебирать все константы в перечислении и знать их имена и значения. Мы хотим иметь возможность перебирать все поля структуры и знать их имена и типы. Разумеется, что когда поле является перечислением или структурой, нам также нужна возможность рекурсивной рефлексии. И так далее.

К сожалению, сегодня мы не можем всё это делать с помощью доступных «стандартных» инструментов. Конечно, в некоторых реализациях можно хакнуть часть информации с помощью различных трюков. Я бы предпочёл пользоваться макросами и шаблонными методами для достижения этой цели, так как с ними код несколько аккуратнее, более переносим и более удобен в обслуживании, чем то же самое, но с использованием нестандартных синтаксисов. Конечно, всё это не сравнится с прямой поддержкой будущего стандарта C++.

Несколько слов о технике с макросами

За много лет я собрал коллекцию полезных макросов, начиная с работ Netcan. Ключевые макросы:

  • GET_ARG_COUNT: взять количество переменных, GET_ARG_COUNT(a, b, c) развернётся в 3.

  • REPEAT_ON: передать аргументы макросу (со счетчиком), REPEAT_ON(func, a, b, c) станет func(0, a) func(1, b) func(2, c).

  • PAIR: удалить первую пару скобок из аргумента, PAIR((long)v1) станет long v1.

  • STRIP: удалить первую часть в скобках, STRIP((long)v1) станет v1.

Некоторые идеи возникли примерно в начале 2012 года, но тот код Paul Fultz не подходил для реальных программных проектов. Мой текущий код следует считать готовым к использованию, его модификации уже использовались в некоторых крупных приложениях. Он также был протестирован для всех основных компиляторов, включая нестандартный MSVC (поддержка старого MSVC потребовала некоторых усилий). Вы можете найти мои макросы в проекте Mozi с открытым исходным кодом.

Многие считают макросы адом, и их действительно следует избегать, если можно найти лучшие альтернативы, но лично я считаю, что макросы проще понять и поддерживать, чем некоторые шаблонные техники.

Начнём с рефлексии перечислений

Часто желательно знать количество величин определенных в перечислениях, какой целочисленный тип лежит в их основе и их строковые формы. Последние особенное важны для отладки и логирования.

Существующие реализации

Существуют библиотеки, предоставляющие необходимые возможности, например Magic Enum C++ и Better Enums.

Magic Enum C++ требует новейшего компилятора C++17 и работает со стандартной формой определения перечисления. Однако, поскольку он использует методы времени компиляции для поиска значений перечислений, диапазон перечислений ограничен. Кроме того, он плохо уживается с числовыми значениями перечисления, которые не объявлены в определении (например, что-то вроде Color{100}) — обращение к magic_enum::enum_name для такого значения даст пустой string_view. Тем не менее, я рекомендую использовать его, если он подходит вашим потребностям.

Better Enums работает практически с любым компилятором, даже со старыми C++98. Однако он требует применения специальных правил для определения перечисления. Это само по себе уродливо, но приемлемо. Еще уродливее то, что результат не является типом перечисления, и он не может ужиться со значениями, которые вообще не объявлены в определении перечисления — преобразование такого значения в строку вызовет ошибку сегментации…

Моя собственная реализация

Главным образом для того, чтобы лучше понять проблему, я попробовал сам сделать рефлексию перечислений. Я достиг следующего:

  • Результат генерации кода по-прежнему является перечислением

  • Обеспечение отображения именованных констант перечисления в их строковые формы с помощью инлайн-переменных constexpr

  • Поддержка необходимых операций с использованием перегруженных функций, таких как to_string

Пример определения enum class:

  DEFINE_ENUM_CLASS(Color, int,                     red = 1, green, blue);

Может быть использован так:

  cout << to_string(Color::red) << '\n';   cout << to_string(Color{9}) << '\n';

Что выдаст результат:

  red   (Color)9

Некоторые детали реализации

Хотя вы можете взять детали реализации из проекта Mozi, я хотел бы показать, что делает DEFINE_ENUM_CLASS. Его определение:

#define DEFINE_ENUM_CLASS(e, u, ...)       \   enum class e : u { __VA_ARGS__ };        \   inline constexpr std::array<             \     std::pair<u, std::string_view>,        \     GET_ARG_COUNT(__VA_ARGS__)>            \     e##_enum_map_{REPEAT_FIRST_ON(         \       ENUM_ITEM, e, __VA_ARGS__)};         \   ENUM_FUNCTIONS(e, u)

Вы можете увидеть, что он выполняет три действия:

  • Определяет стандартный enum class

  • Определяет инлайновый constexpr-массив, который содержит пары значений базового целочисленного типа (underlying integer) и строковые формы значений, которые генерируются путем применения макроса ENUM_ITEM к значениям перечисления

  • Объявляет вспомогательные функции для нового перечисления enum

После определения Color выше, он разворачивается на первом уровне до

enum class Color : int { red = 1, green, blue }; inline constexpr std::array<   std::pair<int, std::string_view>, 3>   Color_enum_map_{     ENUM_ITEM(0, Color, red = 1),     ENUM_ITEM(1, Color, green),     ENUM_ITEM(2, Color, blue),   }; ENUM_FUNCTIONS(Color, int)

Полное разворачивание приводит к чему-то вроде

enum class Color : int { red = 1, green, blue }; inline constexpr std::array<   std::pair<int, std::string_view>, 3>   Color_enum_map_{     std::pair{       to_underlying(Color(         (eat_assign<Color>)Color::red = 1)),       remove_equals("red = 1")},     std::pair{       to_underlying(         Color((eat_assign<Color>)Color::green)),       remove_equals("green")},     std::pair{to_underlying(Color((                 eat_assign<Color>)Color::blue)),               remove_equals("blue")},   }; inline std::string to_string(Color value) {   return enum_to_string(to_underlying(value),                         "Color",                         Color_enum_map_.begin(),                         Color_enum_map_.end()); }

Этого должно быть достаточно, чтобы вы увидели основные идеи. Подробности реализации вы можете посмотреть в проекте Mozi, если вам интересно.

Пример рефлексии перечисления в C++26

Этот код должен работать в соответствии с рассматриваемым предложением по статической рефлексии для C++26 P2996.

template <typename E>   requires std::is_enum_v<E> std::string to_string(E value) {   template for (constexpr auto e :                 std::meta::enumerators_of(^E)) {     if (value == [:e:]) {       return std::string(         std::meta::identifier_of(e));     }   }   return std::string("(") +          std::meta::identifier_of(^E) + ")" +          std::to_string(to_underlying(value)); }

Он использует следующие новые конструкции для рефлексии:

  • ^E генерирует информацию для рефлексии перечисления E.

  • [:e:] «встраивает» в код специальный объект рефлексии, который здесь является значением константы перечисления.

  • Специальный цикл template for позволяет итерироваться по разнородным объектам во время компиляции.

  • std::meta::enumerators_of получает все значения перечисления.

  • std::meta::identifier_of получает идентификатор/имя объекта. Здесь мы используем его дважды, один раз — для имени константы и другой раз — для имени перечисления.

Этот пример делает то же самое, что и мой самодельный to_string, но без костылей: макросы больше не нужны.

Реализация более раннего предложения P2320, доступная в Compiler Explorer, удобна для демонстрационных целей. Очевидные различия между P2996 и P2320 — это имена функций: enumerators_of было members_of, а identifier_of было name_of. Существуют и другие компиляторы с поддержкой рефлексии на Godbolt, которые пока недостаточно эффективны, в основном из-за отсутствия поддержки операторов разворачивания. Я написал два разных примера рефлексии enum, которые работают под P2320:

  • https://cppx.godbolt.org/z/8rWTcf1KP: Простой пример, который выполняет линейный поиск, как показано выше.

  • https://cppx.godbolt.org/z/P5Ycdv3xj: Более сложный пример, который собирает строковые формы констант в перечислении и сортирует их, чтобы мы могли использовать двоичный поиск (похоже на то, что я делал в Mozi.)

Как вы можете видеть, не смотря на то, что реализация полной логики все еще не является тривиальной, главное преимущество заключается в том, что мы можем определять перечисления в стандартной форме без ограничений в Magic Enum C++. Информация в рефлексии может быть доступна во время компиляции, но мы можем сохранить её, чтобы получить к ней доступ позже во время выполнения.

Рефлексия для структур

Потребность в рефлексии структур ещё сильнее, чем перечислений. Рефлексия очень полезна при отладке или логировании, а сериализация и десериализация становятся простыми, когда есть рефлексия.

Существующие реализации

Я знаком в двумя существующими реализациями рефлексии.

Boost.PFR:

…библиотека на C++14 для очень простой рефлексии, которая даёт вам доступ к элементам структуры по индексу и предоставляет другие методы, подобные тем, что есть для std::tuple, для пользовательских типов без использования каких-либо макросов или повторяющегося кода.

Она проста в использовании. Поддерживает общие операции, такие как итерация, сравнение и вывод. Однако из-за отсутствия статической рефлексии она не имеет возможности доступа к именам полей.

Struct_pack — это «очень простая в использовании, высокопроизводительная библиотека сериализации.» Она требует C++17 и специализируется на сериализации/десериализации. В целом, она не предназначена для рефлексии, и вы не можете использовать её для собственных сценариев рефлексии (без серьёзных хаков).

Хотя это и не настоящая реализация, самый ранний известный мне код для рефлексии структур принадлежит Paul Fultz. Современные возможности времени компиляции еще не были готовы в 2012 году, поэтому, хотя основные идеи были похожи, Netcan и я не заимствовали много кода оттуда.

Моя собственная реализация

У меня есть собственная реализация для рефлексии структур, которая не имеет ограничений Boost.PFR, но использует макросы. Однако, как только статическая рефлексия будет стандартизирована, большую часть кода можно будет адаптировать к стандартному C++.

Основной подход состоит из:

  • Использование макросов для генерации кода, чтобы результирующий тип действительно представлял собой структуру предполагаемого размера (не больше!)

  • Генерирование вложенных типов и статических constexpr полей, которые предоставляют необходимую информацию

  • Предоставление независимых/внешних шаблонов функций для общих операций.

Вот пример. Предположим, у нас есть следующие определения структур:

  DEFINE_STRUCT(     Point,     (double)x,     (double)y   );   DEFINE_STRUCT(     Rect,     (Point)p1,     (Point)p2,     (uint32_t)color   );

Тогда мы можем инициализировать такие структуры как обычно:

  Rect rect{     {1.2, 3.4},     {5.6, 7.8},     12345678   };

Легко выводить на печать

 print(data);

И получать

  {       p1: {           x: 1.2,           y: 3.4       },       p2: {           x: 5.6,           y: 7.8       },       color: 12345678   }

Сценарий использования: копирование одноименных полей

Детали реализации могут быть неинтересными, но у нас есть более интересные сценарии использования. Одна вещь, которую я реализовал, — это копирование пересекающихся полей.

Предположим, что даны следующие определения структур (обратите внимание, что v2 и v4 имеют разные типы в S1 и S2):

  DEFINE_STRUCT(S1,     (uint16_t)v1,     (uint16_t)v2,     (uint32_t)v3,     (uint32_t)v4,     (string)msg   );      DEFINE_STRUCT(S2,     (int)v2,     (long)v4   );      S1 s1{ /* ... */};   // ...   S2 s2;

Затем, такое выражение будет делать правильную вещь:

  copy_same_name_fields(s1, s2);

И это будет сделано с максимально возможной эффективностью, эквивалентной s2.v2 = s1.v2; s2.v4 = s1.v4;. Я проверил его ассемблерный код x86-64, сгенерированный компилятором, который получился таким:

  movzx   eax, WORD PTR s1[rip+2]   mov     DWORD PTR s2[rip], eax   mov     eax, DWORD PTR s1[rip+8]   mov     QWORD PTR s2[rip+8], rax

Я не думаю, что Java или Python когда-либо смогут сделать что-то подобное!

Если это не выглядит полезным, просто подумайте о больших записях в базах данных. Представьте, что у нас есть контейнер больших объектов BookInfo, и мы хотим сделать что-то вроде SELECT name, publish_year WHERE author_id = … на SQL. Код мог бы быть таким:

DEFINE_STRUCT(   BookInfoNameYear,   (string)name,   (int)publish_year );  BookInfoNameYear record{}; vector<BookInfoNameYear> result; Container<BookInfo> container;  while (/* ... */) {   auto it = container.find(/* ... */);   // ...   copy_same_name_fields(*it, record);   result.push_back(record); }

Этот код намного проще, не так ли, при этом, столь же эффективен, как ручное копирование нужных полей. Преимущество особенно очевидно, когда таких полей много.

Я видел подобное копирование десятков полей в реальном коде, часто сопровождаемое сериализацией (для отправки информации по сети), эту тему я рассмотрю отдельно.

Технические детали

DEFINE_STRUCT определена так:

  #define DEFINE_STRUCT(st, ...)                 \     struct st {                                  \       using is_reflected = void;                 \       template <typename, size_t>          \       struct _field;                             \       static constexpr size_t _size =            \         GET_ARG_COUNT(__VA_ARGS__);              \       REPEAT_ON(FIELD, __VA_ARGS__)              \     }

s2 из примера выше развернётся в:

  int v2;   template <typename T>   struct _field<T, 0> {     using type = decltype(decay_t<T>::v2);     static constexpr auto name = CTS_STRING(v2);     constexpr explicit _field(T&& obj)       : obj_(std::forward<T>(obj)) {}     constexpr decltype(auto) value()     { return (std::forward<T>(obj_).v2); }     T&& obj_;   };

Я оставляю CTS_STRING(v2) неразвёрнутым, потому что у него есть два возможных определения в зависимости от среды. Пока что вы можете думать о нём просто как о «v2» с некоторой дополнительной магией (которую требует copy_same_name_fields.)

Если у вас есть obj типа S2, вы можете получить доступ к его полям, используя номера их полей: _field(obj).value() — это в точности obj.v2 (с правильной категорией значения), а S2::_field::type — это тип obj.v2 (который является int). С помощью выражений свёртки теперь возможны более сложные вещи, такие как итерация полей во время компиляции, как показано ниже:

template <size_t I, typename T> constexpr decltype(auto) get(T&& obj) {   using DT = decay_t<T>;   static_assert(I < DT::_size,                 "Index to get is out of range");   return typename DT::template _field<T, I>(            std::forward<T>(obj))     .value(); } template <typename T, typename F, size_t... Is> constexpr void for_each_impl(T&& obj, F&& f,               std::index_sequence<Is...>) {   using DT = decay_t<T>;   (void(std::forward<F>(f)(      index_t<Is>{},      DT::template _field<T, Is>::name,      get<Is>(std::forward<T>(obj)))),    ...); } template <typename T, typename F> constexpr void for_each(T&& obj, F&& f) {   using DT = decay_t<T>;   for_each_impl(     std::forward<T>(obj), std::forward<F>(f),     std::make_index_sequence<DT::_size>{}); }

Теперь такой вызов функции как for_each(obj, f) будет эквивалентен:

  f(0, S2::_field<S2&, 0>::name, get<0>(obj));   f(1, S2::_field<S2&, 1>::name, get<1>(obj));

Такие возможности, как for_each, необходимы для реализации пользовательских инструментов подобных печати и сериализации.

Пример рефлексии структуры в C++26

Как и в случае с рефлексией перечисления, мы сможем обойтись без макросов, когда появится статическая рефлексия в C++26. Демонстрационная реализация print (слегка изменённая по сравнению с примером из C++ Compile-Time Programming для соответствия обновлённой версии предложения P2996):

template <typename T> void print(const T& obj, ostream& os = cout,            std::string_view name = "",            int depth = 0) {   if constexpr (is_class_v<T>) {     os << indent(depth) << name        << (name != "" ? ": {\n" : "{\n");     template for (constexpr meta::info member :         meta::nonstatic_data_members_of(^T)) {       print(obj.[:member:], os,             meta::identifier_of(member),             depth + 1);     }     os << indent(depth) << "}"        << (depth == 0 ? "\n" : ",\n");   } else {     os << indent(depth) << name << ": " << obj        << ",\n";   } }

Уже зная, что такое ^T и [:member:] , показанный код — достаточно простой. Вот некоторые ключевые моменты (обратите внимание, что синтаксис может быть изменён до окончательного принятия в C++26):

  • ^T — это предлагаемый синтаксис для получения объекта рефлексии (неопределённого типа) во время компиляции.

  • [:expr:] — это обращение объекта рефлексии обратно в тип C++ или выражение и встраивание его в код; [:^T:] даёт нам T в коде.

  • template for — это цикл времени компиляции для итерации по объектам во время компиляции, предложение P1306R2, устраняющий необходимость в универсальных лямбда‑выражениях и for_each.

  • Пространство имён std::meta предоставляет инструменты для работы с объектом рефлексии во время компиляции:

    • info — обобщённый объект рефлексии.

    • members_of извлекает вектор рефлексий всех полей типа или пространства имён.

    • nonstatic_data_members_of извлекает нестатические поля данных.

    • identifier_of получает имя рефлексируемого объекта.

Мы можем увидеть рабочий код для предложений P2320 (https://cppx.godbolt.org/z/G3EcvhKxK) и P2996 с обходным решением с помощью оператора разворачивания expand (https://godbolt.org/z/631Tebb91).

Несколько слов о Mozi

Mozi — это проект с открытым исходным кодом, который я начал в конце 2023 года, в основном для экспериментов со статической рефлексией на основе макросов. Я реализовал общее сравнение, копирование, печать и сериализацию/десериализацию. Реализован сценарий сериализации под названием net_pack, который включает полностью автоматическую замену порядка байтов и подходит для работы с сетевыми датаграммами. Для поддержки битовых полей по сети предусмотрен специальный тип bit_field.

Я рассматриваю это как демонстрацию некоторых интересных вещей, которые возможны со статической рефлексией. То, что в настоящее время реализуется с помощью макросов, будет возможно со статической рефлексией в C++26, только это будет проще, как для программиста, так и для пользователя.


ссылка на оригинал статьи https://habr.com/ru/articles/870750/


Комментарии

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

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