Pull to refresh

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

Reading time 24 min
Views 14K

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

enum Color {
    Red, Green, Blue
};

using MetaT = reflexpr(Color);

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

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

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

template <Enum T> struct get_enumerators;

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

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

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

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

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

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

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

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

Рефлексия в constexpr

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

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

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

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

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

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

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

consteval meta::info make_pointer(meta::info type, int n) {
    for (int i = 0; i < n; ++i) {
        type = meta::add_pointer(type);
    }
    return type;
}

template<typename T, int N>
auto make_indirect_reflective() {
    return typename [:make_pointer(^T, N):]{};
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

docker pull sehnsucht88/clang-p2320

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

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

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

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

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

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

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

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

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

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

enum LightColor { Red, Green, Blue };

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

template<meta::info R>
consteval void check_functions_linkage() {
    static_assert(meta::is_namespace(R));

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

        if constexpr (meta::is_namespace(e)) {
            check_functions_linkage<e>();
        }
    }
}

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

namespace outer {
    bool foo(int i) { return i == 13; }
    std::string bar(std::string s) { return s + s; };

    namespace inner {
        double fizz() { return 3.14; }
    } // namespace inner
} // namespace outer

int main() {
    check_functions_linkage<^outer>();
    std::cout << "compiled!" << std::endl;
}

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

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

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

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

    namespace inner {
        static double fizz() { return 3.14; }
    } // namespace inner
} // namespace outer

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

namespace outer {
namespace {
    bool foo(int i) { return i == 13; }
    std::string bar(std::string s) { return s + s; };

    namespace inner {
        double fizz() { return 3.14; }
    } // namespace inner
} // anonymous namespace
} // namespace outer

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

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

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

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

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

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

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

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

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

namespace traits {

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

template<typename T>
constexpr bool is_interface = is_interface_impl<T>();

} // namespace traits

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

Разные тесты
// IS NOT abstract, IS NOT interface
class foo {
public:
    void foo_void();
private:
    int _foo_int;
};
static_assert(not std::is_abstract_v<foo>);
static_assert(not traits::is_interface<foo>);

// IS abstract, IS NOT interface
class bar {
public:
    virtual void bar_void() = 0;
    std::string bar_string();
private:
    int _foo_int;
};
static_assert(    std::is_abstract_v<bar>);
static_assert(not traits::is_interface<foo>);

// IS abstract, IS NOT interface
class fizz {
public:
    virtual void fizz_void() = 0;
    std::string fizz_string();
};
static_assert(    std::is_abstract_v<fizz>);
static_assert(not traits::is_interface<fizz>);

// IS abstract, IS NOT interface
class buzz {
public:
    virtual void buzz_void() = 0;
    virtual std::string buzz_string() = 0;
};
static_assert(    std::is_abstract_v<buzz>);
static_assert(not traits::is_interface<buzz>);

// IS abstract, IS NOT interface
class biba {
public:
    virtual ~biba() { /* ... not defaulted dtor ... */ };
    virtual void biba_void() = 0;
    virtual std::string biba_string() = 0;
};
static_assert(    std::is_abstract_v<biba>);
static_assert(not traits::is_interface<biba>);

// IS abstract, IS interface
class boba {
public:
    virtual ~boba() = default;
    virtual void boba_void() = 0;
    virtual std::string boba_string() = 0;
};
static_assert(    std::is_abstract_v<boba>);
static_assert(    traits::is_interface<boba>);

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

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

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

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

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

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

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

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

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

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

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

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

Array должен быть контейнером из последовательных элементов. Другими словами, это должен быть 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
    }
}

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

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

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

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

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

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

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

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

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

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

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

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

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

namespace bicycle {

template <class T>
constexpr bool equality_comparable = requires(const T& a, const T& b) {
    std::is_convertible_v<decltype(a == b), bool>;
};

template <class T>
constexpr bool iterable = requires(const T& t, size_t i) {
    t[i];
    std::begin(t);
    std::end(t);
    std::size(t);
};

} // namespace bicycle

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

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

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

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

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

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

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

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

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

namespace equal_util {

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

} // namespace equal_util

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

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

int main() {
    model::library a, b;

    a.address = model::latlon{.lat = 51.507351, .lon = -0.127696};
    b.address = a.address;

    a.books.push_back(model::book{
        .name = "The Picture of Dorian Gray",
        .author = "Oscar Wilde",
        .year = 1890,
    });
    b.books = a.books;

    std::cout << std::boolalpha;
    std::cout << equal_util::equal(a, b) << std::endl;
    b.books.clear();
    std::cout << equal_util::equal(a, b) << std::endl;
}

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

Контейнер Dependency Injection

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

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

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

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

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

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

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

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

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

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

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

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

namespace component {

class database {
public:
    void post_construct() {
        /* тут инициализируем подключение к БД */
    }

    /* тут некие методы об операциях в БД */
};

class link_shortener {
public:
    void set_component(std::shared_ptr<database> component) {
        _database = std::move(component);
    }

    /* тут некие методы link_shortener. */ 
    /* метод post_construct() не нужен */

private:
    std::shared_ptr<database> _database;
};

class s3_storage {
public:
    /* тут некие методы s3_storage. */ 
    /* метод post_construct() не нужен */
};

class http_server {
public:
    void set_component(std::shared_ptr<s3_storage> component) {
        _s3_storage = std::move(component);
    }

    void set_component(std::shared_ptr<link_shortener> component) {
        _link_shortener = std::move(component);
    }

    void set_component(std::shared_ptr<database> component) {
        _database = std::move(component);
    }

    void post_construct() {
        /* тут поднимаем http-сервер и ждём запросы */
    }

private:
    std::shared_ptr<s3_storage> _s3_storage;
    std::shared_ptr<link_shortener> _link_shortener;
    std::shared_ptr<database> _database;
};

} // namespace component

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

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

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

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

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

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

namespace dependency_injection {

static constexpr meta::info shared_ptr_refl = ^std::shared_ptr;

class components_builder {
public:
    template<typename Component>
    std::shared_ptr<Component> build() && {
        return build_component_impl<Component>();
    }

private:
    using ready_components_container = std::unordered_map<std::string_view, std::any>;
    static constexpr std::string_view COMPONENT_INJECTION_FUNCTION_NAME = "set_component";
    static constexpr std::string_view COMPONENT_POST_CONSTRUCT_FUNCTION_NAME = "post_construct";

private:
    // другие методы...
  
private:
    ready_components_container _ready_components;
};

} // namespace dependency_injection

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

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

    // don't build component again if already has one built
    template<typename Component>
    std::shared_ptr<Component> build_or_get_component() {
        std::shared_ptr<Component> component;

        constexpr std::string_view comp_name = meta::name_of(meta::entity_of(^Component));
        if (auto _ready_iter = _ready_components.find(comp_name); _ready_iter != _ready_components.end()) {
            component = std::any_cast<decltype(component)>(_ready_iter->second);
        } else {
            component = build_component_impl<Component>();
            _ready_components[comp_name] = component;
        }

        return component;
    }

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

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

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

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

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

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

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

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

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

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

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

    template<typename Component>
    void build_dependent_components(Component& component) {
        template for (constexpr meta::info e : meta::members_of(^Component)) {
            // iterate through functions
            if constexpr (is_callable_function<e>(COMPONENT_INJECTION_FUNCTION_NAME)) {
                // the function should have exactly one parameter
                constexpr auto param_range = meta::parameters_of(e);
                static_assert(size(param_range) == 1, "Please pass only one parameter");
                constexpr meta::info param = *param_range.begin();

                // the type of the parameter should be std::shared_ptr<U>
                constexpr meta::info param_type = meta::type_of(param);
                static_assert(meta::is_specialization(param_type), "Please pass std::shared_ptr<component>");
                static_assert(meta::template_of(param_type) == shared_ptr_refl, "Please pass std::shared_ptr<component>");

                // obtain dependent component type
                using SharedPtrType = typename [:param_type:];
                using DependentComponentType = typename SharedPtrType::element_type;

                // build the dependent component (if not built yet) and give it to the original component
                auto dependent_component = build_or_get_component<DependentComponentType>();
                component.[:e:](dependent_component);
            }
        }
    }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Tags:
Hubs:
+27
Comments 48
Comments Comments 48

Articles