Вступление
Всем привет
Меня всегда интересовала тема рефлексии в языках программирования, и то, какие программы можно создавать с ее помощью. Рефлексия — это мощный инструмент, позволяющий работать с программой не как с набором логических объектов (в случае использования ООП), а как с набором свойств и методов из которых они состоят. Такой подход дает возможность создавать алгоритмы, которые могут работать с любыми типами данных, для которых включена поддержка рефлексии.
Во многих языках программирования рефлексия является частью языка. Так например в языке C#, рефлексия используется во многих функциях .Net фреймворка:
-
Сериализация
-
Тестирование
-
Внедрение зависимостей
-
Динамическое построение запросов в EntityFramework
Что же касается C++, то тут рефлексия на уровне языка, вовсе отсутствует. В нем есть rtti, но назвать это полноценной рефлексией трудно, из-за скудного функционала.
Одним из наиболее полноценных решений для C++, которое мне встречалось, это рефлексия в Unreal Engine. С ее помощью в движке реализовано большое количество функционала, например:
-
Система плагинов
-
Возможность модификации состояния С++ объектов через редактор
-
Сериализация
-
RPC (Remote Procedure Call)
-
Вызов С++ функций из Blueprints (система визуального программирования)
Именно знакомство с этим решением вдохновило меня на создание полноценной (насколько это возможно) рефлексии для C++, которую можно было бы использовать в любом проекте.
Требования
Для начала нужно было определится с ключевыми требованиями к будущему решению:
-
Решение которое можно было б использовать в любом C++ проекте, с минимальным количеством зависимостей
-
Возможность выборочного применения рефлексии к типам
-
Автоматическая генерация метаинформации
-
Наличие большинства (по возможности) функций, которые существуют в языках со встроенной рефлексией (за основу взята рефлексия из 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 — указываем уникальный макрос проекта (нужно для динамических библиотек)
Заключение
Данная версия не является финальной, и в ней присутствует много вещей которые можно улучшить и доработать, но даже сейчас с его помощью можно реализовать разные вещи по типу универсальных алгоритмов или системы расширений.
Детальнее с данным решение вы можете ознакомиться в репозитории.
Буду рад любой обратной связи.
Автор: 7Ilya