Рефлексия в 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 <Enum T> struct get_enumerators;  // и его short-hand template <Enum T> using get_enumerators_t = typename get_enumerators<T>::type;

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

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

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

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

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

template <ObjectSequence T> struct get_size; template <size_t I, ObjectSequence T> struct get_element; template <template <class...> class Tpl, ObjectSequence T>   struct unpack_sequence;

Если мы хотим сделать такую элементарную задачу, как по значению enum-а получить его строковое представление, надо написать какую-то жуть, в которой совсем ничего не понятно — ссылка на код в gist.

Рефлексия в constexpr

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

Основные источники информации про текущий вариант рефлексии — документ P2320, видео-выступление Andrew Sutton на ютубе, и частично Wiki в гитхабе реализации.

Рефлексия вводится в виде оператора ^X перед рефлексируемой сущностью X. Применение оператора создаст constexpr-объект типа std::experimental::meta::info.

После манипуляций с объектом (которые должны происходить в compile-time) можно «вернуть» его в «реальный» мир через оператор [:X:] (называется «splice»). Запись [:^X:] практически эквивалентна X.

Andrew Sutton в видео приводит игрушечный пример с созданием объекта типа T****...* (количество звёздочек равно N). Вот так можно сделать через шаблоны:

template<typename T, int N> auto make_indirect_template() {     if constexpr (N == 0) {         return T{};     } else {         return make_indirect_template<T*, N - 1>();     } }

А вот так можно сделать через рефлексию:

consteval meta::info make_pointer(meta::info type, int n) {     for (int i = 0; i < n; ++i) {         type = meta::add_pointer(type);     }     return type; }  template<typename T, int N> auto make_indirect_reflective() {     return typename [:make_pointer(^T, N):]{}; }

Код внутри consteval-методов выполняется только в compile-time. Все consteval-методы после компиляции «испаряются», то есть их код в бинарнике отсутствует.

Можно вывести имя получившихся типов:

int main() {     auto ptr1 = make_indirect_template<int, 42>();     std::cout << meta::name_of(meta::type_of(^ptr1)) << std::endl;      auto ptr2 = make_indirect_reflective<int, 42>();     std::cout << meta::name_of(meta::type_of(^ptr2)) << std::endl; }

Компиляция на godbolt

Соглашение о записи операторов

Записи операторов ^X и [:X:] могут не пройти проверку временем и видоизмениться к момента входа в стандарт. Но это будут взаимозаменяющие записи.

Ранее вместо ^X был reflexpr(X), вместо [:X:] был unreflexpr(X).

На данный момент текущая запись является «официальной», что можно увидеть в github-тикете про P2320.

Компиляция и запуск

Свой код с использованием рефлексии можно запустить на cppx.godbolt.com, выбрав компилятор p2320 trunk.

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

Сборка с использованием docker-образа

docker-образ был создан по этому Dockerfile, собирал ветку paper/p2320.

Образ можно загрузить:

docker pull sehnsucht88/clang-p2320

Пусть ваш исходник code.cpp находится в директории /home/username/cpp, тогда запускать можно так:

docker run --rm -v /home/username/cpp:/cpp sehnsucht88/clang-p2320 -std=c++2a -freflection -stdlib=libc++ /cpp/code.cpp -o /cpp/bin

После компиляции в директории /home/username/cpp будет лежать запускаемый бинарник bin

На случай удаления репозитория я сделал форк — https://github.com/Izaron/meta.

Рефлексия на практике

Теперь попробуем написать что-то рефлексивное.

Значение enum-а в строковом представлении

В отличие от «рефлексии на шаблонах», в «рефлексии на constexpr» это сделать намного проще. Пример кода (немного изменил код из видео Andrew Sutton):

template<typename T> requires std::is_enum_v<T> constexpr std::string_view to_string(T value) {     template for (constexpr meta::info e : meta::members_of(^T)) {         if ([:e:] == value) {             return meta::name_of(e);         }     }     throw std::runtime_error("Unknown enum value"); }

template for — это фича, которая не успела войти в стандарт C++20. В нашем случае она раскрывает range методом копипаста. Пусть у нас такой enum:

enum LightColor { Red, Green, Blue };

Тогда метод раскроется в такой вид:

template<> constexpr std::string_view to_string<LightColor>(T value) {     { if (Red == value) return "Red"; }     { if (Green == value) return "Green"; }     { if (Blue == value) return "Blue"; }     throw std::runtime_error("Unknown enum value"); }

Аналогично можно сделать метод, который по строковому представлению вернет значение enum-а

Исходник from_string
template<typename T> requires std::is_enum_v<T> constexpr std::optional<T> from_string(std::string_view value) {     template for (constexpr meta::info e : meta::members_of(^T)) {         if (meta::name_of(e) == value) {             return [:e:];         }     }     return {}; }

Компиляция на godbolt

Проверка функций на internal linkage

Можно реализовать проверку на отсутствие видимых «снаружи» (вне единицы трансляции) методов с помощью вызова meta::is_externally_linked.

Небольшое отступление — в форке компиляции доступно несколько вспомогательных методов, работающих в compile-time:

  • __reflect_dump — принимает meta::info, выведет в терминал AST соответствующей ему сущности.

  • __compiler_error — принимает строку, завершает компиляцию ошибкой с выводом данной строки.

  • __concatenate — соединяет несколько строковых литералов в один.

Первые два метода нужны для удобства разработки compile-time кода. Третий метод нужен, потому что std::string в compile-time пока еще нет в стандарте (но когда-то будет).

Про meta::info есть один факт — в некоторых случаях мы не можем написать метод так:

consteval void foo(meta::info r) { /* ... */ }

потому что компилятор думает, что meta::info протекает в run-time… Зато можем написать так:

template<meta::info R> consteval void foo() { /* ... */ }

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

template<meta::info R> consteval void check_functions_linkage() {     static_assert(meta::is_namespace(R));      template for (constexpr meta::info e : meta::members_of(R)) {         if constexpr (meta::is_function(e)) {             __reflect_dump(e);             if constexpr (meta::is_externally_linked(e)) {                 constexpr auto error_msg =                     __concatenate("The method '", meta::name_of(e), "' is externally linked");                 __compiler_error(error_msg);             }         }          if constexpr (meta::is_namespace(e)) {             check_functions_linkage<e>();         }     } }

Заведем игрушечный namespace — компиляция будет падать так, как нам нужно:

namespace outer {     bool foo(int i) { return i == 13; }     std::string bar(std::string s) { return s + s; };      namespace inner {         double fizz() { return 3.14; }     } // namespace inner } // namespace outer  int main() {     check_functions_linkage<^outer>();     std::cout << "compiled!" << std::endl; }

Чтобы компиляция перестала падать, нужно сделать методы имеющими internal linkage.

Способы это сделать

Написать модификатор static

namespace outer {     static bool foo(int i) { return i == 13; }     static std::string bar(std::string s) { return s + s; };      namespace inner {         static double fizz() { return 3.14; }     } // namespace inner } // namespace outer

Или поместить методы внутри анонимного namespace

namespace outer { namespace {     bool foo(int i) { return i == 13; }     std::string bar(std::string s) { return s + s; };      namespace inner {         double fizz() { return 3.14; }     } // namespace inner } // anonymous namespace } // namespace outer

При желании можно пропарсить всё, до чего только можно дотянуться — если итерироваться по глобальному namespace (он же ::). Рефлексия глобального namespace это ^::.

Компиляция на godbolt

Проверка, что тип является интерфейсом

Можно проверить, что тип является «абстрактным», то есть имеет хотя бы один чисто виртуальный метод, через std::is_abstract.

Понятие «интерфейс» в стандарте не зафиксировано, но можно выработать для него требования:

  1. Все user-defined методы (т.е. которые юзер написал сам, а не которые сгенерированы компилятором) публичные и чисто виртуальные.

  2. У класса нет переменных.

  3. В классе есть публичный виртуальный деструктор, являющийся defaulted.

Вот как можно описать эти требования:

namespace traits {  template<typename T> consteval bool is_interface_impl() {     constexpr meta::info refl = ^T;     if constexpr (meta::is_class(refl)) {         template for (constexpr meta::info e : meta::members_of(refl)) {             // interfaces SHALL NOT have data members             if constexpr (meta::is_data_member(e)) {                 return false;             }             // every user function in interfaces SHOULD BE public and pure virtual             if constexpr (meta::is_function(e) && !meta::is_special_member_function(e)) {                 if constexpr (!meta::is_public(e) || !meta::is_pure_virtual(e)) {                     return false;                 }             }             // the destructor SHOULD BE virtual and defaulted             if constexpr (meta::is_function(e) && meta::is_destructor(e)) {                 if constexpr (!meta::is_public(e) || !meta::is_defaulted(e) || !meta::is_virtual(e)) {                     return false;                 }             }         }         return true;     }     return false; }  template<typename T> constexpr bool is_interface = is_interface_impl<T>();  } // namespace traits

Можно протестировать написанный метод:

Разные тесты
// IS NOT abstract, IS NOT interface class foo { public:     void foo_void(); private:     int _foo_int; }; static_assert(not std::is_abstract_v<foo>); static_assert(not traits::is_interface<foo>);  // IS abstract, IS NOT interface class bar { public:     virtual void bar_void() = 0;     std::string bar_string(); private:     int _foo_int; }; static_assert(    std::is_abstract_v<bar>); static_assert(not traits::is_interface<foo>);  // IS abstract, IS NOT interface class fizz { public:     virtual void fizz_void() = 0;     std::string fizz_string(); }; static_assert(    std::is_abstract_v<fizz>); static_assert(not traits::is_interface<fizz>);  // IS abstract, IS NOT interface class buzz { public:     virtual void buzz_void() = 0;     virtual std::string buzz_string() = 0; }; static_assert(    std::is_abstract_v<buzz>); static_assert(not traits::is_interface<buzz>);  // IS abstract, IS NOT interface class biba { public:     virtual ~biba() { /* ... not defaulted dtor ... */ };     virtual void biba_void() = 0;     virtual std::string biba_string() = 0; }; static_assert(    std::is_abstract_v<biba>); static_assert(not traits::is_interface<biba>);  // IS abstract, IS interface class boba { public:     virtual ~boba() = default;     virtual void boba_void() = 0;     virtual std::string boba_string() = 0; }; static_assert(    std::is_abstract_v<boba>); static_assert(    traits::is_interface<boba>);

Компиляция на godbolt

Сериализация объекта в JSON

Сериализация в JSON это такой FizzBuzz для любителей рефлексии. Каждый уважающий себя разработчик рефлексии рано или поздно это напишет.

В своем видео Andrew Sutton разбирает вопрос с JSON, но больше как псевдокод. Мы напишем свою реализацию.

Если модель данных немаленькая, то с «голым» JSON работать становится очень неудобно — всё нетипизированно и как будто постоянно лезешь в свалку данных, чтобы получить нужные поля. Можно конвертировать JSON в свои структуры, но это влечет кучу копипаста — чего можно избежать при наличии рефлексии.

Базовые типы JSON это Number, String, Boolean, Array, Object; пустое значение — null. Напишем концепты для каждого типа.

Number это каждый тип, удовлетворяющий std::is_arithmetic:

template<typename T> concept JsonNumber = std::is_arithmetic_v<T>;

String это строковой тип, причем объект должен владеть строкой, а не просто знать о ней (как std::string_view). Потому что где сериализация — там и десериализация, поэтому нужен владеющий тип. Это, конечно, только std::string:

template<typename T> concept JsonString = std::same_as<std::string, T>;

Boolean это просто bool:

template<typename T> concept JsonBoolean = std::same_as<bool, T>;

Array должен быть контейнером из последовательных элементов. Другими словами, это должен быть SequenceContainerstd::array/std::vector/std::deque/std::forward_list/std::list.

К сожалению, готового концепта для них нет — есть только вилами по воде писанные свойства. Поэтому напишем свой концепт с нуля, который определяет, что тип является инстанциацией нужного шаблона:

концепт JsonArray

спустя несколько ошибок компиляции…

static constexpr meta::info vector_refl = ^std::vector; static constexpr meta::info array_refl = ^std::array; static constexpr meta::info deque_refl = ^std::deque; static constexpr meta::info list_refl = ^std::list; static constexpr meta::info forward_list_refl = ^std::forward_list;  template<typename T> consteval bool is_json_array_impl() {     if constexpr (meta::is_specialization(^T)) {         constexpr auto tmpl = meta::template_of(^T);         constexpr bool result =             tmpl == vector_refl || tmpl == array_refl ||             tmpl == deque_refl || tmpl == list_refl ||             tmpl == forward_list_refl;         return result;     }     return false; }  template<typename T> concept JsonArray = is_json_array_impl<T>();

В данный момент сравнение как tmpl == ^std::vector крашит clang, поэтому придется писать так.

Object это просто класс/структура. В нем, конечно, должны быть дополнительные ограничения, чтобы сериализовать/десериализовать можно было достаточно простой тип — скажем, без членов со ссылочными типами. Но пока обойдемся базовым примером.

template<typename T> concept JsonObject = std::is_class_v<T>;

Значение null можно ввести для std::optional, который не содержит значения.

концепт JsonNullable
static constexpr meta::info optional_refl = ^std::optional;  template<typename T> consteval bool is_json_nullable_impl() {     if constexpr (meta::is_specialization(^T)) {         return meta::template_of(^T) == optional_refl;     }     return false; }  template<typename T> concept JsonNullable = is_json_nullable_impl<T>();

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

Особенность работы с концептами

В своем видео Andrew Sutton дает мега-совет — поскольку один тип может удовлетворять нескольким концептам, то не надо писать код вроде:

    template<Concept1 T>     void write(T const& t) { /* ... */ }      template<Concept2 T>     void write(T const& t) { /* ... */ }      template<Concept3 T>     void write(T const& t) { /* ... */ }

Потому что рано или поздно можно попасть на неоднозначность выбора метода. Поэтому надо делать диспетчеризацию, проверяя концепты по приоритетности:

    template<typename T>     void write(T const& t) {         if constexpr (Concept1<T>) {             write_concept1(t);         } else if constexpr (Concept2<T>) {             write_concept2(t);         } else if constexpr (Concept3<T>) {             write_concept3(t);         }     }

Сделаем класс json_writer, пусть он принимает объект, куда можно стримить выходной поток

template<typename Out> class json_writer { public:     json_writer(Out& out)         : _out{out}     {}          // ... другой код ...  private:     Out& _out; };

Реализуем метод для сериализации, который будет «диспетчером» для разных JSON-типов:

    template<typename T>     void write(T const& t) {         if constexpr (JsonNullable<T>) {             write_nullable(t);         } else if constexpr (JsonNumber<T>) {             write_number(t);         } else if constexpr (JsonString<T>) {             write_string(t);         } else if constexpr (JsonBoolean<T>) {             write_boolean(t);         } else if constexpr (JsonArray<T>) {             write_array(t);         } else if constexpr (JsonObject<T>) {             write_object(t);         }     }

Методы, которые вызываются из write, могут естественным образом делать рекурсивный запрос в write снова. Реализуем запись nullable-типа:

    template<JsonNullable T>     void write_nullable(T const& t) {         if (t.has_value()) {             write(*t);         } else {             _out << "null";         }     }

Записи числового, строкового, булевого типов нерекурсивны:

    template<JsonNumber T>     void write_number(const T t) {         _out << t;     }      template<JsonString T>     void write_string(T const& t) {         _out << '"' << t << '"';     }      template<JsonBoolean T>     void write_boolean(const T t) {         if (t) {             _out << "true";         } else {             _out << "false";         }     }

Запись массива достаточно проста — надо только правильно ставить запятые, разделяющие объекты:

    template<JsonArray T>     void write_array(T const& t) {         _out << '[';         bool is_first_item = true;         for (const auto& item : t) {             if (is_first_item) {                 is_first_item = false;             } else {                 _out << ',';             }             write(item);         }         _out << ']';     }

Запись объекта — самая сложная, нужно проитерироваться по членам структуры и записать каждый член отдельно:

    template<JsonObject T>     void write_object(T const& t) {         _out << '{';         bool is_first_member = true;          template for (constexpr meta::info e : meta::members_of(^T)) {             if constexpr (meta::is_data_member([:^e:])) {                 if (is_first_member) {                     is_first_member = false;                 } else {                     _out << ',';                 }                  _out << '"' << meta::name_of(e) << '"';                 _out << ':';                 write(t.[:e:]);             }         }          _out << '}';     }

Создадим модель данных — пусть это будет библиотека, у которой несколько книг, один адрес, и опционально «описание»

namespace model {  struct book {     std::string name;     std::string author;     int year; };  struct latlon {     double lat;     double lon; };  struct library {     std::vector<book> books;     std::optional<std::string> description;     latlon address; };  } // namespace model

Зададим библиотеке адрес, добавим несколько книг, и выведем ее в формате JSON:

int main() {     model::library l;     l.address = model::latlon{.lat = 51.507351, .lon = -0.127696};     l.books.push_back(model::book{         .name = "The Picture of Dorian Gray",         .author = "Oscar Wilde",         .year = 1890,     });     l.books.push_back(model::book{         .name = "Fahrenheit 451",         .author = "Ray Bradbury",         .year = 1953,     });     l.books.push_back(model::book{         .name = "Roadside Picnic",         .author = "Arkady and Boris Strugatsky",         .year = 1972,     });      json::json_writer{std::cout}.write(l);     std::cout << std::endl; }

Программа выведет неотформатированный JSON:

{"books":[{"name":"The Picture of Dorian Gray","author":"Oscar Wilde","year":1890},{"name":"Fahrenheit 451","author":"Ray Bradbury","year":1953},{"name":"Roadside Picnic","author":"Arkady and Boris Strugatsky","year":1972}],"description":null,"address":{"lat":51.5074,"lon":-0.127696}}
Отформатированный вид такой:
{     "books": [         {             "name": "The Picture of Dorian Gray",             "author": "Oscar Wilde",             "year": 1890         },         {             "name": "Fahrenheit 451",             "author": "Ray Bradbury",             "year": 1953         },         {             "name": "Roadside Picnic",             "author": "Arkady and Boris Strugatsky",             "year": 1972         }     ],     "description": null,     "address": {         "lat": 51.5074,         "lon": -0.127696     } }

Компиляция на godbolt

Если бы сериализацию/десериализацию надо было сделать в реальном проекте, я бы посоветовал добавить «прокладку» в виде существующей json-библиотеки, например nlohmann/json.

То есть мы бы переводили объект «нашей» структуры в объект из json-библиотеки, а этот объект уже конвертировался бы в строку. При десериализации наоборот — строка в json-объект, json-объект в «наш» объект.

Это нужно, чтобы не переизобретать велосипед — с «прокладкой» работать проще и надежнее, чем самому что-то парсить.

Такой же подход работает для XML, ORM в базу данных, и прочего.

Универсальный метод сравнения двух объектов

Возьмем model::book из предыдущего кода. Если мы попытаемся сравнить два объекта этого типа, то получим ошибку компиляции

    model::book a, b;     std::cout << (a == b) << std::endl; // тут ошибка компиляции

Можно выработать свои правила для универсального сравнения:

  1. Если объекты можно сравнить, то есть вызов a == b скомпилируется, то результат сравнения — вызов этого оператора.

  2. Если объект — итерируемый контейнер (как std::vector), то проверим, что размеры совпадают, и сравним каждый элемент контейнера.

  3. Иначе проитерируемся по членам типа и сравним каждый член отдельно.

Для первого и второго пункта концепты пришлось написать самому, так как существующие не нашел…

namespace bicycle {  template <class T> constexpr bool equality_comparable = requires(const T& a, const T& b) {     std::is_convertible_v<decltype(a == b), bool>; };  template <class T> constexpr bool iterable = requires(const T& t, size_t i) {     t[i];     std::begin(t);     std::end(t);     std::size(t); };  } // namespace bicycle

Теперь напишем наш метод, как и планировали — с проверкой с первого по третий пункт? На самом деле нет — первый и второй пункт надо поменять местами

Концепты иногда работают не так, как ожидали

Если проверить первый концепт, то можно обнаружить подставу:

static_assert(    bicycle::equality_comparable<int>); static_assert(    bicycle::equality_comparable<std::string>); static_assert(    bicycle::equality_comparable<std::optional<std::string>>); static_assert(    bicycle::equality_comparable<std::vector<model::book>>); // <<< :( static_assert(not bicycle::equality_comparable<model::book>); static_assert(not bicycle::equality_comparable<model::library>);

Сравнение двух объектов типа model::book не скомпилируется, так же, как типа std::vector<model::book>. Но концепт резольвится в true!

Дело в том, что концепт смотрит на сигнатуру метода, а не на весь метод. Он видит, что у вектора оператор сравнения объявлен:

template< class T, class Alloc > bool operator==( const std::vector<T,Alloc>& lhs,                  const std::vector<T,Alloc>& rhs );

А в определение метода он не лезет, к тому же это может быть невозможно — определение может лежать в другом translation unit. То, что в итоге код не скомпилируется, для концепта это «уже не его проблемы».

Напишем наш метод:

namespace equal_util {  template<typename T> bool equal(const T& a, const T& b) {     if constexpr (bicycle::iterable<T>) {         if (a.size() != b.size()) {             return false;         }         for (size_t i = 0; i < a.size(); ++i) {             if (!equal(a[i], b[i]))                 return false;         }         return true;     } else if constexpr (bicycle::equality_comparable<T>) {         return a == b;     } else {         template for (constexpr meta::info e : meta::members_of(^T)) {             if constexpr (meta::is_data_member([:^e:])) {                 if (!equal(a.[:e:], b.[:e:])) {                     return false;                 }             }         }         return true;     } }  } // namespace equal_util

Возможно, стоило бы для типов float и double сравнивать их разницу с эпсилоном… Но пока обойдемся без этих подарочков.

Проверим метод — в первый раз выведется true, во второй раз false, успех!

int main() {     model::library a, b;      a.address = model::latlon{.lat = 51.507351, .lon = -0.127696};     b.address = a.address;      a.books.push_back(model::book{         .name = "The Picture of Dorian Gray",         .author = "Oscar Wilde",         .year = 1890,     });     b.books = a.books;      std::cout << std::boolalpha;     std::cout << equal_util::equal(a, b) << std::endl;     b.books.clear();     std::cout << equal_util::equal(a, b) << std::endl; }

Компиляция на godbolt

Контейнер Dependency Injection

И наконец, мы сделаем собственный контейнер для Dependency Injection!

Этот паттерн программирования хардкорно используется, например, в Spring — самом популярном Java-фреймворке.

В модели управления обычно одни объекты зависят от других объектов. Далее будем писать «компонент» вместо «объект».

Смысл паттерна в том, что вместо того, чтобы компонент сам создавал зависимые компоненты, эти компоненты создавал бы фреймворк. И потом давал бы их компоненту через конструктор (все компоненты сразу) либо через сеттер-методы (по одному сеттер-методу на компонент).

Во многих случаях такой подход сильно упрощает программирование. В сложных проектах длина цепочки зависимостей может находиться за пределами возможностей человеческого мозга.

Создадим модель управления для сервиса а-ля «URL Shortener», который принимает длинные ссылки и отдает короткие (и наоборот). У нас будет, очень условно, четыре компонента (в реальности было бы побольше):

  1. s3_storage — сервис, который умеет брать картинку из s3-хранилища и возвращать ее.

  2. database — сервис-«прокладка» для работы с базой данных

  3. link_shortener — сервис, принимающий длинную ссылку и возвращающий короткую (и наоборот). Зависит от database, где хранит соответствие между ссылками.

  4. http_server — сервис, обрабатывающие запросы по http. Зависит от s3_storage (показ лого на сайте), link_shortener (понятно для чего), database (куда пишет всякую статистику про посетителя сайта).

Зависимости в программе
Зависимости в программе

Опишем компоненты в коде:

namespace component {  class database { public:     void post_construct() {         /* тут инициализируем подключение к БД */     }      /* тут некие методы об операциях в БД */ };  class link_shortener { public:     void set_component(std::shared_ptr<database> component) {         _database = std::move(component);     }      /* тут некие методы link_shortener. */      /* метод post_construct() не нужен */  private:     std::shared_ptr<database> _database; };  class s3_storage { public:     /* тут некие методы s3_storage. */      /* метод post_construct() не нужен */ };  class http_server { public:     void set_component(std::shared_ptr<s3_storage> component) {         _s3_storage = std::move(component);     }      void set_component(std::shared_ptr<link_shortener> component) {         _link_shortener = std::move(component);     }      void set_component(std::shared_ptr<database> component) {         _database = std::move(component);     }      void post_construct() {         /* тут поднимаем http-сервер и ждём запросы */     }  private:     std::shared_ptr<s3_storage> _s3_storage;     std::shared_ptr<link_shortener> _link_shortener;     std::shared_ptr<database> _database; };  } // namespace component

Что должен сделать фреймворк:

  1. Создать компоненты через std::make_shared, каждый компонент должен быть создан ровно один раз.

  2. Вызвать set_component с готовыми зависимыми компонентами.

  3. Когда все нужные set_component вызваны, вызвать метод post_construct, если он есть в классе. Сначала вызывается у зависимых компонент, потом у зависящих.

  4. Когда «корневой компонент» (в нашем случае http_server) закончит работу post_construct, в правильном порядке уничтожить компоненты, чтобы на момент вызова деструктора все зависимые компоненты были «живы».

Создадим заготовку класса:

namespace dependency_injection {  static constexpr meta::info shared_ptr_refl = ^std::shared_ptr;  class components_builder { public:     template<typename Component>     std::shared_ptr<Component> build() && {         return build_component_impl<Component>();     }  private:     using ready_components_container = std::unordered_map<std::string_view, std::any>;     static constexpr std::string_view COMPONENT_INJECTION_FUNCTION_NAME = "set_component";     static constexpr std::string_view COMPONENT_POST_CONSTRUCT_FUNCTION_NAME = "post_construct";  private:     // другие методы...    private:     ready_components_container _ready_components; };  } // namespace dependency_injection

Готовые компоненты хранятся в хешмапе. Значения хешмапы имеют тип std::any, потому что компоненты не имеют общего типа.

Создадим метод-«прокладку», который сначала ищет компонент в хешмапе, а если не найдет, то строит компонент:

    // don't build component again if already has one built     template<typename Component>     std::shared_ptr<Component> build_or_get_component() {         std::shared_ptr<Component> component;          constexpr std::string_view comp_name = meta::name_of(meta::entity_of(^Component));         if (auto _ready_iter = _ready_components.find(comp_name); _ready_iter != _ready_components.end()) {             component = std::any_cast<decltype(component)>(_ready_iter->second);         } else {             component = build_component_impl<Component>();             _ready_components[comp_name] = component;         }          return component;     }

Чтобы построить компонент, надо создать его объект через std::make_shared, потом построить все зависимые компоненты и вызвать для каждого set_component, потом вызвать метод post_construct при его наличии.

    template<typename Component>     std::shared_ptr<Component> build_component_impl() {         auto component = std::make_shared<Component>();         build_dependent_components(*component);         try_call_post_construct(*component);         return component;     }

Сделаем вспомогательный метод, который определяет, имеем ли мы перед собой рефлексию метода с нужным именем:

    template<meta::info R>     static constexpr bool is_callable_function(std::string_view expected_function_name) {         // drop special functions and non-public functions         if constexpr (meta::is_function(R) && meta::is_public(R) && !meta::is_special_member_function(R)) {             constexpr std::string_view function_name = meta::name_of(R);             return function_name == expected_function_name;         }         return false;     }

Как мы можем определить зависимые компоненты:

  1. Ищем все методы с названием set_component. Пусть мы зафиксировали один такой метод.

  2. Проверяем, что в этом методе ровно один параметр.

  3. Тип этого параметра должен являться специализацией шаблона std::shared_ptr.

  4. Класс, которым был специализирован шаблон — это класс компонента, который нужно создать (или взять готовый, если есть).

  5. Вызываем set_component с компонентом из п. 4.

С этим планом сделаем метод build_dependent_components:

    template<typename Component>     void build_dependent_components(Component& component) {         template for (constexpr meta::info e : meta::members_of(^Component)) {             // iterate through functions             if constexpr (is_callable_function<e>(COMPONENT_INJECTION_FUNCTION_NAME)) {                 // the function should have exactly one parameter                 constexpr auto param_range = meta::parameters_of(e);                 static_assert(size(param_range) == 1, "Please pass only one parameter");                 constexpr meta::info param = *param_range.begin();                  // the type of the parameter should be std::shared_ptr<U>                 constexpr meta::info param_type = meta::type_of(param);                 static_assert(meta::is_specialization(param_type), "Please pass std::shared_ptr<component>");                 static_assert(meta::template_of(param_type) == shared_ptr_refl, "Please pass std::shared_ptr<component>");                  // obtain dependent component type                 using SharedPtrType = typename [:param_type:];                 using DependentComponentType = typename SharedPtrType::element_type;                  // build the dependent component (if not built yet) and give it to the original component                 auto dependent_component = build_or_get_component<DependentComponentType>();                 component.[:e:](dependent_component);             }         }     }

Вызов post_construct выглядит проще:

    template<typename Component>     void try_call_post_construct(Component& component) {         template for (constexpr meta::info e : meta::members_of(^Component)) {             if constexpr (is_callable_function<e>(COMPONENT_POST_CONSTRUCT_FUNCTION_NAME)) {                 constexpr auto param_range = meta::parameters_of(e);                 static_assert(size(param_range) == 0, "Please don't pass parameters in \"post_construct\"");                 component.[:e:]();             }         }     }

Осталось только установить «корневой компонент» и запустить весь процесс:

int main() {     dependency_injection::components_builder().build<component::http_server>(); }

Если для каждого компонента добавить лог имени вызываемого метода в конструкторе, деструкторе, set_component и post_construct, то можно увидеть, что именно делает фреймворк:

call "http_server::http_server()" call "s3_storage::s3_storage()" call "s3_storage::post_construct()" <<<<<<<<<< THE COMPONENT IS READY call "http_server::set_component(std::shared_ptr<s3_storage>)" call "link_shortener::link_shortener()" call "database::database()" call "database::post_construct()" <<<<<<<<<< THE COMPONENT IS READY call "link_shortener::set_component(std::shared_ptr<database>)" call "link_shortener::post_construct()" <<<<<<<<<< THE COMPONENT IS READY call "http_server::set_component(std::shared_ptr<link_shortener>)" call "http_server::set_component(std::shared_ptr<database>)" call "http_server::post_construct()" <<<<<<<<<< THE COMPONENT IS READY call "http_server::~http_server()" call "link_shortener::~link_shortener()" call "database::~database()" call "s3_storage::~s3_storage()"

Фреймворк все делает правильно!

Из того, что можно добавить:

  1. Проверку на циклы зависимостей — их быть не должно. Кажется, циклы возможно обнаружить в compile-time.

  2. Можно зависеть от интерфейса, а не от реализации, «как в лучших домах Парижу».

Зависимость от интерфейса, а не от реализации

Сервис s3_storage — это просто реализация сервиса по работе с хранилищем картинок.

Можно сделать так, чтобы s3_storage наследовался от интерфейса image_storage, и в http_server был бы метод set_component(std::shared_ptr<image_storage>).

Рефлексия могла бы распарсить весь namespace, найти реализацию интерфейса, и создать его.

Другие примеры рефлексивного программирования

Кроме примеров выше, я сделал hasattr.cpp — имитация методов hasattr и getattr из языка Python, а также opts.cpp — типизированный парсер командной строки.

Разбирать их я не стал, потому что новой информации там нет.

Все примеры доступны на githubссылка.

Что хочется иметь от рефлексии в будущем?

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

Хочется, чтобы рефлексия умела работать с атрибутами, потому что без этого отнимается большой пласт крутых юзкейсов.

Когда рефлексия войдет в C++ — пока точно не известно, но вероятнее всего, успеют к стандарту C++26.


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


Комментарии

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

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