Язык С++ по сей день является одним из самых востребованных и гибких языков программирования. Но иногда возможностей языка не хватает, несмотря на то что стандарт развивается и расширяется. С такой проблемой столкнулся и я в процессе разработки 2D движка для игр. Я стоял перед необходимостью решения несколько нетривиальных задач, таких как сериализация, анимирование и связка с редактором. Для этого отлично подходит рефлексия. Готовые решения из сети, к сожалению, мне не подошли, поэтому пришлось конструировать свой велосипед.
Далее описаны детали реализации и демо проект. Кому интересно — добро пожаловать под кат.
Требования
Существует мнение, что правильно поставленные условия задачи практически решают эту задачу. Именно поэтому перед началом реализации были собраны необходимые требования и ограничения:
- Минимальный функционал. Пользователь должен иметь доступ к типам объектов и их наследованию. Тип объекта должен предоставлять информацию о членах, его атрибутах, и методах класса. Так же необходим функционал для изменения значений членов класса и вызовов методов через рефлексию.
- Простота в использовании. Использование рефлексиии не должно нагружать синтаксис какими-либо сложностями.
- Быстродействие. Работа с рефлексией не должна ощутимо влиять на производительность приложения.
Синтаксис
Следуя требованию о простоте был выработан довольно простой синтаксис определения объекта поддерживающего рефлексию: необходимо чтобы класс был унаследован от IObject, содержал в теле макрос IOBJECT(*имя класса*); и у членов класса были указаны необходимые атрибуты через комментарии.
Пример класса с поддержкой рефлексии:
class MyClass: public IObject
{
IOBJECT(MyClass);
float mSomeFloat; // @SERIALIZABLE
int mSomeInteger; // @SOME_ATTRIBUTE
MyClass* mSomeObject;
void Func(int abc) { ... }
};
Примеры использования рефлексии:
// Объявим экземпляр класса
MyClass sample;
// Получаем тип класса
Type& myClassType = MyClass::type;
// Получаем тип класса из объекта
Type& myClassTypeToo = sampe.GetType();
// Создать экземпляр класса MyClass
IObject* newSample = myClassType.CreateSample();
// Получение члена класса
FieldInfo* fieldInfo = myClassType.Field("mSomeFloat");
// Получение значения члена класса
float fieldValue = fieldInfo->GetValue<float>(&sampe);
// Присваивание значения члену класса
fieldInfo->SetValue<float>(&sample, 36.6f);
// Получим функцию из типа
FunctionInfo* funcInfo = myClassType.GetFunction("Func");
// Вызовем функцию
funcInfo->Invoke<void, int>(&sample, 123);
Реализация
Как вы наверное уже догадались, работает все просто: пользователь объявляет классы, которые поддерживают рефлексию, а отдельная утилита генерирует код, который инициализирует типы в системе. В данной статье я опишу принцип работы, с минимальным включением исходного кода, так как его наличие раздует статью. Но к статье прилагается демо проект со всеми исходниками, который можно посмотреть и протестировать.
Итак, подробнее о структуре рефлексии.
Тип объекта
Каждому классу соответствует описание типа, в котором содержится имя класса, информация о наследовании, члены и методы класса. С помощью типа можно создать экземпляр класса. Так же в типе класса есть довольно интересный функционал: можно получить указатель на член класса по его пути и наоборот, получить путь к члену класса по его указателю. Чтобы понять что делают последние две функции достаточно взглянуть на пример использования:
class A;
class B;
class MyClass: public IObject
{
IOBJECT(MyClass);
A* aObject = new A();
};
class A: public IObject
{
IOBJECT(A);
B* bObject = new B();
};
class B: public IObject
{
IOBJECT(A);
int value = 777;
};
MyClass sample;
// Получаем путь к члену класса:
string path = sample.GetType().GetFieldPath(&sample->aObject->bObject->value);
// Получаем указатель на член класса:
int* ptr = sample.GetType().GetFieldPtr<int>("aObject/bObject/value");
Зачем это нужно? Ближайший пример — анимация. Допустим, есть анимация, которая анимирует параметр value из примера. Но как сохранить такую анимацию в файл? Здесь и нужны эти функции. Мы сохраняем ключи анимации с указанием пути к анимируемому параметру «aObject/bObject/value», и при загрузке из файла по этому пути находим нужную нам переменную. Такой подход позволяет анимировать совершенно любые члены любых объектов и сохранять/загружать в файл.
Однако, есть небольшой недостаток, который необходимо учитывать. Поиск указателя на член класса по пути происходит быстро и линейно, но обратный процесс совершенно не линеен и может занять много времени. Алгоритму приходится «прочесывать» все члены класса, их члены и так далее, пока не встретит нужный нам указатель.
Члены класса
Каждый член класса определяется с помощью типа FieldInfo, который содержит в себе тип члена, его имя и список атрибутов. Так же тип члена класса позволяет получить значение члена класса для определенного экземпляра и наоборот, присвоить значение. Тип, имя и атрибуты заполняются сгенерированным кодом. Присваивание и получение значения работают через адресную арифметику. На этапе инициализации типа высчитывается смещение в байтах относительно начала памяти под объект, затем, при присваивании или получении значения к адресу объекта прибавляется это смещение.
Функции класса
Каждой функции класса соответствует объект с интерфейсом FunctionInfo, который хранит в себе тип возвращаемого значения, имя функции и список параметров. Почему именно интерфейс и как вызвать функцию из этого интерфейса? Чтобы вызвать функцию нам нужен указатель на нее. Причем, указатель на функцию класса должен быть такого вида:
_res_type(_class_type::*mFunctionPtr)(_args ... args);
Для хранения таких указателей определены классы определены шаблонные классы:
template<typename _res_type, typename ... _args>
class ISpecFunctionInfo: public FunctionInfo
{
public:
virtual _res_type Invoke(void* object, _args ... args) const = 0;
};
template<typename _class_type, typename _res_type, typename ... _args>
class SpecFunctionInfo: public ISpecFunctionInfo<_res_type, _args ...> { ... };
template<typename _class_type, typename _res_type, typename ... _args>
class SpecConstFunctionInfo: public ISpecFunctionInfo<_res_type, _args ...> { ... };
Шаблонная магия пугает, но работает все довольно просто. На этапе инициализации типа для каждой функции создается объект класса SpecFunctionInfo или SpecConstFunctionInfo в зависимости от константности функции и помещается в тип объекта Type. Затем, когда нам нужно вызвать функцию из интерфейса FunctionInfo, мы статически преобразуем интерфейс в ISpecFunctionInfo и вызываем у него виртуальную функцию Invoke, переопределенный вариант которой выполняет функцию по сохраненному адресу.
Атрибуты
Функция атрибутов — это добавление мета информации для членов классов. С помощью атрибутов можно указывать сериализуемые члены, тип их сериализации, отображение в редакторе, параметры анимации и так далее. Атрибуты наследуются от интерфейса IAttribute и добавляются к членам класса на этапе инициализации типа. Пользователю лишь необходимо обозначит необходимые атрибуты через комментарии к членам класса.
Модуль рефлексии
Итак, вся структура типов у нас есть, необходимо все это где-то хранить. С этой простой задачей справляется класс-синглтон Reflection. С его помощью можно получить тип по имени, создать экземпляр типа или получить список всех зарегистрированных типов.
Кодогенерация
Почему именно кодогенерация? Есть решения рефлексии с помощью хитрых макросов, прописывания функции сериализации и тому подобное. Но у таких подходов один общий минус — фактически приходится вручную описывать типы. В рефлексии от этого никуда не избавиться, поэтому проще всего доверить эту рутинную работу отдельной подпрограмме, которая работает до компиляции и генерирует отдельный исходный файл с инициализацией типов.
По-хорошему, это конечно же должен делать сам компилятор, ведь у него есть вся информация о классах, членах и методах. Но решение должно быть не зависимым от компилятора, в конце концов глупо навязывать свой определенный компилятор разработчику, тем более для 2D игр с кучей целевых платформ.
Именно поэтому кодогенерация отдельной утилитой. К сожалению, утилита в моем проекте не может похвастаться изящностью и стопроцентной стабильностью, но на моем уже довольно немаленьком проекте она работает прекрасно. В будущем она наверняка будет переписана и дополнена, так как у нее есть как минимум один существенный минус — она написана на C#, что требует Windows платформы, либо наличия Mono. Это путь наименьшего сопротивления, и я выбрал его потому что на этапе демо версии мне нужно как можно больше функционала в проекте. Тем не менее в этой статье я опишу этапы ее работы и с какими трудностями я столкнулся.
Работа утилиты делится на два этапа:
- Парсинг исходников проекта
- Генерация итогового исходника с инициализацией типов
Парсинг исходников проекта
Здесь мой подход отличается от работы компиляторов.
- Каждый заголовочный файл парсится отдельно, без обработки включения других заголовочных файлов. Проще говоря, все #include игнорируются.
- Строится список заголовочных файлов, в которых есть список пространств имен, в которых список классов и так далее.
- Пространства имен всех заголовочных файлов объединяются и происходит связывание наследования.
- Специализация шаблонных классов: если где-то в коде встречается специализация шаблона, класс с такой специализацией создается и регистрируется.
На выходе мы получаем все пространства имен, классы и описание классов.
Генерация итогового исходника с инициализацией типов
Данный этап включает в себя генерирование следующих частей:
- Объявление статических типов классов
- Объявление функций инициализации типов: регистрирование членов, добавление атрибутов в зависимости от комментариев, регистрирование функций и их параметров
- Функция инициализации всех типов и связывание наследования
В конечном итоге мы получаем исходных файл с функцией, которая инициализирует все типы приложения. Пользователю лишь необходимо ее вызвать при старте приложения.
Недостатки и будущие улучшения
- Конечно же утилита генерации кода далеко не совершенна и скорее всего будет переписана на С++, что даст уменьшение времени работы и мультиплатформенность.
- Время работы утилиты хоть и небольшое (около семи секунд на моем ноутбуке), но всеравно ощутимо.
- Все еще нужно вручную прописывать макрос IOBJECT() в теле класса. Было бы неплохо, чтобы он добавлялся автоматически.
- Статический член класса type является публичным и по неосторожности его можно изменить. Следить за этим можно, но от ошибок никто не застрахован.
- Обозначение атрибутов через комментарии чревато опечатками и не так удобно, как в языках, где поддерживается рефлексия. Пока что приемлемого решения я не нашел.
Ссылка на демо-проект
P.S.: Приветствуются замечания и предложения по улучшению!
Автор: anz