Вступление
Всем привет
Меня всегда интересовала тема рефлексии в языках программирования, и то, какие программы можно создавать с ее помощью. Рефлексия — это мощный инструмент, позволяющий работать с программой не как с набором логических объектов (в случае использования ООП), а как с набором свойств и методов из которых они состоят. Такой подход дает возможность создавать алгоритмы, которые могут работать с любыми типами данных, для которых включена поддержка рефлексии.
Во многих языках программирования рефлексия является частью языка. Так например в языке C#, рефлексия используется во многих функциях .Net фреймворка:
Сериализация
Тестирование
Внедрение зависимостей
Динамическое построение запросов в EntityFramework
Что же касается C++, то тут рефлексия на уровне языка, вовсе отсутствует. В нем есть rtti, но назвать это полноценной рефлексией трудно, из-за скудного функционала.
Одним из наиболее полноценных решений для C++, которое мне встречалось, это рефлексия в Unreal Engine. С ее помощью в движке реализовано большое количество функционала, например:
Система плагинов
Возможность модификации состояния С++ объектов через редактор
Сериализация
RPC (Remote Procedure Call)
Вызов С++ функций из Blueprints (система визуального программирования)
Именно знакомство с этим решением вдохновило меня на создание полноценной (насколько это возможно) рефлексии для C++, которую можно было бы использовать в любом проекте.
Реализация
Требования
Для начала нужно было определится с ключевыми требованиями к будущему решению:
Решение которое можно было б использовать в любом C++ проекте, с минимальным количеством зависимостей
Возможность выборочного применения рефлексии к типам
Автоматическая генерация метаинформации
Наличие большинства (по возможности) функций, которые существуют в языках со встроенной рефлексией (за основу взята рефлексия из C#)
Принцип работы
Как я говорил ранее, одним из ключевых источников вдохновения для создания моего решения стала система рефлексии в Unreal Engine. В целом, основной принцип работы позаимствован именно оттуда, за исключения некоторых отличий. Система работает следующим образом.
Генерация метаинформации: проект использует утилиту MetaGenerator, которая отвечает за автоматическое создание метаинформации. При запуске MetaGenerator парсит все указанные файлы проекта, и на их основе генерирует дополнительные программные файлы, которые содержат в себе описание типов данных. Таким образом, вся метаинформация становится частью проекта, и попадает в конечную сборку.
Доступ к метаинформации в рантайме: для работы с метаинформацией в рантайме используется библиотека CppReflection.
Использование и возможности CppReflection
Сразу хочу ввести такое понятие как программная сущность (ПС), под которым я подразумеваю любую конструкцию языка C++, вроде класса, поля, метода, перечисления и тд.
Маркеры
Маркеры — это обыкновенные макросы которые используются для обозначения того к каким ПС будет применена рефлексии. Именно благодаря маркерам, MetaGenerator понимает для каких сущностей нужно генерировать метаинформацию. На данный момент существуют следующие маркеры:
REFLECTABLE() — примеряется к классам, шаблонам и перечислениям
FIELD() — примеряется к полям класса
METHOD() — примеряется к методам класса
CONSTRUCTOR() — примеряется к конструкторам класса
REFLECTABLE() class Calculator { #include "Generation/Calculator.meta.inl" FIELD() int m_A; int m_B; CONSTRUCTOR() Calculator(int a, int b) : m_A(a), m_B(b) { } METHOD() int Calculate(int op) const { switch (op) { case 1: return m_A + m_B; case 2: return m_A - m_B; case 3: return m_A * m_B; case 4: return m_A / m_B; } } METHOD() static const char* GetModelName() { return "MyCalculator"; } }; REFLECTABLE() enum class Numebers { ONE = 1, TWO, THREE };
Класс Type
Type является ключевым классом который содержит в себе основную информацию об определенном типе данных. С его помощью можно получить такую информацию о типе как имя, размер, атрибуты, родительские типы и так далее.
Есть несколько способов получить информацию о типе.
Первый способ — использовать шаблон TypeOf. С его помощью можно статически получить информацию о типе.
if (auto type = TypeOf<Calculator>::Get()) { auto name = type->GetName(); auto size = type->GetSize(); auto isEnum = type->IsEnum(); // что-то шаманим }
Второй способ — использовать метод GetType, который генерируется внутри Reflectable класса. Преимущество данного способа заключается в том, что он использует виртуальный метод, что дает возможность получить реальный тип объекта, через базовый указатель.
Calculator* obj = new SuperCalculator(); if (auto type = obj->GetType()) // вернет тип SuperCalculator { // что-то шаманим }
Кроме того, существует возможность получения типа по имени, но об этом чуть позже.
Помимо общей информации о типе, можно также получить информацию о его полях, методах и родительских типах.
Например, можно найти поле по имени, узнать тип, спецификаторы (является ли поле константным, статическим или указателем), а также получить или изменить его значение.
FieldPtr fieldPtr = type->GetFieldPtr("m_A"); if (fieldPtr != nullptr) { int32_t* valuePtr = fieldPtr.GetValue<int32_t>(obj); *valuePtr = 123456789; }
Если с полями всё достаточно просто и понятно, то вот с методами дела обстоят чуть интереснее. Помимо получения общей информации, можно проверить их сигнатуру, а также вызвать их.
if (const MethodInfo* method = type->GetMethod("GetModelName")) { using ReturnType = const char*; if (method->IsStatic() && method->CheckSignature<ReturnType>()) { const char* name = method->Invoke<ReturnType>(); } }
Перечисления
Доступ к информации о перечислениях осуществляется точно так же, как и к классам, только используются другие методы.
TypePtr type = TypeOf<Numebers>::Get(); TypePtr baseType = type->GetEnumBaseType(); for (auto& info : type->GetEnumValues()) { auto name = info.name; uint64_t value = info.value; }
Атрибуты
Атрибуты — это мощный инструмент, позволяющий добавлять дополнительную добавлять дополнительную метаинформации к вашим ПС. Для создания пользовательского атрибута нужно унаследоваться от базового класса Attribute.
Чтобы добавить атрибут к сущности, его следует объявить внутри маркера, используя корректную сигнатуру конструктора.
REFLECTABLE() class DisplayName : public Reflection::Attribute { #include "Generation/DisplayName.meta.inl" private: const char* m_Name; public: DisplayName(const char* name) : m_Name(name) { } const char* GetName() const { return m_Name; } }; ... // добавляем атрибут к полю внутри класса Calculator FIELD(DisplayName("Super Integer")) int32_t m_A;
После этого, вы можете извлекать атрибуты из ПС, в рантайме.
if (const FieldInfo* info = type->GetField("m_A")) { if (auto displayName = info->GetAttribute<DisplayName>()) { auto name = displayName->GetName(); } }
Сборки
Если вы задаетесь вопросом, где же динамика, то сейчас она появится. Во всех предыдущих примерах, демонстрирующих основной функционал, предполагалось что программа работает только с собственными типами или со статическими зависимостями (то есть все используемые типы известны во время компиляции). Но что если мы хотим работать с каким нибудь неизвестным типом из динамически загружаемой библиотеки? В таком случае мы можем использовать класс Assembly.
Assembly предоставляет возможность загружать библиотеки и извлекать из них все Reflectable типы. Использование сборок, позв��ляет вам работать с неизвестными типами и модифицировать поведение программы в рантайме.
const Assembly* plugin = Assembly::Load("MyPlugin"); // Загружаем плагин if (plugin == nullptr) return; const TypePtr type = plugin->GetType("Calculator"); // Находим нужный тип if (type == nullptr) return; const LifetimeControl* lifetimeControl = type->GetLifetimeControl(); // Смотрим можно ли создать объект if (lifetimeControl == nullptr) return; const ConstructorInfo* constructor = lifetimeControl->GetConstructor<int32_t, int32_t>(); // Находим нужный конструктор (по сигнатуре) auto obj = malloc(type->GetSize()); // Выделяем память для объекта // Инициализация объекта { uint32_t args[2] = { 3, 4 }; // Все аргументы упаковываются в один буфер InvokeInfo invoke = { .result = obj, .args = args }; constructor->ConstructExplicit(&invoke); // Вызываем контруктор } if (const MethodInfo* method = type->GetMethod("Calculate")) { if (method->IsStatic() == false) { int subOperation = 2; ArgumentsPack pack(obj, subOperation); // Для нестатических методов, всегда 1ый агрумент - объект int result; const InvokeInfo invoke = { .result = &result, .args = pack.Get() }; method->InvokeExplicit(&invoke); // Вызываем метод } } lifetimeControl->Destroy(obj); // Вызываем деструктор free(obj); // Освобождаем память объекта Assembly::Free(plugin); // Выгружаем плагин
Зачастую с неизвестными объектами, работают через заранее известные интерфейсы. CppReflection предоставляет возможность, приводит объекты, как базовому, так и к производному типу (аналог dynamic_cast). Если предыдущий пример переделать так, чтобы Calculator реализовывал интерфейс ICalculator, то можно будет сделать следующее:
ICalculator* calculator = Cast<ICalculator>(obj, type); int product = calculator->Calculate(3);
Адаптация под C++
Одним из второстепенных требований к данному решению была адаптация под C++. Под этим подразумевалось не только поддержка особых конструкций языка (вроде указателей, ссылок или множественного наследования), но и максимальную гибкость в использовании. Например, вы спокойно можете создать Reflectable тип, и унаследовать его от не Reflectable типа, и наоборот. Или создать Reflectable поле, тип которого будет не Reflectable.
Замечание
Конечно такой подход делает рефлексию максимально гибкой, как и задумывалось, но имеет подводные камни которые нужно учитывать. Помимо очевидных вещей, вроде, что вы не сможете получить тип поля, имеющего не Reflectable тип (а только его имя, спецификаторы и позицию), есть более неявные недостатки. Менее очевидный недостаток проявляется в сложных иерархиях классов, где Reflectable и не Reflectable типы чередуются. В такой ситуации, имея ссылку на фактический объект, невозможно получить полную цепочку базовых классов, поскольку нельзя определить типы, от которых наследуется не Reflectable класс.
MetaGenerator и настройка проекта
С функционалом вроде разобрались, теперь немного поговорим о генераторе и настройке проекта. Генератор — это утилита, которая занимается анализом и генерацией дополнительных программных файлов.
Генератор должен запускаться после внесения изменений в заголовочные файлы с Reflectable типами, чтобы сгенерировать актуальную информацию. Можно настроить запуск перед компиляцией в ide или вызывать его вручную - кому как удобно.
Также стоит упомянуть какие именно файлы генерируются.
(ProjectName)ReflectionInclude — это основной заголовочный файл, содержащий TypeOf для всех Reflectable типов текущей сборки. Это единственный файл который вы должны включить в заголовки с Reflectable типами, а также в файлы других проектов, которые имеют зависимость от данного проекта.
Inline файлы — эти файлы генерируются для каждого Reflectable класса, и содержат метод GetType и другую дополнительную информацию. Его нужно вручную включать в тело класса.
Пример вызова генератора
MetaGenerator.exe ../MyProject/ analyze_dirs=Include/MyProject gen_dir=Include dll_export=MY_PROJECT_DLL_EXPORT
Первый аргумент — директория проекта
analyze_dirs — указываем из какой директории парсить файлы
gen_dir — указываем где будут находиться все сгенерированные файлы
dll_export — указываем уникальный макрос проекта (нужно для динамических библиотек)
Заключение
Данная версия не является финальной, и в ней присутствует много вещей которые можно улучшить и доработать, но даже сейчас с его помощью можно реализовать разные вещи по типу универсальных алгоритмов или системы расширений.
Детальнее с данным решение вы можете ознакомиться в репозитории.
Буду рад любой обратной связи.
