Рефлексия в C++Next на практике

Определение понятия «рефлексия» из Википедии:

In computer science, reflective programming or reflection is the ability of a process to examine, introspect, and modify its own structure and behavior.

В последние годы разрабатываются варианты ввода рефлексии в стандарт C++.

В этой статье мы напишем код на C++ с рефлексией для решения разных задач, скомпилируем и запустим его на форке компилятора с рабочей реализацией рефлексии.

Рефлексия в других языках

Во многих других языках, активно использующихся для бэкенда, рефлексия очень мощная. Пара примеров:

В языке Python в run-time можно получить класс объекта; имя класса; все его методы и аттрибуты; добавить методы и аттрибуты в класс; и так далее. По большому счету, каждый объект и класс это dict (с синтаксическим сахаром), который можно изменять как угодно.

В языке Java в run-time также можно получить класс объекта; его поля, методы, константы, конструкторы, суперклассы; получать и устанавливать значение поля по его имени; вызывать метод объекта по имени; и так далее. Информация о классах находится в памяти Java Virtual Machine.

Действия, описанные выше — как раз то, что обычно подразумевается под словом «рефлексия».

Эрзац-рефлексия в C++

В C++ постепенно добавлялись некоторые магические кусочки «языкознания», с помощью которых можно получить часть информацию о коде — например std: is_enum (compile-time), typeid (run-time).

Это можно отнести к рефлексии, но функционал спартанский и для великих свершений не годен. История знает разного рода приспособления для уменьшения боли.

Кодогенерация по описанию типа данных

К этому типу принадлежит protobuf — модный «носитель» данных.

В .proto-файле описывается структура данных (message Person), по которой кодогенератор для C++ может создать соответствующий ей класс (class Person) с геттерами/сеттерами, и возможностью сериализовать эти данные без копипаста имени каждого метода.

Сериализовать объект класса можно в бинарное представление (оптимальный путь, для передачи по сети), или в человекочитаемое представление (например для логирования).

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

Адские макросы и шаблоны

К этому типу принадлежит библиотека Boost.Hana. Для нее нужно описывать структуру нужным образом:

struct Person {
  BOOST_HANA_DEFINE_STRUCT(Person,
    (std::string, name),
    (int, age)
  );
};

Макрос «раскроется» и все сгенерирует. Похоже на «демосцену» — выжимают максимум возможностей из инструмента, который не был для этого предназначен.

Экстремальный залаз в компилятор

Интересные вещи можно сделать, проанализировав исходный код.

Некоторые инструменты (кодогенераторы/чекеры/etc.) создаются как «плагин» к используемому компилятору. Например, чтобы работать с исходниками на уровне AST (абстрактного синтаксического дерева), можно использовать cppast.

AST это промежуточный вариант между исходным кодом и ассемблером. К нему надо привыкнуть, но это проще, чем писать самодельный парсер кода на C++. Если кто-то смотрел исходники GCC или Clang, тот знает, что с нуля написать парсер малореально.

Особенности рефлексии в C++

В отличие от многих других языков, где с рефлексией работают в run-time, дух C++ требует сделать рефлексию в compile-time.

Так как язык старается соответствовать принципу «don«t pay for what you don«t use», то ~95% всей информации из исходников в рантайме просто испаряется. В языке не существует теоретической возможности сделать рефлексию в рантайме без раздувания бинаря чем-нибудь навроде RTTI (с многократно большим объемом).

C++ можно рассматривать как сборник из «под-языков», работающих на разных «уровнях». Условное деление:

  • Собственно C++: работа с памятью, объектами, потоками (и вообще с интерфейсом ОС), манипуляция данными. Работает в run-time.

  • Шаблоны: обобщенное программирование в исходниках. Работает (вычисляется) в compile-time.

  • Constexpr-вычисления: это «интерпретируемое» подмножество «Собственно C++», от года в год расширяется. Подробнее о них можно прочитать в моей прошлой статье. Вычисляется в compile-time прямо внутри компилятора.

  • Препроцессор: работает с токенами (отдельными словами) исходников. С C++ имеет очень посредственную связь, абсолютно такой же препроцессор могли бы сделать для Rust/Java/C#/etc. Единственный из «под-языков» не тьюринг-полный. Работает в compile-time.

Делать рефлексию в виде препроцессорных директив бессмыслено из-за отсутствия тьюринг-полноты. Остаются только шаблоны или constexpr-вычисления.

Сначала рефлексию планировали ввести в шаблонной парадигме, сейчас планируют ввести в constexpr-парадигме (так как возможности constexpr значительно расширились).

Я приведу примеры обеих подходов, и где можно скомпилировать код с их использованием.

Рефлексия на шаблонах

Основной источник информации про рефлексию на шаблонах — pdf-ка Reflection TS, более короткое объяснение есть на cppreference.com.

Свой код с использованием рефлексии можно скомпилировать на godbolt.org, выбрав компилятор x86-64 clang (reflection).

Вводится оператор reflexpr(X), которому можно «скормить» вместо X что угодно: тип, выражение, имя переменной, вызов метода, и т.д.

Этот оператор вернет так называемый meta-object type (далее — магический тип»), который для нас будет являться безымянным incomplete классом. Пример кода:

enum Color {
    Red, Green, Blue
};

using MetaT = reflexpr(Color);

Этот класс будет удовлетворять некоторому множеству концептов (в Reflection TS есть таблица концептов).

Например, MetaT удовлетворяет концепту reflect::Enum, и не удовлетворяет reflect::Variable — ссылка на код с проверкой.

Работа происходит с помощью «трансформаций» одних магических типов в других. Список доступных трансформаций зависит от того, каким концептам удовлетворяет исходный тип. Например, Reflection TS определяет такой шаблон, доступный только удовлетворяющим reflect::Enum магическим типам:

template  struct get_enumerators;

// и его short-hand
template 
using get_enumerators_t = typename get_enumerators::type;

Таким образом, трансформация get_enumerators_t скомпилируется. С ее помощью мы получим другой магический тип, на этот раз удовлетворяющий концепту reflect::ObjectSequence.

Выведем название первого элемента enum Color спустя несколько трансформаций:

int main() {
    constexpr std::string_view name = get_name_v>>;
    std::cout << "The name of the first value is \"" << name << "\"" << std::endl;
}

Ссылка на код.

Основная претензия к шаблонному подходу — неочевидность, как надо писать код. Мы хотим написать цикл по ObjectSequence? Обычным for-ом это сделать нельзя, есть только размер последовательности и получение элемента из него, и некий unpack_sequence:

template  struct get_size;
template  struct get_element;
template