Мета-программирование атрибутов для сериализации

Kate

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

В движке рефлексия используется для сериализации. Вкратце работает так:

  • Описываем класс, сериализуемые поля отмечаем атрибутом, через комментарии
struct MyClass: public ISerializable
{
int _myAValue = 0; // @SERIALIZABLE
int _myBValue = 0; // @SERIALIZABLE
int _myCValue = 0;
int _myDValue = 0;
};
  • Утилита кодгена парсит класс, генерирует код-описание класса на макросах
CLASS_FIELDS_META(MyClass)
{
FIELD().DEFAULT_VALUE(0).SERIALIZABLE().NAME(_myAValue);
FIELD().DEFAULT_VALUE(0).SERIALIZABLE().NAME(_myBValue);
FIELD().DEFAULT_VALUE(0).NAME(_myCValue);
FIELD().DEFAULT_VALUE(0).NAME(_myDValue);
}
  • Этот код разворачивается в шаблонную функцию, и в шаблоне мы можем передать свой обработчик.
template<typename Processor>
void MyClass::processFields(Processor& processor, MyClass* object)
{
processor.BeginField().SetDefaultValue(0).AddAttribute<SerializableAttribute>().Complete(object, "_myAValue", object->_myAValue);
processor.BeginField().SetDefaultValue(0).AddAttribute<SerializableAttribute>().Complete(object, "_myBValue", object->_myBValue);
processor.BeginField().SetDefaultValue(0).Complete(object, "_myCValue", object->_myCValue);
processor.BeginField().SetDefaultValue(0).Complete(object, "_myDValue", object->_myDValue);
}
  • При сериализации мы передаем обработчик, который сериализует поля класса в json, например.
struct JsonSerializeProcessor
{
// Вызывается при старте обработки поля класса
FieldProcessor BeginField() const { return FieldProcessor(); }

// Обработчик поля класса
struct FieldProcessor
{
template<typename T>
FieldProcessor& SetDefaultValue(const T& value) { ...; return *this; }

template<typename T, typename ... Args>
FieldProcessor& AddAttribute(Args ... args) { ...; return *this; }

template<typename ObjectType, typename T>
void Complete(ObjectType* object, const char* name, T* fieldPtr)
{
json[name] = *fieldPtr; //Здесь пишем в json значение
}
};
};
Рассмотрим обработку одного поля этим обработчиком

processor.BeginField()
// Возвращаем FieldProcessor
.SetDefaultValue(0)
// Запоминаем дефолтное значение и возвращаем FieldProcessor&
.AddAttribute<SerializableAttribute>()
// Добавляем во внутренний список аттрибут и снова возвращаем FieldProcessor&
.Complete("_myAValue", object->_myAValue);
// Здесь пишем значение в json
Этот обработчик сериализует все поля. Но на не нужно сериализвать все, а только те что помечены специальным атрибутом.

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

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

Здесь на помощь приходит dead code ellimination. Это оптимизация компилятора, которая выкидывает мертвые куски кода, которые ни к чему не приводят. То есть их можно удалить без изменения поведения программы. Например пустые функции.

Что ж, осталось написать наш шаблонный обработчик так, чтобы он выдавал dead code если нет соответствующего атрибута.

struct JsonSerializeProcessor
{
// Вызывается при старте обработки поля класса
FieldProcessor BeginField() const { return FieldProcessor(); }

// Мертвый процессор поля класса, который ничего не делает
struct DeadProcessor
{
template<typename T>
DeadProcessor& SetDefaultValue(const T& value) { return *this; }

template<typename T, typename ... Args>
DeadProcessor& AddAttribute(Args ... args) { return *this; }

template<typename ObjectType, typename T>
void Complete(ObjectType* object, const char* name, T* fieldPtr)
{} // Тут компилятор поймет что код ничего не делает
};

// Обработчик поля класса, который умеет реагировать на атрибуты
struct FieldProcessor
{
template<typename T, typename ... Args>
auto AddAttribute(Args ... args)
{
// Если тип аттрибута не подходит, возвращаем процессор, который приводит к
// dead code
if constexpr (!std::is_same<T, SerializableAttribute>::value)
return DeadProcessor();

return *this;
}

template<typename ObjectType, typename T>
void Complete(ObjectType* object, const char* name, T* fieldPtr)
{
json[name] = *fieldPtr; //Здесь пишем в json значение
}

template<typename T>
FieldProcessor& SetDefaultValue(const T& value) { ...; return *this; }
};
};
Самое интересное место здесь - FieldProcessor::AddAttribute. Именно эта функция меняет поведение в зависимости от типа T. Если этот тип не атрибут сериализации - возвращаем DeadProcessor, который ничего не делает. Иначе, позвращаем себя и в Complete успешно пишем поле в json. Так же нам приходится использовать auto в качестве возвращаемого значения, ведь фактически оно бывает разных типов (FieldProcessor& или DeadProcessor).

Вышло хитро, но не расширяемо. Он умеет реагировать только на один тип атрибута - SerializableAttribute. В идеале бы сделать так, чтобы сам атрибут определял поведение обработчика поля.

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

Для этого будем использовать паттерн mixin'ов - это шаблонные классы, которые могут добавить к какому-либо классу определенный функционал, без привязки к базовому классу. Например:

template<typename Base>
struct Mixin: public Base
{
void MyFunction();
};

class A { int a; };
class B { int b; };

class C: public Mixin<A> {}; // Добавляем функционал Mixin к классу A
class D: public Mixin<B> {}; // Добавляем функционал Mixin к классу B
Теперь попробуем этот подход применить к обработчику полей класса и атрибутам.

Сделаем так, чтобы FieldProcessor мог дополнять себя mixin'ами из атрибутов. И перенесем запись json в mixin атрибута сериализации:

struct SerializableAttribute
{
// Определим mixin, который будет заниматься записью в json
template<typename Base>
struct FieldProcessorMixin: public Base
{
template<typename ObjectType, typename T>
void Complete(ObjectType* object, const char* name, T* fieldPtr)
{
json[name] = *fieldPtr; //Здесь пишем в json значение
}
};
};

struct JsonSerializeProcessor
{
// Вызывается при старте обработки поля класса
FieldProcessor BeginField() const { return FieldProcessor(); }

// Обработчик поля класса, который умеет реагировать на атрибуты
struct FieldProcessor
{
template<typename T, typename ... Args>
auto AddAttribute(Args ... args)
{
// С помощью шаблонной магии проверяем определен ли класс FieldProcessorMixin
// в аттрибуте T. Если да - возращаем mixin Из него
if constexpr (HasFieldProcessorMixin<T>::value)
return T::FieldProcessorMixin<FieldProcessor>();

return *this;
}

template<typename ObjectType, typename T>
void Complete(ObjectType* object, const char* name, T* fieldPtr)
{} // Теперь тут ничего не делаем, запись перенесена в mixin

template<typename T>
FieldProcessor& SetDefaultValue(const T& value) { ...; return *this; }
};
};
Окей, теперь посмотрим как эта конструкция сработает на примере сериализуемого поля класса:

processor.BeginField()
// Тут возвращаем наш базовый FieldProcessor
.SetDefaultValue(0)
// Все еще возвращаем FieldProcessor
.AddAttribute<SerializableAttribute>()
// Тут уже вернется SerializableAttribute::FieldProcessorMixin<FieldProcessor>
.Complete("_myAValue", object->_myAValue);
// Вызовется SerializableAttribute::FieldProcessorMixin<FieldProcessor>::Complete,
// которая запишет значение в json
и как для не сериализуемого поля:

processor.BeginField()
// Тут возвращаем наш базовый FieldProcessor
.SetDefaultValue(0)
// Все еще возвращаем FieldProcessor
.Complete("_myСValue", object->_myСValue);
// Вызовется FieldProcessor::Complete,
// которая ничего не делает. Компилятор вырежет эту цепочку вызовов полностью
Класс! Мы через класс атрибута меняем поведение обработчика! Однако, здесь есть нюанс - это работает только если у поля задан один единственный атрибут. Mixin в качестве базового класса всегда получает FieldProcessor, и забывает о предыдущих атрибутах.

T::FieldProcessorMixin<FieldProcessor>()

То есть в Base мы как-то должны передавать все накопленные mixin'ы.

К сожалению единственное решение - в каждом mixin'е атрибутов определять метод AddAttribute. Что ж, попробуем хотя бы обобщить:

struct SerializableAttribute
{
// Определим mixin, который будет заниматься записью в json
template<typename Base>
struct FieldProcessorMixin: public Base
{
// Добавляем технический метод добавления аттрибута,
// Который вполне можно завернуть в макрос ATTRIBUTE()
template<typename T, typename ... Args>
auto AddAttribute(Args ... args)
{
// Используем обобщенную функцию FieldProcessor::AddAttributeImpl
// В качестве Base передаем текущий тип класса, в котором уже накоплены
// все предыдущие Mixin'ы
return Base::AddAttributeImpl<T, FieldProcessorMixin<Base>, Args ...>(args ...);
}

template<typename ObjectType, typename T>
void Complete(ObjectType* object, const char* name, T* fieldPtr)
{
json[name] = *fieldPtr; //Здесь пишем в json значение
}
};
};

struct JsonSerializeProcessor
{
// Вызывается при старте обработки поля класса
FieldProcessor BeginField() const { return FieldProcessor(); }

// Обработчик поля класса, который умеет реагировать на аттрибуты
struct FieldProcessor
{
// Обобщенная функция с шаблонным Base
template<typename T, typename Base, typename ... Args>
auto AddAttributeImpl(Args ... args)
{
if constexpr (HasFieldProcessorMixin<T>::value)
return T::FieldProcessorMixin<Base>(args ...);
}

template<typename T, typename ... Args>
auto AddAttribute(Args ... args)
{
// Используем обобщенный метод, в Base передаем сами себя
return AddAttributeImpl<T, FieldProcessor, Args ...>(args ...);
}

template<typename ObjectType, typename T>
void Complete(ObjectType* object, const char* name, T* fieldPtr)
{} // Тут ничего не делаем, запись перенесена в mixin

template<typename T>
FieldProcessor& SetDefaultValue(const T& value) { ...; return *this; }
};
};
Теперь нужен какой-то пример, чтобы увидеть систему в действии. Добавим атрибут, который добавляет условие к сериализации.

struct SerializeIfAttribute
{
// Определим mixin, который вызывает функцию IsSerializable у object, и вызывает
// базовый Complete, если она возвращает true
template<typename Base>
struct FieldProcessorMixin: public Base
{
// Используем макрос
ATTRIBUTE();

template<typename ObjectType, typename T>
void Complete(ObjectType* object, const char* name, T* fieldPtr)
{
// Вызов определенной функции
if (object->IsSerializable())
Base::Complete(name, fieldPtr);
}
};
};
Посмотрим как это работает:

processor.BeginField()
// Тут возвращаем наш базовый FieldProcessor
.SetDefaultValue(0)
// Все еще возвращаем FieldProcessor
.AddAttribute<SerializableAttribute>()
// Тут уже вернется SerializableAttribute::FieldProcessorMixin<FieldProcessor>
.AddAttribute<SerializeIfAttribute>()
// Тут уже вернется SerializeIfAttribute::FieldProcessorMixin<SerializableAttribute::FieldProcessorMixin<FieldProcessor>>
.Complete("_myAValue", object->_myAValue);
// В этой функции сначала вызовется преверка функции в SerializeIfAttribute::FieldProcessorMixin::Complete
// Если она проходит, то вызывается SerializableAttribute::FieldProcessorMixin::Complete
// Которая уже запишет значение в json
С помощью mixin'ов можно добавить проверку дефолтного значения, чтобы не записывать их в json.

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

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


Источник статьи: https://habr.com/ru/post/558480/
 
Сверху