[Перевод] Статическая рефлексия в 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, \
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, 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, 3>
Color_enum_map_{
std::pair{
to_underlying(Color(
(eat_assign)Color::red = 1)),
remove_equals("red = 1")},
std::pair{
to_underlying(
Color((eat_assign)Color::green)),
remove_equals("green")},
std::pair{to_underlying(Color((
eat_assign)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
requires std::is_enum_v
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 result;
Container 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 \
struct _field; \
static constexpr size_t _size = \
GET_ARG_COUNT(__VA_ARGS__); \
REPEAT_ON(FIELD, __VA_ARGS__) \
}
s2
из примера выше развернётся в:
int v2;
template
struct _field {
using type = decltype(decay_t::v2);
static constexpr auto name = CTS_STRING(v2);
constexpr explicit _field(T&& obj)
: obj_(std::forward(obj)) {}
constexpr decltype(auto) value()
{ return (std::forward(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
constexpr decltype(auto) get(T&& obj)
{
using DT = decay_t;
static_assert(I < DT::_size,
"Index to get is out of range");
return typename DT::template _field(
std::forward(obj))
.value();
}
template
constexpr void
for_each_impl(T&& obj, F&& f,
std::index_sequence)
{
using DT = decay_t;
(void(std::forward(f)(
index_t{},
DT::template _field::name,
get(std::forward(obj)))),
...);
}
template
constexpr void for_each(T&& obj, F&& f)
{
using DT = decay_t;
for_each_impl(
std::forward(obj), std::forward(f),
std::make_index_sequence{});
}
Теперь такой вызов функции как for_each(obj, f)
будет эквивалентен:
f(0, S2::_field::name, get<0>(obj));
f(1, S2::_field::name, get<1>(obj));
Такие возможности, как for_each
, необходимы для реализации пользовательских инструментов подобных печати и сериализации.
Пример рефлексии структуры в C++26
Как и в случае с рефлексией перечисления, мы сможем обойтись без макросов, когда появится статическая рефлексия в C++26. Демонстрационная реализация print
(слегка изменённая по сравнению с примером из C++ Compile-Time Programming для соответствия обновлённой версии предложения P2996):
template
void print(const T& obj, ostream& os = cout,
std::string_view name = "",
int depth = 0)
{
if constexpr (is_class_v) {
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, только это будет проще, как для программиста, так и для пользователя.
Habrahabr.ru прочитано 4950 раз