Pull to refresh

Рефлексия и кодогенерация в C++

Reading time7 min
Views26K
Язык С++ по сей день является одним из самых востребованных и гибких языков программирования. Но иногда возможностей языка не хватает, несмотря на то что стандарт развивается и расширяется. С такой проблемой столкнулся и я в процессе разработки 2D движка для игр. Я стоял перед необходимостью решения несколько нетривиальных задач, таких как сериализация, анимирование и связка с редактором. Для этого отлично подходит рефлексия. Готовые решения из сети, к сожалению, мне не подошли, поэтому пришлось конструировать свой велосипед.

Далее описаны детали реализации и демо проект. Кому интересно — добро пожаловать под кат.

Требования


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

  1. Минимальный функционал. Пользователь должен иметь доступ к типам объектов и их наследованию. Тип объекта должен предоставлять информацию о членах, его атрибутах, и методах класса. Так же необходим функционал для изменения значений членов класса и вызовов методов через рефлексию.
  2. Простота в использовании. Использование рефлексиии не должно нагружать синтаксис какими-либо сложностями.
  3. Быстродействие. Работа с рефлексией не должна ощутимо влиять на производительность приложения.

Синтаксис


Следуя требованию о простоте был выработан довольно простой синтаксис определения объекта поддерживающего рефлексию: необходимо чтобы класс был унаследован от 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. Это путь наименьшего сопротивления, и я выбрал его потому что на этапе демо версии мне нужно как можно больше функционала в проекте. Тем не менее в этой статье я опишу этапы ее работы и с какими трудностями я столкнулся.

Работа утилиты делится на два этапа:

  1. Парсинг исходников проекта
  2. Генерация итогового исходника с инициализацией типов

Парсинг исходников проекта


Здесь мой подход отличается от работы компиляторов.

  • Каждый заголовочный файл парсится отдельно, без обработки включения других заголовочных файлов. Проще говоря, все #include игнорируются.
  • Строится список заголовочных файлов, в которых есть список пространств имен, в которых список классов и так далее.
  • Пространства имен всех заголовочных файлов объединяются и происходит связывание наследования.
  • Специализация шаблонных классов: если где-то в коде встречается специализация шаблона, класс с такой специализацией создается и регистрируется.

На выходе мы получаем все пространства имен, классы и описание классов.

Генерация итогового исходника с инициализацией типов


Данный этап включает в себя генерирование следующих частей:

  • Объявление статических типов классов
  • Объявление функций инициализации типов: регистрирование членов, добавление атрибутов в зависимости от комментариев, регистрирование функций и их параметров
  • Функция инициализации всех типов и связывание наследования


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

Недостатки и будущие улучшения


  1. Конечно же утилита генерации кода далеко не совершенна и скорее всего будет переписана на С++, что даст уменьшение времени работы и мультиплатформенность.
  2. Время работы утилиты хоть и небольшое (около семи секунд на моем ноутбуке), но всеравно ощутимо.
  3. Все еще нужно вручную прописывать макрос IOBJECT() в теле класса. Было бы неплохо, чтобы он добавлялся автоматически.
  4. Статический член класса type является публичным и по неосторожности его можно изменить. Следить за этим можно, но от ошибок никто не застрахован.
  5. Обозначение атрибутов через комментарии чревато опечатками и не так удобно, как в языках, где поддерживается рефлексия. Пока что приемлемого решения я не нашел.

Ссылка на демо-проект


github.com

P.S.: Приветствуются замечания и предложения по улучшению!
Tags:
Hubs:
Total votes 11: ↑11 and ↓0+11
Comments19

Articles