Статическая рефлексия обсуждается в грядущем 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++. Информация в рефлексии может быть доступна во время компиляции, но мы можем сохранить её, чтобы получить к ней доступ позже во время выполнения.
Рефлексия для структур
Потребность в рефлексии структур ещё сильнее, чем перечислений. Рефлексия очень полезна при отладке или логировании, а сериализация и десериализация становятся простыми, когда есть рефлексия.
Существующие реализации
Я знаком в двумя существующими реализациями рефлексии.
…библиотека на 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/
Добавить комментарий