Определение понятия "рефлексия" из Википедии:
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.
Автор: Евгений