
Определение понятия «рефлексия» из Википедии:
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; }
Соглашение о записи операторов
Записи операторов ^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 {}; }
Проверка функций на 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 это ^::.
Проверка, что тип является интерфейсом
Можно проверить, что тип является «абстрактным», то есть имеет хотя бы один чисто виртуальный метод, через std::is_abstract.
Понятие «интерфейс» в стандарте не зафиксировано, но можно выработать для него требования:
-
Все user-defined методы (т.е. которые юзер написал сам, а не которые сгенерированы компилятором) публичные и чисто виртуальные.
-
У класса нет переменных.
-
В классе есть публичный виртуальный деструктор, являющийся 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>);
Сериализация объекта в 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 должен быть контейнером из последовательных элементов. Другими словами, это должен быть SequenceContainer — std::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 } }
Если бы сериализацию/десериализацию надо было сделать в реальном проекте, я бы посоветовал добавить «прокладку» в виде существующей json-библиотеки, например nlohmann/json.
То есть мы бы переводили объект «нашей» структуры в объект из json-библиотеки, а этот объект уже конвертировался бы в строку. При десериализации наоборот — строка в json-объект, json-объект в «наш» объект.
Это нужно, чтобы не переизобретать велосипед — с «прокладкой» работать проще и надежнее, чем самому что-то парсить.
Такой же подход работает для XML, ORM в базу данных, и прочего.
Универсальный метод сравнения двух объектов
Возьмем model::book из предыдущего кода. Если мы попытаемся сравнить два объекта этого типа, то получим ошибку компиляции
model::book a, b; std::cout << (a == b) << std::endl; // тут ошибка компиляции
Можно выработать свои правила для универсального сравнения:
-
Если объекты можно сравнить, то есть вызов a == b скомпилируется, то результат сравнения — вызов этого оператора.
-
Если объект — итерируемый контейнер (как std::vector), то проверим, что размеры совпадают, и сравним каждый элемент контейнера.
-
Иначе проитерируемся по членам типа и сравним каждый член отдельно.
Для первого и второго пункта концепты пришлось написать самому, так как существующие не нашел…
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; }
Контейнер Dependency Injection
И наконец, мы сделаем собственный контейнер для Dependency Injection!
Этот паттерн программирования хардкорно используется, например, в Spring — самом популярном Java-фреймворке.
В модели управления обычно одни объекты зависят от других объектов. Далее будем писать «компонент» вместо «объект».
Смысл паттерна в том, что вместо того, чтобы компонент сам создавал зависимые компоненты, эти компоненты создавал бы фреймворк. И потом давал бы их компоненту через конструктор (все компоненты сразу) либо через сеттер-методы (по одному сеттер-методу на компонент).
Во многих случаях такой подход сильно упрощает программирование. В сложных проектах длина цепочки зависимостей может находиться за пределами возможностей человеческого мозга.
Создадим модель управления для сервиса а-ля «URL Shortener», который принимает длинные ссылки и отдает короткие (и наоборот). У нас будет, очень условно, четыре компонента (в реальности было бы побольше):
-
s3_storage — сервис, который умеет брать картинку из s3-хранилища и возвращать ее.
-
database — сервис-«прокладка» для работы с базой данных
-
link_shortener — сервис, принимающий длинную ссылку и возвращающий короткую (и наоборот). Зависит от database, где хранит соответствие между ссылками.
-
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
Что должен сделать фреймворк:
-
Создать компоненты через
std::make_shared, каждый компонент должен быть создан ровно один раз. -
Вызвать
set_componentс готовыми зависимыми компонентами. -
Когда все нужные
set_componentвызваны, вызвать методpost_construct, если он есть в классе. Сначала вызывается у зависимых компонент, потом у зависящих. -
Когда «корневой компонент» (в нашем случае
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; }
Как мы можем определить зависимые компоненты:
-
Ищем все методы с названием
set_component. Пусть мы зафиксировали один такой метод. -
Проверяем, что в этом методе ровно один параметр.
-
Тип этого параметра должен являться специализацией шаблона
std::shared_ptr. -
Класс, которым был специализирован шаблон — это класс компонента, который нужно создать (или взять готовый, если есть).
-
Вызываем
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()"
Фреймворк все делает правильно!
Из того, что можно добавить:
-
Проверку на циклы зависимостей — их быть не должно. Кажется, циклы возможно обнаружить в compile-time.
-
Можно зависеть от интерфейса, а не от реализации, «как в лучших домах Парижу».
Зависимость от интерфейса, а не от реализации
Сервис 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/
Добавить комментарий