Предисловие


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

Поддерживаются следующие типы данных:
— Все фундаментальные типы С++
— std::string
— std::vector где T — все что угодно из этого же списка
— Любой перечислимый тип

Релизация


В качестве среды разработки я использую Visual Studio 2013, но код решения является кросс-платформенным. Класс, отвечающий за весь функционал я назвал AbstractSaveData. Используется он с помощью наследования. Я решил не делать сам класс шаблонным, так как это сделает его использование довольно не удобным, а в заголовке все таки это слово фигурирует. Вместо этого шаблонными будут только его методы и, таким образом, при использовании этого класса не придется ни разу явно инстанцировать ни один шаблонный метод.

Интерфейс класса составляют следующие методы:
virtual void const* Serialize(int& size) = 0;
virtual void Deserialize(const void* buf, size_t size) = 0;
virtual int SerializedSize()const = 0;
void CleanSerializedBuffer();

Реализация первых трех методов должна быть в классе-потомке.

Метод CleanSerializedBuffer используется для очистки локального буфера с сериализованными данными. В реализации ничего особенного:
void CleanSerializedBuffer()
{
	delete[] serializedBuf;
	serializedBuf = nullptr;
	m_size = 0;
}

Но это только то, что касается public-методов. Классу-потомку, чьи данные подлежат сериализации, предстоит иметь дело со следующими, protected-методами:
template<class ...Ts>
int Serialization(const Ts&... objects);
template<class ...Ts>
void Deserialization(const void* buf, size_t size, Ts&... objects);
template<class T, class ...Ts>
inline int CalculateSize(const T& obj, const Ts&... objects)const;
const char* SerializedBuf()const;

Метод Serialization как не трудно догадаться осуществляет сериализацию. Реализация метода:
template<class ...Ts>
int Serialization(const Ts&... objects)
{
	if (serializedBuf)
		delete[] serializedBuf;
	m_size = CalculateSize(objects...);
	serializedBuf = new char[m_size];
	
	ProcessSerialization(0, objects...);

	return m_size;
}

Сначала вычисляется размер буфера, необходимый для данных и выделяется соответствующее количество памяти. Затем происходит уже непосредственно сериализация данных.

Реализация метода Deserialization по своей структуре схожа с предыдущим:
template<class ...Ts>
void Deserialization(const void* buf, size_t size, Ts&... objects)
{
	if (size)
	{
		int read = CalculateSize(buf, objects...);
		if (read != size)
			throw ApproxException(L"Ошибка десериализации");
		ProcessDeserialization(static_cast<const char*>(buf), objects...);
	}
}

Только здесь вычисление размера нужно только для контроля ошибок.

И наконец, метод CalculateSize, занимающийся расчетом занимаемого объектами места, он имеет 2 варианта:
template<class T>
inline int CalculateSize(const T& obj)const
{
	return reqSize(obj);
}
template<class T, class ...Ts>
inline int CalculateSize(const T& obj, const Ts&... objects)const
{
	return reqSize(obj) + CalculateSize<Ts...>(objects...);
}

Здесь уже можно наблюдать рекурсию как во время компиляции, так и во время выполнения. Знакомым с кортежами в C++ не составит труда понять что здесь происходит. Стоит упомянуть, что всего имеется 4 реализации данного метода, остальные две находятся в private-секции и не вызываются непосредственно из класса-наследника, но вызываются в процессе десериализации.

Ну, и метод SerializedBuf просто возвращает указатель на серилизованные данные:
const char* SerializedBuf()const
{
	return serializedBuf;
}

И, наконец, то что как говорится «под капотом».

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

Всего здесь есть 3 группы методов:

Первая: рекурсивные. Они обеспечивают раскрытие списка аргументов, перемещение по буферу и вызов методов производящих обработку объектов в соответствии с их типом. Это методы с именами CalculateSize, ProcessSerialization, ProcessDeserialization.

Вторая: копирующие. Они осуществляют сериализацию или десериализацию на уровне отдельного объекта и копируют полученное в буфер или из него. Это методы с именами CopyS и CopyD. Методы с именами CopyS используются в процессе сериализации, а CopyD — в десериализации.

Третья: вспомогательные. Они производят расчет занимаемого места на уровне отдельного объекта. Это методы с именем reqSize.
В коде активно применяется явная специализация шаблонов, а так же средства стандартной библиотеки проверки типов std::is_fundamental, std::is_enum и std::is_base_of и вместе с ними std::enable_if. Эти средства позволили отделить объекты с постоянным размером от объектов с переменным размером. Я для наглядности и простоты написал свое средство проверки типа основанное на стандартных:
template<typename T>
struct is_simple
	: std::_Cat_base<std::is_fundamental<T>::value || std::is_enum<T>::value>
{
};

Оно просто объединяет множество фундаметальных типов и типов перечислений, что в нашем случае весьма удобно так как в обоих случаях по типу объекта можно однозначно узнать какого он размера. Для удобства далее будем называть эти типы простыми.
В общем, здесь представлена самая обычная сериализация — в начале 4 байта хранят размер, а затем идут сами данные. Исключение составляют простые типы, им не требуется заголовок, так как информация о их размере соответствует их типу и предоставляется классом-наследником при десериализации. Таким образом уменьшается избыточность данных.

Отдельного внимания следует уделить массивам(векторам). Метод сериализации массива выбирается на основе типа данных, которые он содержит. В данной реализации может произойти 2 варианта: массив простых типов и массив типов, производных от AbstractSaveData. Обычные структуры не поддерживаются, попытка их использования приведет к ошибке компиляции, но их внедрение не проблема, просто в моем проекте это не требуется, к тому же, их применение снимает гарантию успешной сериализации, так как их содержимое неизвестно и может быть любым (указатели, те же строки и векторы), и вместо них можно использовать структуру, с наследованием от AbstractSaveData.

Это, пожалуй, все что я мог рассказать в теории. Вот код:
template<class T>
inline void ProcessDeserialization(const char* buf, T& obj)
{
	CopyD(buf, obj);
}

template<class T, class ...Ts>
inline void ProcessDeserialization(const char* buf, T& obj, Ts&... objects)
{
	ProcessDeserialization<Ts...>(buf + CopyD(buf, obj), objects...);
}

template<class T>
inline void ProcessSerialization(int shift, const T& obj)
{
	CopyS(shift, obj);
}
template<class T, class ...Ts>
inline void ProcessSerialization(int shift, const T& obj, const Ts&... objects)
{
	shift += CopyS(shift, obj);
	ProcessSerialization<Ts...>(shift, objects...);
}
	//Copy Serialization methods begin
template<typename saveData>
inline typename std::enable_if<std::is_base_of<AbstractSaveData, saveData>::value, int>::type CopyS(int shift, saveData& obj)
{
	AbstractSaveData* data = dynamic_cast<AbstractSaveData*>(&obj);
	int size;
	auto ptr = data->Serialize(size);
	size += sizeof(int);
	memcpy(serializedBuf + shift, &size, sizeof(int));
	memcpy(serializedBuf + shift + sizeof(int), ptr, size - sizeof(int));
	data->CleanSerializedBuffer();
	return size;
}

template<typename T>
inline typename std::enable_if<is_simple<T>::value,int>::type CopyS(int shift, const T& obj)
{
	memcpy(serializedBuf + shift, &obj, sizeof(T));
	return sizeof(T);
}
inline int CopyS(int shift, const std::string& obj)
{
	int size = reqSize(obj);
	memcpy(serializedBuf + shift, &size, sizeof(int));
	memcpy(serializedBuf + shift + sizeof(int), obj.c_str(), size - sizeof(int));
	return size;
}

inline int CopyS(int shift, const std::pair<const void*, int>& obj)
{
	memcpy(serializedBuf + shift, &obj.second, sizeof(int));
	memcpy(serializedBuf + shift + sizeof(int), obj.first, obj.second);
	return obj.second + sizeof(int);
}

template<class T>
inline typename std::enable_if<is_simple<T>::value, int>::type CopyS(int shift, const std::vector<T>& obj)
{
	int size = reqSize(obj);
	memcpy(serializedBuf + shift, &size, sizeof(int));
	memcpy(serializedBuf + shift + sizeof(int), obj.data(), size - sizeof(int));
	return size;
}
template<class T>
inline typename std::enable_if<!is_simple<T>::value, int>::type CopyS(int shift, const std::vector<T>& objects)
{
	int size = reqSize(objects);
	memcpy(serializedBuf + shift, &size, sizeof(int));
	for (auto obj : objects)
	{
		shift += CopyS(shift + sizeof(int), obj);
	}
	return size;
}

//Copy Serialization methods end

//Copy Deserialization methods begin
template<class T>
inline typename std::enable_if<is_simple<T>::value, int>::type CopyD(const void* buf, T& obj)
{
	memcpy(&obj, buf, sizeof(T));
	return sizeof(T);
}

inline int CopyD(const void* buf, std::string& obj)
{
	int size = *static_cast<const int*>(buf) - sizeof(int);
	obj.reserve(size);
	obj.assign(size, '0');
	memcpy(&obj[0], static_cast<const char*>(buf)+sizeof(int), size);
	return size + sizeof(int);
}

template<typename T>
inline typename std::enable_if<is_simple<T>::value, int>::type CopyD(const void* buf, std::vector<T>& obj)
{
	int size = *static_cast<const int*>(buf)-sizeof(int);
	obj.reserve(size / sizeof(T));
	obj.assign(obj.capacity(), 0);
	memcpy(obj.data(), static_cast<const char*>(buf) + sizeof(int) , size);
	return size + sizeof(int);
}

template<typename T>
inline typename std::enable_if<!is_simple<T>::value, int>::type CopyD(const void* buf, std::vector<T>& objects)
{
	int size = *static_cast<const int*>(buf);
	int remainedSize = size - sizeof(int);
	while (remainedSize != 0)
	{
		T obj;
		remainedSize -= CopyD(static_cast<const char*>(buf) + size - remainedSize, obj);
		objects.push_back(obj);
		if (remainedSize < 0)
		     throw ApproxException(L"Ошибка десериализации при работе с вектором.");
	}
	return size;
}

template<typename saveData>
inline typename std::enable_if<std::is_base_of<AbstractSaveData, saveData>::value, int>::type CopyD(const void* buf, saveData& obj)
{
	AbstractSaveData* data = dynamic_cast<AbstractSaveData*>(&obj);
	const int size = *static_cast<const int*>(buf);
	data->Deserialize(static_cast<const char*>(buf)+sizeof(int), size - sizeof(int));
	return size;
}
//Copy Deserialization methods end
template<typename simpleType>
inline typename std::enable_if<is_simple<simpleType>::value, int>::type reqSize(simpleType)const
{
	return sizeof(simpleType);
}
		
template<typename simpleType>
inline typename std::enable_if<is_simple<simpleType>::value, int>::type reqSize(const void*, simpleType)const
{
	return sizeof(simpleType);
}

template<typename saveData>
inline typename std::enable_if<std::is_base_of<AbstractSaveData, saveData>::value, int>::type reqSize(const saveData& Data)const
{
	return Data.SerializedSize() + sizeof(int);
}

inline int reqSize(const std::string& obj)const
{
	return obj.size() + sizeof(int);
}

template<class T>
inline typename std::enable_if<is_simple<T>::value, int>::type reqSize(const std::vector<T>& obj)const
{
	return obj.size() * sizeof(T) + sizeof(int);
}

template<class T>
inline typename std::enable_if<!(is_simple<T>::value), int>::type reqSize(const std::vector<T>& objects)const
{
	int res = 0;
	for (auto obj : objects)
	{
		res += reqSize(obj);
	}
	return res + sizeof(int);
}

inline int reqSize(const std::pair<const void*, int>& obj)const
{
	return obj.second + sizeof(int);
}

template<typename notSimpleType>
inline typename std::enable_if<!(is_simple<notSimpleType>::value), int>::type reqSize(const void* buf, const notSimpleType&)const
{
	return *static_cast<const int*>(buf);
}

template<class T>
inline int CalculateSize(const void* buf, const T& obj)const
{
	return reqSize(buf, obj);
}
template<class T, class ...Ts>
inline int CalculateSize(const void* buf, const T& obj, const Ts&... objects)const
{
	int shift = reqSize(buf, obj);
	return shift + CalculateSize<Ts...>(static_cast<const char*>(buf) + shift, objects...);
}

Пример использования


И ради чего все это было? Чтобы можно было написать вот так:
using std::string;
	struct ShaderPart : AbstractSaveData
	{
		string Str_code;
		Shader_Type Shader_Type = ST_NONE;
		string EntryPoint;
		vector<RuntimeBufferInfo> BuffersInfo;
		vector<int> ParamsIDs;
		vector<int> TextureSlots;
		
		const void* Serialize(int& size)override final
		{
			size = Serialization(Str_code, Shader_Type, EntryPoint, BuffersInfo, ParamsIDs, TextureSlots);
			return SerializedBuf();
		}
		void Deserialize(const void* buf, size_t size)override final
		{
			Deserialization(buf, size, Str_code, Shader_Type, EntryPoint, BuffersInfo, ParamsIDs, TextureSlots);
		}
		int SerializedSize()const override final
		{
			return CalculateSize(Str_code, Shader_Type, EntryPoint, BuffersInfo, ParamsIDs, TextureSlots);
		}
	};

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

Всем кто уделил время моей статье — моя благодарность, а тем кто смог дотерпеть до конца еще и уважение.

Комментарии (13)


  1. xrEngine512 Автор
    27.07.2015 11:40
    +3

    Почему то большую часть статьи не видно…


    1. xrEngine512 Автор
      27.07.2015 11:48
      +2

      Теперь заработало. Я уж переживать начал.


  1. kmu1990
    27.07.2015 12:40
    +6

    Удобная сериализация данных с Variadic Templates
    А я не понял, в чем, собственно, удобство? Чем это решение лучше, чем просто операторы << и >> с каким-нибудь специальным классом, представляющим поток сериализованных данных?

    При таком подходе не нужно наследование и не нужен метод SerializedSize. Способ представления сериализованных данных зависит от класса потока, соответственно, не только легко можно подменить одну реализацию другой, но и иметь несколько различных реализаций потоков одновременно. А кроме того, это будет работать без variadic template-ов, а разница в использовании сведется к замене "," в вызове функции на << или >>.


    1. xrEngine512 Автор
      27.07.2015 13:01

      Возможно ваш подход и лучше. Я редко использую потоки, не подумал о них. Я не считаю что мое решение самое удобное. Скажем так: удобнее с ним чем без него. SerializedSize нужен если в списке аргументов есть экземпляр класса, производный от самого AbstractSaveData. Таких вложений может быть сколь угодно, я посчитал эту возможность полезной.


      1. xrEngine512 Автор
        27.07.2015 20:14

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


  1. viatoriche
    27.07.2015 17:45

    Чем это лучше Google Protocol Buffers, который, к тому же, еще и «language-neutral, platform-neutral extensible mechanism for serializing structured data.»?


    1. xrEngine512 Автор
      27.07.2015 19:25

      Насколько я понял из прочитанного по вашей ссылке, там используется внешний генератор исходного кода, так что наверняка лучше, ведь возможностей для анализа данных там больше. Но с другой стороны чтобы мой код работал достаточно только компилятора c++


    1. GooRoo
      27.07.2015 19:45

      Кстати, помимо других похожих либ вроде Thrift или Cap'n Proto, свой достаточно интересный аналог есть и у Microsoft, к тому же написанный на Haskell :) — Microsoft Bond.


  1. NeoCode
    27.07.2015 18:19
    +2

    Основная проблема любых сериализаций (и рефлексии на С++ как таковой) это необходимость описывать поля еще раз, отдельно от самой структуры данных. Решения со встроенным описанием (на boost.preprocessor) слишком громоздки и не все хотят с ними связываться.
    Но если уж описывать отдельно — то хотя-бы один раз. У вас описано три раза (в Serialization, Deserialization и CalculateSize). Есть интересные решения на шаблонах, когда данные описываются один раз, а дополнительным агрументом передается некий объект-обработчик, который делает то что нужно: сериализует и десериализует (причем разные обработчики могут писать/читать в любые форматы — binary, xml, json...), загружает в GUI для унифицированного отображения и т.д.


    1. xrEngine512 Автор
      27.07.2015 19:10

      Чтобы не описывать данные три раза, я в конце предложил использовать кортеж. Можно один раз в нем описать данные и передавать его как аргумент.


  1. CyberZlodey
    27.07.2015 22:05
    +1

    Автору стоит взглянуть вот на это msgpack.org


    1. xrEngine512 Автор
      27.07.2015 22:38

      Спасибо.


  1. SHVV
    28.07.2015 08:53

    А как в вашем случае поддерживать версирование?