Рефлексия в 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 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
auto make_indirect_template() {
if constexpr (N == 0) {
return T{};
} else {
return make_indirect_template();
}
}
А вот так можно сделать через рефлексию:
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
auto make_indirect_reflective() {
return typename [:make_pointer(^T, N):]{};
}
Код внутри consteval-методов выполняется только в compile-time. Все consteval-методы после компиляции «испаряются», то есть их код в бинарнике отсутствует.
Можно вывести имя получившихся типов:
int main() {
auto ptr1 = make_indirect_template();
std::cout << meta::name_of(meta::type_of(^ptr1)) << std::endl;
auto ptr2 = make_indirect_reflective();
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
requires std::is_enum_v
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(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
requires std::is_enum_v
constexpr std::optional 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
consteval void foo() { /* ... */ }
Теперь попробуем решить нашу задачу. Методы находятся внутри namespace. Поэтому надо проитерироваться по всем членам namespace, являющимися функциями. Также могут быть вложенные namespace, поэтому их также надо проверить рекурсивно.
template
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();
}
}
}
Заведем игрушечный 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.
Понятие «интерфейс» в стандарте не зафиксировано, но можно выработать для него требования:
Все user-defined методы (т.е. которые юзер написал сам, а не которые сгенерированы компилятором) публичные и чисто виртуальные.
У класса нет переменных.
В классе есть публичный виртуальный деструктор, являющийся defaulted.
Вот как можно описать эти требования:
namespace traits {
template
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
constexpr bool is_interface = is_interface_impl();
} // 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);
static_assert(not traits::is_interface);
// 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);
static_assert(not traits::is_interface);
// IS abstract, IS NOT interface
class fizz {
public:
virtual void fizz_void() = 0;
std::string fizz_string();
};
static_assert( std::is_abstract_v);
static_assert(not traits::is_interface);
// 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);
static_assert(not traits::is_interface);
// 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);
static_assert(not traits::is_interface);
// 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);
static_assert( traits::is_interface);
Компиляция на godbolt
Сериализация объекта в JSON
Сериализация в JSON это такой FizzBuzz для любителей рефлексии. Каждый уважающий себя разработчик рефлексии рано или поздно это напишет.
В своем видео Andrew Sutton разбирает вопрос с JSON, но больше как псевдокод. Мы напишем свою реализацию.
Если модель данных немаленькая, то с «голым» JSON работать становится очень неудобно — всё нетипизированно и как будто постоянно лезешь в свалку данных, чтобы получить нужные поля. Можно конвертировать JSON в свои структуры, но это влечет кучу копипаста — чего можно избежать при наличии рефлексии.
Базовые типы JSON это Number
, String
, Boolean
, Array
, Object
; пустое значение — null
. Напишем концепты для каждого типа.
Number
это каждый тип, удовлетворяющий std: is_arithmetic:
template
concept JsonNumber = std::is_arithmetic_v;
String
это строковой тип, причем объект должен владеть строкой, а не просто знать о ней (как std::string_view
). Потому что где сериализация — там и десериализация, поэтому нужен владеющий тип. Это, конечно, только std::string
:
template
concept JsonString = std::same_as;
Boolean
это просто bool
:
template
concept JsonBoolean = std::same_as;
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
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
concept JsonArray = is_json_array_impl();
В данный момент сравнение как tmpl == ^std::vector
крашит clang, поэтому придется писать так.
Object
это просто класс/структура. В нем, конечно, должны быть дополнительные ограничения, чтобы сериализовать/десериализовать можно было достаточно простой тип — скажем, без членов со ссылочными типами. Но пока обойдемся базовым примером.
template
concept JsonObject = std::is_class_v;
Значение null
можно ввести для std::optional
, который не содержит значения.
концепт JsonNullable
static constexpr meta::info optional_refl = ^std::optional;
template
consteval bool is_json_nullable_impl() {
if constexpr (meta::is_specialization(^T)) {
return meta::template_of(^T) == optional_refl;
}
return false;
}
template
concept JsonNullable = is_json_nullable_impl();
Теперь можно сериализовать объект в зависимости от того, какому концепту он удовлетворяет.
Особенность работы с концептами
В своем видео Andrew Sutton дает мега-совет — поскольку один тип может удовлетворять нескольким концептам, то не надо писать код вроде:
template
void write(T const& t) { /* ... */ }
template
void write(T const& t) { /* ... */ }
template
void write(T const& t) { /* ... */ }
Потому что рано или поздно можно попасть на неоднозначность выбора метода. Поэтому надо делать диспетчеризацию, проверяя концепты по приоритетности:
template
void write(T const& t) {
if constexpr (Concept1) {
write_concept1(t);
} else if constexpr (Concept2) {
write_concept2(t);
} else if constexpr (Concept3) {
write_concept3(t);
}
}
Сделаем класс json_writer, пусть он принимает объект, куда можно стримить выходной поток
template
class json_writer {
public:
json_writer(Out& out)
: _out{out}
{}
// ... другой код ...
private:
Out& _out;
};
Реализуем метод для сериализации, который будет «диспетчером» для разных JSON-типов:
template
void write(T const& t) {
if constexpr (JsonNullable) {
write_nullable(t);
} else if constexpr (JsonNumber) {
write_number(t);
} else if constexpr (JsonString) {
write_string(t);
} else if constexpr (JsonBoolean) {
write_boolean(t);
} else if constexpr (JsonArray) {
write_array(t);
} else if constexpr (JsonObject) {
write_object(t);
}
}
Методы, которые вызываются из write
, могут естественным образом делать рекурсивный запрос в write
снова. Реализуем запись nullable-типа:
template
void write_nullable(T const& t) {
if (t.has_value()) {
write(*t);
} else {
_out << "null";
}
}
Записи числового, строкового, булевого типов нерекурсивны:
template
void write_number(const T t) {
_out << t;
}
template
void write_string(T const& t) {
_out << '"' << t << '"';
}
template
void write_boolean(const T t) {
if (t) {
_out << "true";
} else {
_out << "false";
}
}
Запись массива достаточно проста — надо только правильно ставить запятые, разделяющие объекты:
template
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
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 books;
std::optional 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; // тут ошибка компиляции
Можно выработать свои правила для универсального сравнения:
Если объекты можно сравнить, то есть вызов a == b скомпилируется, то результат сравнения — вызов этого оператора.
Если объект — итерируемый контейнер (как std: vector), то проверим, что размеры совпадают, и сравним каждый элемент контейнера.
Иначе проитерируемся по членам типа и сравним каждый член отдельно.
Для первого и второго пункта концепты пришлось написать самому, так как существующие не нашел…
namespace bicycle {
template
constexpr bool equality_comparable = requires(const T& a, const T& b) {
std::is_convertible_v;
};
template
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);
static_assert( bicycle::equality_comparable);
static_assert( bicycle::equality_comparable>);
static_assert( bicycle::equality_comparable>); // <<< :(
static_assert(not bicycle::equality_comparable);
static_assert(not bicycle::equality_comparable);
Сравнение двух объектов типа model::book
не скомпилируется, так же, как типа std::vector
. Но концепт резольвится в true
!
Дело в том, что концепт смотрит на сигнатуру метода, а не на весь метод. Он видит, что у вектора оператор сравнения объявлен:
template< class T, class Alloc >
bool operator==( const std::vector& lhs,
const std::vector& rhs );
А в определение метода он не лезет, к тому же это может быть невозможно — определение может лежать в другом translation unit. То, что в итоге код не скомпилируется, для концепта это «уже не его проблемы».
Напишем наш метод:
namespace equal_util {
template
bool equal(const T& a, const T& b) {
if constexpr (bicycle::iterable) {
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) {
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», который принимает длинные ссылки и отдает короткие (и наоборот). У нас будет, очень условно, четыре компонента (в реальности было бы побольше):
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 component) {
_database = std::move(component);
}
/* тут некие методы link_shortener. */
/* метод post_construct() не нужен */
private:
std::shared_ptr _database;
};
class s3_storage {
public:
/* тут некие методы s3_storage. */
/* метод post_construct() не нужен */
};
class http_server {
public:
void set_component(std::shared_ptr component) {
_s3_storage = std::move(component);
}
void set_component(std::shared_ptr component) {
_link_shortener = std::move(component);
}
void set_component(std::shared_ptr component) {
_database = std::move(component);
}
void post_construct() {
/* тут поднимаем http-сервер и ждём запросы */
}
private:
std::shared_ptr _s3_storage;
std::shared_ptr _link_shortener;
std::shared_ptr _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
std::shared_ptr build() && {
return build_component_impl();
}
private:
using ready_components_container = std::unordered_map;
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
std::shared_ptr build_or_get_component() {
std::shared_ptr 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(_ready_iter->second);
} else {
component = build_component_impl();
_ready_components[comp_name] = component;
}
return component;
}
Чтобы построить компонент, надо создать его объект через std::make_shared
, потом построить все зависимые компоненты и вызвать для каждого set_component
, потом вызвать метод post_construct
при его наличии.
template
std::shared_ptr build_component_impl() {
auto component = std::make_shared();
build_dependent_components(*component);
try_call_post_construct(*component);
return component;
}
Сделаем вспомогательный метод, который определяет, имеем ли мы перед собой рефлексию метода с нужным именем:
template
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
void build_dependent_components(Component& component) {
template for (constexpr meta::info e : meta::members_of(^Component)) {
// iterate through functions
if constexpr (is_callable_function(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
constexpr meta::info param_type = meta::type_of(param);
static_assert(meta::is_specialization(param_type), "Please pass std::shared_ptr");
static_assert(meta::template_of(param_type) == shared_ptr_refl, "Please pass std::shared_ptr");
// 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();
component.[:e:](dependent_component);
}
}
}
Вызов post_construct
выглядит проще:
template
void try_call_post_construct(Component& component) {
template for (constexpr meta::info e : meta::members_of(^Component)) {
if constexpr (is_callable_function(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();
}
Если для каждого компонента добавить лог имени вызываемого метода в конструкторе, деструкторе, 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)"
call "link_shortener::link_shortener()"
call "database::database()"
call "database::post_construct()" <<<<<<<<<< THE COMPONENT IS READY
call "link_shortener::set_component(std::shared_ptr)"
call "link_shortener::post_construct()" <<<<<<<<<< THE COMPONENT IS READY
call "http_server::set_component(std::shared_ptr)"
call "http_server::set_component(std::shared_ptr)"
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
.
Рефлексия могла бы распарсить весь namespace, найти реализацию интерфейса, и создать его.
Другие примеры рефлексивного программирования
Кроме примеров выше, я сделал hasattr.cpp — имитация методов hasattr
и getattr
из языка Python, а также opts.cpp — типизированный парсер командной строки.
Разбирать их я не стал, пот