Рефлексия в C++14

в 8:10, , рубрики: c++, C++14, c++17, metaprogramming, reflection

Данная статья является расшифровкой (с небольшими правками) доклада Антона antoshkka Полухина — “Немного магии для C++14”.

Я тут недавно ковырялся с C++ и случайно открыл пару новых приемов метапрограммирования, которые позволяют делать рефлексию в C++14. Пара мотивационных примеров. Вот у вас есть какая-то POD структура, в ней какие-то поля:

struct complicated_struct {
    int i;
    short s;
    double d;
    unsigned u;
};

Количество полей и их имена не имеют значение, важно то, что с этой структуры мы можем написать следующий кусочек кода:

#include <iostream>
#include "magic_get.hpp"

struct complicated_struct { /* … */ };

int main() {
    using namespace pod_ops;
    complicated_struct s {1, 2, 3.0, 4};
    std::cout << "s == " << s << std::endl; // Compile time error?
}

Функция main, в ней создаем переменную нашей структуры, как-то ее инициализируем через aggregate инициализацию, а потом эту переменную пытаемся вывести в std::cout. И в этот момент у нас, по идее, должна быть ошибка компиляции: мы не определили оператор вывода в поток для нашей структуры, компилятор не знает как все это скомпилировать и вывести. Однако, оно скомпилируется и выведет содержимое структуры:

antoshkka@home:~$ ./test
s == {1, 2, 3.0, 4}


Мы можем вернуться к коду, поменять имена полей, поменять название структуры, поменять имя переменной, все что угодно можем делать — код продолжит работать и правильно выводить содержимое структуры. Давайте посмотрим как оно работает.

В заголовочном файле magicget.hpp описан оператор, он работает с любыми типами данных:

template <class Char, class Traits, class T>
std::basic_ostream<Char, Traits>&
    operator<<(std::basic_ostream<Char, Traits>& out, const T& value)
{
    flat_write(out, value);
    return out;
}

Этот оператор вызывает метод flat_write. Метод flat_write выводит фигурные скобки и содержит стрёмную линию посередине:

template <class Char, class Traits, class T>
void flat_write(std::basic_ostream<Char, Traits>& out, const T& val) {
    out << '{';
    detail::flat_print_impl<0, flat_tuple_size<T>::value >::print(out, val);
    out << '}';
}

Посредине стрёмной линии есть flat_tuple_size::value. И тут надо заметить, что в стандартной библиотеке есть std::tuple_size<std::tuple>, которая вводит количество элементов в кортеже. Однако здесь T это не кортеж, не std::tuple, а пользовательский тип. Здесь flat_tuple_size выводит количество полей в пользовательском типе.

Давайте посмотрим дальше, что делает функция print:

template <std::size_t FieldIndex, std::size_t FieldsCount>
struct flat_print_impl {

    template <class Stream, class T>
    static void print (Stream& out, const T& value) {
        if (!!FieldIndex) out << ", ";
        out << flat_get<FieldIndex>(value);         // std::get<FieldIndex>(value)
        flat_print_impl<FieldIndex + 1, FieldsCount>::print(out, value);
    }
};

Функция print выводит или не выводит запятую в зависимости от индекса поля с которым мы работаем, а дальше идет вызов функции flat_get и комментарий, что она работает как std::get, то есть по индексу возвращает поле из структуры. Появляется закономерный вопрос: как же это работает?

Работает это следующим образом: оператор вывода в поток определяет количество полей вашей структуры, итерируется по полям через индексы и выводит каждый из полей по индексу. Таким образом получается то, что вы видели в начале статьи.

Давайте далее разберемся как сделать методы flat_get и flat_tuple_size, которые работают с пользовательскими структурами, определяют количество полей в структуре, выводят эту структуру по полям.

Начнем с простого. Будем подсчитывать количество полей в структуре. У нас имеется POD -структура T:

static_assert(std::is_pod<T>::value, "")

для этой структуры мы можем написать выражение:

T { args... }

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

Вот из этой абракадабры мы попробуем получить количество полей внутри структуры T. Как мы это будем делать? Мы возьмем нашу структуру T и попробуем ее проинициализировать каким-то огромным количеством аргументов. Это не скомпилируется. Мы один из аргументов отбросим и попробуем еще раз. Это тоже не скомпилируется, но когда-нибудь мы доберемся до того количества аргументов, которое равно количеству полей внутри нашей структуры, и тогда это соберется. В этот момент нам нужно просто запомнить количество аргументов — и вот мы готовы: у нас есть количество полей внутри структуры. Это базовая идея. Пойдемте в детали.

Как много аргументов нам нужно с самого начала, если наша структура T содержит в себе только char или unsigned char и прочие типы размером в 1 байт? В этом случае количество полей внутри структуры T будет равно размеру этой структуры. Если у нас поля другие, например, int или указатель, то тогда количество полей будет меньше чем размер структуры.

Мы получили количество полей с которого надо начинать aggregate инициализацию. То есть мы будем инициализировать нашу структуру T с количеством аргументов равным sizeof(T). Если не получилось скомпилировать, то один аргумент откидываем, пробуем опять, если скомпилировалось, то мы нашли количество полей внутри структуры. Остается одна проблема: даже если мы угадали с количеством аргументов внутри структуры, код все равно не соберется. Потому что нам нужно точно знать тип поля.

Давайте сделаем workaround. Мы сделаем структуру с оператором неявного приведения типа к любому типу:

struct ubiq {    
    template <class Type>
    constexpr operator Type&() const;
};


int i = ubiq{};
double d = ubiq{};
char c = ubiq{};

Это значит, что переменные этой структуры приводятся к любому типу: int, double, std::string, std::vector, любые пользовательские типы, к чему угодно.

Полностью рецепт: мы берем структуру T и пробуем сделать aggregate инициализацию этой структуры с количеством аргументов равным sizeof(T), где каждый аргумент — это инстанс нашей структуры ubiq. На этапе aggregate инициализации каждый инстанс из ubiq превратится в тип поля внутри структуры T, и нам остается только подобрать количество аргументов. Если много аргументов и не скомпилировалось, один отбрасываем и пытаемся опять. Если скомпилировалось, то считаем количество аргументов — и мы получили результат.

Теперь немного кода. Слегка меняем структуру ubiq: добавляем шаблонный параметр чтобы проще было использовать эту структуру при variadic темплейтах. Также нам понадобится std::make_index_sequence (сущность из C++14, которая разворачивается в std::index_sequence — длинную цепочку из циферок).

Готовы увидеть страшный код? Поехали.

Всего две функции:

// #1
template <class T, std::size_t I0, std::size_t... I>
constexpr auto detect_fields_count(std::size_t& out, std::index_sequence<I0, I...>)
    -> decltype( T{ ubiq_constructor<I0>{}, ubiq_constructor<I>{}... } )
{ out = sizeof...(I) + 1;      /*...*/ }

// #2
template <class T, std::size_t... I>
constexpr void detect_fields_count(std::size_t& out, std::index_sequence<I...>) {
    detect_fields_count<T>(out, std::make_index_sequence<sizeof...(I) - 1>{});
}

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

Эта функция имеет trailing return type, то есть тип этой функции это decltype от T с
aggregate инициализацией. Если мы угадали с количеством аргументов, то это выражение скомпилируется, мы проваливаемся в тело этой функции и в выходную переменную out пишем количество аргументов, которые у нас есть. Если не получилось (мы не угадали количество аргументов), тогда компилятор подумает, что это не ошибка, а substitution failure, и он должен найти другую функцию с таким же именем, но менее специализированную. Он возьмет функцию #2. Функция #2 отбрасывает один из индексов (то есть уменьшает количество аргументов на единичку) и вызывает detect_fields_count опять. Опять будет вызвана либо первая функция, либо вторая. Таким образом мы пробежимся по аргументам и найдем количество полей внутри структуры. Это была простая часть.

Впереди сложная: как получить тип поля внутри структуры T?

У нас уже есть наше выражение T c aggregate инициализацией и у нас передаются инстансы ubiq’а внутрь. У каждого инстанса ubiq’а вызывается оператор неявного приведения типа, и мы знаем тип поля внутри этого оператора. Все что нам нужно теперь — это как-то эту информацию схватить и вытащить во внешней scope, там, где мы можем с ней работать — за пределы aggregate инициализации структуры T. К несчастью, в C++ нет механизма записать тип данных в переменную. Точнее, есть std::type_index и std::type_info, но они бесполезны на этапе компиляции. Мы из них потом обратно тип не вытащим.

Давайте попробуем как-то обойти это ограничение. Для этого вспомним, что такое POD (но очень приблизительно: комитет по стандартизации любит менять определение каждые три года).
POD-структура — это структура, поля которой помечены либо public, либо private, либо protected (нас интересует только public поля). И все поля внутри этой структуры либо другие POD структуры, либо фундаментальные типы: указатели, int, std::nullptr_t. На пару минут забудем про указатели и у нас получится, что фундаментальных типов достаточно мало, меньше 32-x, и это значит, что мы каждому фундаментальному типу можем присвоить некий идентификатор (интегральную циферку). Эту циферку мы можем записать в выходной массив, вытащить этот выходной массив за пределы implicit conversion оператора, а там уже циферку обратно преобразовать к типу. Вот такая простая идея.

Поехала имплементация. Для этого меняем нашу структур ubiq:

template <std::size_t I>
struct ubiq_val {
    std::size_t* ref_;

    template <class Type>
    constexpr operator Type() const noexcept {
        ref_[I] = typeid_conversions::type_to_id(identity<Type>{});
        return Type{};
    }
};

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

#define BOOST_MAGIC_GET_REGISTER_TYPE(Type, Index)              
    constexpr std::size_t type_to_id(identity<Type>) noexcept { 
        return Index;                                           
    }                                                           
    constexpr Type id_to_type( size_t_<Index > ) noexcept {     
        Type res{};                                             
        return res;                                             
    }                                                           
    /**/

Макрос нам сгенерирует функцию type_to_id, которая превращает тип в идентификатор и также сгенерирует для нас функцию id_to_type, которая превращают идентификатор обратно в тип. Пользователю этот макрос не виден. Сразу как мы его использовали, мы его undefine-ем. Регистрируем фундаментальные типы (тут приведены не все):

BOOST_MAGIC_GET_REGISTER_TYPE(unsigned char         , 1)
BOOST_MAGIC_GET_REGISTER_TYPE(unsigned short        , 2)
BOOST_MAGIC_GET_REGISTER_TYPE(unsigned int          , 3)
BOOST_MAGIC_GET_REGISTER_TYPE(unsigned long         , 4)
BOOST_MAGIC_GET_REGISTER_TYPE(unsigned long long    , 5)
BOOST_MAGIC_GET_REGISTER_TYPE(signed char           , 6)
BOOST_MAGIC_GET_REGISTER_TYPE(short                 , 7)
BOOST_MAGIC_GET_REGISTER_TYPE(int                   , 8)
BOOST_MAGIC_GET_REGISTER_TYPE(long                  , 9)
BOOST_MAGIC_GET_REGISTER_TYPE(long long             , 10)
...

Нолик не используем. Скажу почему позже. Зарегистрировали все фундаментальные типы. Теперь делаем функцию, которая превращает тип T в массив идентификаторов полей внутри этого типа T. Все самое интересное находится в теле этой функции:

template <class T, std::size_t N, std::size_t... I>
constexpr auto type_to_array_of_type_ids(std::size_t* types) noexcept
    -> decltype(T{ ubiq_constructor<I>{}... })
{
    T tmp{ ubiq_val< I >{types}... };
    return tmp;
}

Здесь идет aggregate инициализация временной переменной, и мы передаем туда инстансы ubiq’а. В этот раз они держат указатель на выходной массив: здесь types это выходной массив, в который мы запишем идентификаторы типов полей. После этой строчки (после инициализации временной переменной) выходной массив types будет хранить в себе идентификаторы типа каждого поля. Функция type_to_array_of_type_ids является constexpr, т.е все можно использовать на этапе компиляции. Красотень! Нам осталось идентификаторы превратить обратно в типы. Делается это вот так:

template <class T, std::size_t... I>
constexpr auto as_tuple_impl(std::index_sequence<I...>) noexcept {
    constexpr auto a = array_of_type_ids<T>();              // #0

    return std::tuple<                                      // #3
        decltype(typeid_conversions::id_to_type(            // #2
            size_t_<a[I]>{}                                 // #1
        ))...                                               
    >{};
}

Нулевая строчка: здесь мы получаем массив идентификаторов. Здесь тип переменной a — это что-то похожее на std::array, но сильно дотюненный, чтобы им можно было пользоваться в constexpr выражениях (потому что у нас C++14, а не C++17, где большинство проблем с constexpr для std::arrary поправлено).

В строчке #1 мы создаем интегральную константу от элемента из массива. Интегральная константа — это std::integral_constant первый параметр для которой это size_t_, а вторым параметром будет как раз наша a[I]. size_t_ — это using декларация, алиас. В строчке #2 мы конвертируем идентификатор обратно к типу, а в строчке #3 мы создаем std::tuple, и каждый элемент из этого tuple’а точно соответствует типам данных внутри структуры T, структуры внутри которой мы заглядывали. Теперь мы можем сделать что-нибудь очень стрёмное. Например, reinterpret_cast пользовательской структуры к tuple’у. И мы можем работать с пользовательской структурой как с tuple’ом. Ну да стрёмненько: reinterpret_cast.

Предупреждаю, не пытайтесь скопировать и запустить потому, что код немного упрощен. Например у std::tuple’а не регламентирован порядок создания и уничтожения аргументов: некоторые имплементации инициализируют аргументы с конца вперед, хранят их не в том порядке, из-за чего std::tuple не работает. Надо сделать свой.

Поехали дальше. Что делать с указателями: указатели на константные указатели, на указатели на int’ы и т.п?

У нас имеется функция type_to_id. Она возвращает std::size_t и куча битиков из этого std::size_t у нас не использовано: мы использовали только под 32 фундаментальных типа. Так вот, эти битики можно использовать под кодирование информации об указателе. Например, если у нас в пользовательской структуре содержится поле с типом unsigned char, то в бинарном виде она будет выглядеть следующим образом:

unsigned char c0; // 0b00000000 00000000 00000000 00000001

Наименее значащий бит содержит идентификатор char’а. Это единичка: так мы его назначили в макросе. Если у нас указатель unsigned char, то наиболее значащие битики будут теперь хранить информацию о том, что это указатель:

unsigned char* с1; // 0b00100000 00000000 00000000 00000001

Если у нас указатель константный, то наиболее значащие битики хранят информацию о том, что это константный указатель:

const unsigned char* с2; // 0b01000000 00000000 00000000 00000001

Если мы добавляем дополнительный уровень индирекции (еще один указатель), то другие наиболее старшие битики поменяются и будут хранить информацию о том, что у нас указатель:

const unsigned char** с3; // 0b01000100 00000000 00000000 00000001

Меняем нижележащий тип: наиболее значащие биты не меняются, наименее значащие теперь содержат идентификатор семерку, что означает, что мы работаем с short’ом:

const short** s0; // 0b01000100 00000000 00000000 00000111

Добавляем функции, которые преобразовывают тип в идентификатор (и соответственно добавляют эти битики):

template<class Type>
constexpr std::size_t type_to_id(identity<Type*>)

template<class Type>
constexpr std::size_t type_to_id(identity<const Type*>)

template<class Type>
constexpr std::size_t type_to_id(identity<const volatile Type*>)

template<class Type>
constexpr std::size_t type_to_id(identity<volatile Type*>)

И добавляем обратные функции, которые идентификатор преобразовывают обратно к типу:

template<std::size_t Index> constexpr auto id_to_type(size_t_<Index>,
if_extension<Index, native_const_ptr_type> = 0) noexcept;

template<std::size_t Index> constexpr auto id_to_type(size_t_<Index>,
if_extension<Index, native_ptr_type> = 0) noexcept;

template<std::size_t Index> constexpr auto id_to_type(size_t_<Index>,
if_extension<Index, native_const_volatile_ptr_type> = 0) noexcept;

template<std::size_t Index> constexpr auto id_to_type(size_t_<Index>,
if_extension<Index, native_volatile_ptr_type> = 0) noexcept;

Здесь if_extension — это std::enable_if с алиасами и кучей магии. Магия заключается в том, что, в зависимости от идентификатора, она позволяют вызвать только одну из представленных функций.

Что делать с enum’ами я не знаю. Единственное, что я смог придумать — это вызывать std::underlying_type. То есть мы теряем информацию о том что это за enum: мы не можем зарегистрировать все пользовательские enum’ы в нашем списке фундаментальных типов, это просто невозможно. Вместо этого мы кодируем только то, как этот enum хранится. Eсли он int, то мы будем сохранять его как int, если пользователь указал class enum: char, то мы получим char и будем кодировать только char, информация о типе enum’а потеряется.

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

Допустим у нас структура а, в ней имеется поле тип которого это структура b, мы заглянем внутрь b и все поля из b затащим внутрь a. Я упрощаю: там еще куча логики с alignment’ами чтобы не поломалось.

Делается это так: добавляется одна функция type_to_id:

template <class Type>
constexpr auto type_to_id(identity<Type>, typename std::enable_if<
    !std::is_enum<Type>::value && !std::is_empty<Type>::value>::type*) noexcept 
{
    return array_of_type_ids<Type>(); // Returns array!
}

В этот раз она может возвращать массив (все прошлые возвращали size_t). Нам надо будет поменять нашу структуру ubiq чтобы она могла работать с массивами, добавить логики на то, как определять offset’ы, куда нам писать offset’ы и информацию о том с какой подструктурой мы работаем. Это все долго и не очень интересно, есть пара примеров как это получается, это тоже технические детали.

Что это нам дает и где это все можно использовать? Нет, вам не надо писать это самому т.к есть готовая библиотека, которая все это реализует. Вот что это библиотека дает вам.

Во-первых сравнения: больше не надо самому вручную писать сравнения для
POD-структур. Там есть три метода. Т.е вы можете вообще ничего не писать, а подключить один заголовочный файл и для всех POD-структур у вас будут работать сравнение из коробки.

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

Есть универсальная функция хеширования: передаете туда любую пользовательскую структуру, и она считает от нее хэш.

Операторы ввода-вывода: то что мы уже увидели во введении, тоже всё есть и работает.

Когда я в первый раз рассказывал об этой метапрограммной магии, то очень обрадовались разработчики каких-то железяк (я точно не вспомню). Они говорят, что у них есть 1000 различных плоских структур, которые представляют собой различные протоколы. То есть структура один-в-один мапится на протокол. Для каждой из этих структур у них по три сериализатора (в зависимости от того на каком железе и какие там провода используются в дальнейшем). И того у них 3000 сериализаторов. Они были этим очень недовольны. С помощью этой библиотеке они смогли 3000 сериализаторов упростить до 3-х сериализаторов. Они были безумно рады.

Эти метапрограммные трюки открывают возможность для базовой рефлексии: можно написать новые type_traits, например: is_continuous_layout, is_padded, has_unique_object_representations(как в C++17).

Можно написать замечательные функции punch_hole<T, Index> (которых нету в библиотеки) и которые определяют в пользовательской структуре неиспользованные битики и байтики, возвращают ссылку на них, позволяя другим людям их использовать.

Наконец можно написать более обобщенные алгоритмы: например можно доработать boost::spirit, так, чтобы он сразу парсил в пользовательскую структуру, и чтобы не надо было эту структуру объявлять с помощью макросов из boost::fusion и boost::spirit. Кстати, один из разработчиков boost::spirit ко мне подошел и сказал: “- Это гениально! Я хочу эту штуку, дай мне ссылку на библиотеку”. Я ему дал.

Пара примеров. Есть такая страшная структура:

namespace foo {
    struct comparable_struct {
        int i; short s; char data[50]; bool bl; int a,b,c,d,e,f;
    };
} // namespace foo

std::set<foo::comparable_struct> s;

В ней куча полей. Проблема в том, что мы эту структуру хотим в какой-то контейнер передать. Например в std::set. Без библиотеки пришлось бы писать страшный компаратор для этой структуры. Можно было бы воспользоваться std::tie для того чтобы написать компаратор, но если структура поменяется, то надо помнить, что везде надо внести соответствующие изменения. Ад. Лучше об этом не задумываться. С библиотекой все работает из коробки: берете структуру, пихаете ее в std::set, все работает. Также работает сериализация этой структуры:

std::set<foo::comparable_struct> s = { /* ... */ };
std::ofstream ofs("dump.txt");

for (auto& a: s)
    ofs << a << 'n';

Просто в ofstream гоните переменные ни о чем не думая.

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

std::set<foo::comparable_struct> s;
std::ifstream ifs("dump.txt");

foo::comparable_struct cs;
while (ifs >> cs) {
    char ignore = {};
    ifs >> ignore;
    s.insert(cs);
}

Красота: кода минимум.

Мой любимый пример потому, что он самый бессмысленный, но выглядит красиво. Функция flat_tie: она позволяет вам проинициализировать вашу структуру из std::tuple.

template <class T>
auto flat_tie(T& val) noexcept;

struct my_struct { int i, short s; };
my_struct s;
flat_tie(s) = std::tuple<int, short>{10, 11};

Теперь у вас my_struct::i хранит значение 10, а my_struct::s хранит 11 внутри
структуры s.

Таким образом на этот момент у нас имеется библиотека, которая позволяет вам делать рефлексию, какую-никакую, но работающую в C++14, куча примеров того где это можно использовать.

Но в библиотеке используется reinterpret_cast. А reinterpret_cast’ы я не люблю: они не позволяют делать функции constexpr, да и некрасиво это.

Поэтому давайте попробуем это исправить. Исправим это быстренько и простенько: всего в полмегабайта кода уложимся. Делать мы это будем в C++17. Идея следующая: в C++17 добавили structure binding. Это такая вещь, которая позволяет нам структуру разложить на поля, получить ссылки на поля. Единственная проблема состоит в том, что нужно точно знать количество полей внутри структуры иначе все не соберется, и через enable_if невозможно использовать этот structure binding. Но мы же в начале статьи уже научились получать количество полей внутри структуры. Из количества полей делаем тип данных и используем tag dispatching:

template <class T>
constexpr auto as_tuple(T& val) noexcept {
  typedef size_t_<fields_count<T>()> fields_count_tag;
  return detail::as_tuple_impl(val, fields_count_tag{});
}

Генерим кучу функций as_tuple_impl:

template <class T>
constexpr auto as_tuple_impl(T&& val, size_t_<1>) noexcept {
  auto& [a] = std::forward<T>(val);
  return detail::make_tuple_of_references(a);
}

template <class T>
constexpr auto as_tuple_impl(T&& val, size_t_<2>) noexcept {
  auto& [a,b] = std::forward<T>(val);
  return detail::make_tuple_of_references(a,b);
}

У этих функций as_tuple_impl вторым параметром будет идти количество полей внутри структуры T. Если мы определили, что внутри структуры T у нас одно поле, то вызовется первая функция as_tuple_impl. Она использует structure binding, достает первое поле, делает кортеж в котором будет ссылка на это поле, возвращает этот кортеж пользователю. Если у нас два поля внутри структуры, то вызываем structure binding для двух полей. Это уже вторая функция, она раскладывает пользовательскую структурно на поля а и b, возвращаем кортеж, который хранит ссылки на первое поле и на второе поле. Красота!

Самое приятное, что все это constexpr, и теперь мы можем написать std::get который работает с пользовательскими полями и структурами — и все это на этапе компиляции. Красотень неимоверная. Единственная проблема это то, что код со structure binding еще не тестировался: компиляторы еще не поддерживают structure binding. Поэтому это красивая теория, и скорее всего с парой изменений она заработает.

Оригинальный доклад Антона antoshkka Полухина — https://youtu.be/jDI5CHKFKd0
Библиотека Precise and Flat Reflection (magic_get)

Автор: Анатолий Левчик

Источник

Поделиться

* - обязательные к заполнению поля