Написать этот пост меня вдохновила замечательная статья в блоге Gaffer on Games «Reading and Writing Packets» и неуёмная тяга автоматизировать всё и вся (особенно написание кода на C++!).
Начнём с постановки задачи. Мы пишем сетевую игру (и сразу MMORPG, конечно же!), и независимо от архитектуры у нас возникает необходимость постоянно посылать и получать данные по сети. У нас, скорее всего, возникнет необходимость посылать несколько разных типов пакетов (действия игроков, обновления игрового мира, просто-напросто аутентификация, в конце концов!), и для каждого у нас должна быть функция чтения и функция записи. Казалось бы, не вопрос сесть и написать спокойно эти две функции и не нервничать, однако у нас сразу же возникает ряд проблем.
Всех интересующихся тем, как Бендер выполнил своё обещание и при этом решил обозначенные проблемы, прошу под кат.
Начнём с начальных предположений. Мы хотим уметь писать и читать текстовый и бинарный формат; пусть текстовый формат будет читаться и писаться из/в стандартные потоки STL (
Понятно, что теперь нам нужны базовые «кирпичики» сериализатора: функции или объекты, умеющие сериализовывать и парсить целые числа или числа с плавающей точкой. Однако, мы (конечно же!) хотим расширяемости, т.е. чтобы программист мог написать «кирпичик» для сериализации любого своего типа данных и использовать его в нашем сериализаторе. Как такой кирпичик должен выглядеть? Я предлагаю простейший формат:
Просто класс с двумя статическими методами и, возможно, неограниченным числом их перегрузок. (Так, вместо одного шаблонного метода допускается написать несколько: один для
Например, для сериализации и парсинга динамического массива элементов интерфейс может выглядеть так:
Вспомогательные шаблоны
Далее нам потребуется возможность проверять, может ли такое-то поле запускать сериализацию/парсинг с такими-то аргументами. Здесь мы приходим к более подробному обсуждению variadic tempates и SFINAE.
Начнём с кода:
Что это? Это структура, на этапе компиляции определяющая по заданному классу
Как это работает? Более тонкий вопрос, давайте разберёмся.
Начнём с класса
Далее. У нас есть шаблон
В её коде есть вызов
Конструкция
Так вот. Если вызов внутри
В качестве пищи для самостоятельного размышления приведу также код шаблона
Наконец, время заняться содержательной частью сериализатора. Вкратце, мы хотим получить шаблонный класс
Начнём с простого — объявления шаблонного класса (с переменным числом аргументов, ня!) и конца рекурсии.
Но как должен выглядеть код функции
Для такой рекурсии по числу аргументов нам потребуется вспомогательный класс (основной класс
Тогда частичная специализация
Теперь напишем рекурсию для
Здесь мы подошли ко второму концепту, обещанному Бендером — perfect forwarding. Нам пришли лишние аргументы (возможно, и ноль аргументов, но скорее всего нет), и мы хотим отправить их дальше, в
Допустим, вы хотите написать враппер вокруг шаблонной функции
Придётся написать оба варианта, чтобы компилятор мог выбрать и полная совместимость присутствовала в обоих случаях:
Для борьбы с этим C++11 вводит rvalue reference и новые правила вычисления типов. Теперь можно написать просто
Итак, конструкция
Продолжим писать рекурсию для
Шаг рекурсии для
Иииии… это всё! На этом наш сериализатор (в самых общих чертах) готов, и простейший код
Раз наши схемы имеют такой же интерфейс, как и простые поля, почему бы не сделать схему из схем?
Дело в том, что
Преимущество SFINAE выползло здесь как недостаток. Компилятор не компилирует функцию прежде чем определить, можно её вызвать с заданными аргументами или нет; он лишь смотрит на её тип. Чтобы устранить это нежелательное поведение, мы должны заставить
Делать это будем сразу для
Что произошло? Во-первых, мы использовали новый синтаксис. Начиная с С++11, эквивалентны следующие способы задания типа результата функции:
Зачем это нужно? В ряде случаев так удобнее. Например, мы смогли добиться желаемого, не используя снова фокус с
А чего мы, собственно, добились? А вот чего: если рекурсия ломается и
Отлично! Теперь чуть менее простой код
компилируется и радостно печатает
Мы сделали это!
C++ проделал большой путь, и стандарт C++11 был особенно большим шагом. Мы планомерно использовали почти все его нововведения, чтобы реализовать чистый и красивый сериализатор, чего только не поддерживающий. Он терпит произвольное число аргументов для каждого поля, терпит произвольное количество шаблонных и нешаблонных перегрузок функции
Побаловаться с кодом можно в репозитории на Github.
Об ошибках и неточностях непременно пишите в комментарии или (лучше) в личку. Возможно, статья сможет освободиться от них и даже стать приличным учебным материалом! Спасибо за внимание.
Начнём с постановки задачи. Мы пишем сетевую игру (и сразу MMORPG, конечно же!), и независимо от архитектуры у нас возникает необходимость постоянно посылать и получать данные по сети. У нас, скорее всего, возникнет необходимость посылать несколько разных типов пакетов (действия игроков, обновления игрового мира, просто-напросто аутентификация, в конце концов!), и для каждого у нас должна быть функция чтения и функция записи. Казалось бы, не вопрос сесть и написать спокойно эти две функции и не нервничать, однако у нас сразу же возникает ряд проблем.
- Выбор формата. Если бы мы писали простенькую игру на JavaScript, нас бы устроил JSON или любой его самописный родственник. Но мы пишем серьёзную многопользовательскую игру, требовательную к трафику; мы не можем позволить себе отправлять ~16 байт на float вместо четырёх. Значит, нам нужен «сырой» двоичный формат. Однако, двоичные данные усложняют отладку; было бы здорово, если бы мы могли менять формат в любой момент, не переписывая целиком все наши функции чтения/записи.
- Проблемы безопасности. Первое правило сетевой игры: не доверяй данным, присланным клиентом! Функция чтения должна уметь оборваться в любой момент и вернуть
false
, если что-то пошло не так. При этом использовать исключения считается неважной идеей, поскольку они слишком медленные. Мамкин хакер пусть и не сломает ваш сервер, но вполне может ощутимо замедлить его беспрерывными эксепшнами. Но вручную писать код, состоящий из if'ов и return'ов, неприятно и неэстетично. - Повторяющийся код. Функции чтения и записи похожи, да не совсем. Необходимость изменить структуру пакета приводит к необходимости поменять две функции, что рано или поздно приведёт к тому, что вы забудете поменять одну из них или поменяете их по-разному, что приведёт к трудно отлавливаемым багам. Как справедливо замечает Gaffer on Games, it is really bloody annoying to maintain separate read and write functions.
Всех интересующихся тем, как Бендер выполнил своё обещание и при этом решил обозначенные проблемы, прошу под кат.
Потоки чтения и записи
Начнём с начальных предположений. Мы хотим уметь писать и читать текстовый и бинарный формат; пусть текстовый формат будет читаться и писаться из/в стандартные потоки STL (
std::basic_istream
и std::basic_ostream
, соответственно). Для бинарного формата у нас будет свой класс BitStream
, поддерживающий аналогичный потокам STL интерфейс (как минимум операторы <<
и >>
, метод rdstate()
, возвращающий 0 при отсутствии ошибок чтения/записи и не 0 в остальных случаях, и способность кушать манипуляторы); так же было бы здорово, если бы он умел писать и читать данные длины, не кратной восьми битам.Возможный интерфейс класса BitStream
using byte = uint8_t;
class BitStream {
byte* bdata;
uint64_t position;
uint64_t length, allocated;
int mode; // 0 = read, other = write
int state; // 0 = OK
void reallocate(size_t);
public:
static const int MODE_READ = 0; // здесь, конечно же, нужен модный
static const int MODE_WRITE = 1; // enum class, но пока забьём
inline int get_mode(void) const noexcept { return mode; }
BitStream(void); // для записи
BitStream(void*, uint64_t); // для чтения
~BitStream(void);
int rdstate(void) const;
// записать младшие how_much бит:
void write_bits(char how_much, uint64_t bits);
// прочитать how_much бит в младшие биты результата:
uint64_t read_bits(char how_much);
void* data(void);
BitStream& operator<<(BitStream&(*func)(BitStream&)); // вкусные
BitStream& operator>>(BitStream&(*func)(BitStream&)); // манипуляторы
};
template<typename Int>
typename std::enable_if<std::is_integral<Int>::value, BitStream&>::type
operator<<(BitStream& out, const Int& arg); // записать 8*sizeof(Int) бит в поток
template<typename Int>
typename std::enable_if<std::is_integral<Int>::value, BitStream&>::type
operator>>(BitStream& in, Int& arg); // прочитать 8*sizeof(Int) бит из потока
Зачем здесь enable_if и как он работает?
std::enable_if<condition, T>
проверяет условие condition
и, если оно выполнено (т.е. не равно нулю), определяет тип std::enable_if<...>::type
, равный указанному пользователем типу T
или (по умолчанию) void
. Если условие не выполнено, обращение к std::enable_if<...>::type
выдаёт undefined; такая ошибка помешает скомпилироваться нашему шаблону, но не помешает скомпилироваться программе, поскольку substitution failure is not an error (SFINAE) – ошибка при подстановке аргументов в шаблон не является ошибкой компиляции. Программа успешно скомпилируется, если где-то определена другая реализация operator<<
с подходящей сигнатурой, или скажет, что подходящей для вызова функции просто нет (умный компилятор, возможно, уточнит, что он пытался, но у него случилось SFINAE).Интерфейс сериализатора
Понятно, что теперь нам нужны базовые «кирпичики» сериализатора: функции или объекты, умеющие сериализовывать и парсить целые числа или числа с плавающей точкой. Однако, мы (конечно же!) хотим расширяемости, т.е. чтобы программист мог написать «кирпичик» для сериализации любого своего типа данных и использовать его в нашем сериализаторе. Как такой кирпичик должен выглядеть? Я предлагаю простейший формат:
struct IntegerField {
template<class OutputStream>
static void serialize(OutputStream& out, int t) {
out << t; // просто скормить сериализуемый объект в поток!
} // эту функцию тоже можно заставить возвращать bool, но пока забьём
template<class InputStream>
static bool deserialize(InputStream& in, int& t) {
in >> t; // просто вытащить считываемый объект из потока!
return !in.rdstate(); // вернуть true, если при чтении не произошло ошибок
}
};
Просто класс с двумя статическими методами и, возможно, неограниченным числом их перегрузок. (Так, вместо одного шаблонного метода допускается написать несколько: один для
std::basic_ostream
, один для BitStream
, неограниченное количество для любых других стримов на вкус программиста.)Например, для сериализации и парсинга динамического массива элементов интерфейс может выглядеть так:
template<typename T>
struct ArrayField {
template<class OutputStream>
static void serialize(OutputStream& out, size_t n, const T* data);
template<class OutputStream>
static void serialize(OutputStream& out, const std::vector<T>& data);
template<class InputStream>
static bool deserialize(InputStream& in, size_t& n, T*& data);
template<class InputStream>
static bool deserialize(InputStream& in, std::vector<T>& data);
};
Вспомогательные шаблоны can_serialize
и can_deserialize
Далее нам потребуется возможность проверять, может ли такое-то поле запускать сериализацию/парсинг с такими-то аргументами. Здесь мы приходим к более подробному обсуждению variadic tempates и SFINAE.
Начнём с кода:
template<typename... Types>
struct TypeList { // просто вспомогательный класс, статический «список типов»
static const size_t length = sizeof...(Types);
};
template<typename F, typename L> class can_serialize;
template<typename F, typename... Ts>
class can_serialize<F, TypeList<Ts...>>
{
template <typename U>
static char func(decltype(U::serialize(std::declval<Ts>()...))*);
template <typename U>
static long func(...);
public:
static const bool value = ( sizeof(func<F>(0)) == sizeof(char) );
};
Что это? Это структура, на этапе компиляции определяющая по заданному классу
F
и списку типов L = TypeList<Types...>
, можно ли вызвать функцию F::serialize
с аргументами этих типов. Например, can_serialize<IntegerField, TypeList<BitStream&, int> >::value
равно 1, как и can_serialize<IntegerField, TypeList<BitStream&, char&> >::value
(потому что char&
прекрасно конвертируется в int
), однако, can_serialize<IntegerField, TypeList<BitStream&> >::value
равно 0, так как в IntegerField
не предусмотрено метода serialize
, принимающего на вход только поток вывода.Как это работает? Более тонкий вопрос, давайте разберёмся.
Начнём с класса
TypeList
. Здесь мы используем обещанные Бендером variadic templates, то есть шаблоны с переменным количеством аргументов. Шаблон класса TypeList
принимает произвольное количество аргументов-типов, которые помещаются в parameter pack под именем Types
. (О том, как использовать parameter packs, я писал подробнее в предыдущей статье.) Наш класс TypeList
не делает ничего полезного, но вообще с parameter pack на руках мы можем сделать довольно многое. Например, конструкцияstd::declval<Ts>()...
для parameter pack длины 4, содержащего типы T1, T2, T3, T4
, раскроется при компиляции вstd::declval<T1>(), std::declval<T2>(), std::declval<T3>(), std::declval<T4>()
Далее. У нас есть шаблон
can_serialize
, принимающий класс F
и список типов L
, и частичная специализация, дающая нам доступ к самим типам в списке. (Если запросить can_serialize<F, L>
, где L
не является списком типов, компилятор пожалуется на неопределённый шаблон (undefined template), и поделом.) В этой частичной специализации и просходит вся магия.В её коде есть вызов
func<F>(0)
внутри sizeof
. Компилятор вынужден будет определить, какая из перегрузок функции func
вызывается, чтобы вычислить размер возвращаемого в байтах, но он не станет пытаться скомпилировать её, и поэтому нас не ждёт ошибок типа «что-то я реализации вашей функции не нахожу» (равно как и ошибок «в теле функции какая-то лажа с типами», если бы это тело было). Сперва он попытается использовать первое определение func
, весьма замысловатого вида:template <typename U>
static char func( decltype( U::serialize( std::declval<Ts>()... ) )* );
Конструкция
decltype
выдаёт тип выражения в скобках; например, decltype(10)
есть то же самое, что int
. Но, как и sizeof
, она не компилирует его; это позволяет работать фокусу с std::declval
. std::declval
— это функция, делающая вид, что возвращает rvalue-ссылку требуемого типа; она делает выражение U::serialize( std::declval<Ts>()... )
имеющим смысл и мимикрирующим под настоящий вызов U::serialize
, даже если у половины аргументов нет конструктора по умолчанию и мы не можем написать просто U::serialize( Ts()... )
(не говоря уже о том, что эта функция может требовать lvalue-ссылки! кстати, в этом случае declval
выдаст lvalue-ссылку, потому что по правилам C++ T& &&
равно T&
). Реализации она, конечно, не имеет; написать в обычном коде int a = std::declval<int>();
— плохая идея.Так вот. Если вызов внутри
decltype
невозможен (нет функции с такой сигнатурой или её подстановка вызывает ошибку по каким-либо причинам) — компилятор считает, что случилась ошибка подстановки шаблона (substitution failure), которая, как известно, is not an error (SFINAE). И он спокойно идёт дальше, пытаясь использовать следующее определение func
, в котором никаких проблем уже не предвидится. Однако, другая функция возвращает результат другого размера, что легко можно отловить с помощью sizeof
. (На самом деле не так легко, и sizeof(long)
вполне может быть равен sizeof(char)
на экзотических платформах, но опустим эти детали — всё это поправимо.)В качестве пищи для самостоятельного размышления приведу также код шаблона
can_deserialize
, который специально чуть-чуть сложнее: он не только проверяет, можно ли вызвать F::deserialize
с заданными типами аргументов, но и убеждается, что тип результата равен bool
.template<typename F, typename L> class can_deserialize;
template<typename F, typename... Ts>
class can_deserialize<F, TypeList<Ts...>>
{
template <typename U> static char func(
typename std::enable_if<
std::is_same<decltype(U::deserialize(std::declval<Ts>()...)), bool>::value
>::type*
);
template <typename U> static long func(...);
public:
using type = can_deserialize;
static const bool value = ( sizeof(func<F>(0)) == sizeof(char) );
};
Собираем пакеты из кирпичиков
Наконец, время заняться содержательной частью сериализатора. Вкратце, мы хотим получить шаблонный класс
Schema
, который бы предоставлял функции serialize
и deserialize
, собранные из «кирпичиков»:using MyPacket = Schema<IntegerField, IntegerField, FloatField, ArrayField<float>>;
MyPacket::serialize(std::cout, 10, 15, 0.3, 0, nullptr);
int da, db;
float fc;
std::vector<float> my_vector;
bool success = MyPacket::deserialize(std::cin, da, db, fc, my_vector);
Начнём с простого — объявления шаблонного класса (с переменным числом аргументов, ня!) и конца рекурсии.
template<typename... Fields>
struct Schema;
template<>
struct Schema<> {
template<typename OutputStream>
static void serialize(OutputStream&) {
// ничего не надо делать!
}
template<typename InputStream>
static bool deserialize(InputStream&) {
return true; // нет работы -- нет ошибок!
}
};
Но как должен выглядеть код функции
serialize
в схеме с ненулевым числом полей? Заранее вычислить типы, принимаемые функциями serialize
всех данных полей, и сконкатенировать их мы не можем: это потребовало бы ещё не включенных в стандарт invocation type traits. Остаётся лишь сделать функцию с переменным числом аргументов и отправлять столько из них в каждое поле, сколько то может съесть — тут-то нам и пригодится рождённая в муках can_serialize
.Для такой рекурсии по числу аргументов нам потребуется вспомогательный класс (основной класс
Schema
будет заниматься рекурсией по числу полей). Определим его, не скупясь на аргументы:template<
typename F, // текущее поле, serialize которого мы пытаемся вызвать
typename NextSerializer, // куда потом отправить «лишние» аргументы
typename OS, // тип потока вывода
typename TL, // типы аргументов, с которыми пытаемся вызвать F::serialize
bool can_serialize // можно ли вызвать с такими типами
> struct SchemaSerializer;
Тогда частичная специализация
Schema
, окончательно реализующая рекурсию по числу полей, примет видtemplate<typename F, typename... Fields>
struct Schema<F, Fields...> {
template<
typename OutputStream, // любой поток вывода
typename... Types // сколько угодно каких угодно аргументов
> static void serialize(OutputStream& out, Types&&... args) {
// просто вызываем serialize вспомогательного класса:
SchemaSerializer<
F, // текущее поле
Schema<Fields...>, // рекурсия по числу полей
OutputStream&, // тип потока вывода
TypeList<Types...>, // типы всех имеющихся аргументов
can_serialize<F, TypeList<OutputStream&, Types...>>::value // !!!
>::serialize(out, std::forward<Types>(args)...);
}
// . . . (здесь должна быть аналогичная deserialize)
};
Теперь напишем рекурсию для
SchemaSerializer
. Начнём с простого — с конца:template<typename F, typename NextSerializer, typename OS>
struct SchemaSerializer<F, NextSerializer, OS, TypeList<>, false> {
// мы дошли до самого низа рекурсии, но ничего не получилось.
// без аргументов (кроме потока вывода) вызвать F::serialize
// тоже не получается. что поделать, просто не объвляем здесь
// ничего -- пользователь где-то накосячил, компилятор выдаст
// ему no such function serialize(...) и будет прав.
};
template<typename F, typename NextSerializer, typename OS>
struct SchemaSerializer<F, NextSerializer, OS, TypeList<>, true> {
// мы дошли до самого низа рекурсии и -- о чудо! -- F::serialize
// можно вызвать вообще без аргументов! (не считая потока вывода)
template<typename... TailArgs> // оставшиеся аргументы
static void serialize(OS& out, TailArgs&&... targs) {
F::serialize(out); // ну вызываем без аргументов, чо
// (здесь можно отправить в out какой-нибудь разделитель)
// рекурсия по числу полей понеслась дальше:
NextSerializer::serialize(out, std::forward<TailArgs>(targs)...);
}
};
Здесь мы подошли ко второму концепту, обещанному Бендером — perfect forwarding. Нам пришли лишние аргументы (возможно, и ноль аргументов, но скорее всего нет), и мы хотим отправить их дальше, в
NextSerializer::serialize
. В случае шаблонов это проблема, известная как perfect forwarding problem.Perfect forwarding
Допустим, вы хотите написать враппер вокруг шаблонной функции
f
, принимающей один аргумент. Например,template<typename T>
void better_f(T arg) {
std::cout << "I'm so much better..." << std::endl;
f(arg);
}
Выглядит неплохо, однако, незамедлительно ломается, если f
принимает на вход lvalue-ссылку T&
, а не просто T
: исходная функция f
получит на вход ссылку на временный объект, поскольку тип Т будет вычислен (deduced) как тип без ссылки. Решение просто:template<typename T>
void better_f(T& arg) {
std::cout << "I'm so much better..." << std::endl;
f(arg);
}
И опять-таки незамедлительно ломается, если f
принимает аргумент по значению: в исходную функцию можно было посылать литералы и прочие rvalues, а в новую — нет.Придётся написать оба варианта, чтобы компилятор мог выбрать и полная совместимость присутствовала в обоих случаях:
template<typename T>
void better_f(T& arg) {
std::cout << "I'm so much better..." << std::endl;
f(arg);
}
template<typename T>
void better_f(const T& arg) {
std::cout << "I'm so much better..." << std::endl;
f(arg);
}
И весь этот цирк для одной функции с одним аргументом. С ростом числа аргументов число необходимых перегрузок для полноценного враппера будет расти экспоненциально.Для борьбы с этим C++11 вводит rvalue reference и новые правила вычисления типов. Теперь можно написать просто
template<typename T>
void better_f(T&& arg) {
std::cout << "I'm so much better..." << std::endl;
// ? . .
}
Модификатор && в контексте вычисления типов имеет особый смысл (хотя его легко спутать с обычной rvalue-ссылкой). Если функции будет передана lvalue-ссылка на объект типа type
, тип T теперь будет угадан как type&
; если же будет передано rvalue типа type
, тип T будет угадан как type&&
. Последнее, что осталось сделать для чистого perfect forwarding без лишних копирований аргументов по умолчанию — это использовать std::forward
:template<typename T>
void better_f(T&& arg) {
std::cout << "I'm so much better..." << std::endl;
f(std::forward<T>(arg));
}
std::forward
не трогает обычные ссылки и превращает объекты, переданные по значению, в rvalue-ссылки; таким образом, после первого же враппера дальше по цепочке врапперов (если такая есть) пойдет rvalue-ссылка вместо непосредственно объекта, избавляя от лишних копирований.Продолжаем сериализатор
Итак, конструкция
NextSerializer::serialize(out, std::forward<TailArgs>(targs)...);
осуществляет perfect forwarding, отправляя все «лишние» аргументы в неизменном виде дальше по цепочке сериализаторов.Продолжим писать рекурсию для
SchemaSerializer
. Шаг рекурсии для can_serialize = false
:template<typename F, typename NextSerializer, typename OS, typename... Types>
struct SchemaSerializer<F, NextSerializer, OS, TypeList<Types...>, false>:
// с такими аргументами вызвать F::serialize не получается --
// попробуем взять их поменьше; если получится, мы унаследуем
// работающую функцию serialize
public SchemaSerializer<F, NextSerializer, OS,
typename Head<TypeList<Types...>>::Result, // все аргументы, кроме последнего
can_serialize<F, typename Head<TypeList<OS, Types...>>::Result>::value // !!!
> {
// в самом классе делать нечего ¯\_(ツ)_/¯
};
Реализация вспомогательного класса Head, отрезающего от списка типов последний элемент
template<typename T> struct Head;
// нам потребуется ещё один вспомогательный класс...
template<typename... Ts> struct Concatenate;
// зато его имя говорит само за себя!
template<>
struct Concatenate<> {
using Result = EmptyList;
};
template<typename... A>
struct Concatenate<TypeList<A...>> {
using Result = TypeList<A...>;
};
template<typename... A, typename... B>
struct Concatenate<TypeList<A...>, TypeList<B...>> {
using Result = TypeList<A..., B...>;
};
template<typename... A, typename... Ts>
struct Concatenate<TypeList<A...>, Ts...> {
using Result = typename Concatenate<
TypeList<A...>,
typename Concatenate<Ts...>::Result
>::Result;
};
// к сожалению, в С++ нельзя написать
// template<typename T, typename... Ts>
// struct Head<TypeList<Ts..., T>>, так что
// приходится идти менее красивым путём
template<typename T, typename... Ts>
struct Head<TypeList<T, Ts...>> {
using Result = typename Concatenate<TypeList<T>, typename Head<TypeList<Ts...>>::Result>::Result;
};
template<typename T, typename Q>
struct Head<TypeList<T, Q>> {
using Result = TypeList<T>;
};
template<typename T>
struct Head<TypeList<T>> {
using Result = TypeList<>;
};
template<>
struct Head<TypeList<>> {
using Result = TypeList<>;
};
Шаг рекурсии для
can_serialize = true
:template<typename F, typename NextSerializer, typename OS, typename... Types>
struct SchemaSerializer<F, NextSerializer, OS, TypeList<Types...>, true> {
template<typename... TailTypes> // оставшиеся аргументы
static void serialize(OS& out, Types... args, TailTypes&&... targs) {
F::serialize(out, std::forward<Types>(args)...);
// (здесь можно отправить в out какой-нибудь разделитель)
// рекурсия по числу полей понеслась дальше:
NextSerializer::serialize(out, std::forward<TailTypes>(targs)...);
}
};
Иииии… это всё! На этом наш сериализатор (в самых общих чертах) готов, и простейший код
using MyPacket = Schema<
IntegerField,
IntegerField,
CharField
>;
MyPacket::serialize(std::cout, 777, 6666, 'a');
успешно выводит 7776666a
Но как такое десериализовать? Нужно всё-таки добавить пробелы. Приличный (то есть достаточно абстрактный для тру-C++) способ сделать это — запилить манипулятор-разделитель полей:template< class CharT, class Traits >
std::basic_ostream<CharT, Traits>& delimiter( std::basic_ostream<CharT, Traits>& os ) {
return os << CharT(' '); // в обычный std::ostream отправляем пробел
}
template< class CharT, class Traits >
std::basic_istream<CharT, Traits>& delimiter( std::basic_istream<CharT, Traits>& is ) {
return is; // при чтении париться о пробелах уже не надо
}
BitStream& delimiter(BitStream& bs) {
return bs; // ничего не надо делать -- ни при чтении, ни при записи!
// (хотя можно запилить манипулятор с выравниванием по байту,
// но это уже другая история)
}
std::basic_ostream
умеет кушать функции, принимающие и возвращающие ссылку на него (как, вы думали, устроен std::endl
, std::flush
?), так что теперь весь код с сериализацией переписывается в видеserialize(OS& out, ...) {
F::serialize(out, ...);
out << delimiter; // пишем вожделенный разделитель
NextSerializer::serialize(out, ...);
}
После чего мы получаем закономерное (и готовое к десериализации)777 6666 a
Но всё ещё остаётся маленькая деталь…Вложенность
Раз наши схемы имеют такой же интерфейс, как и простые поля, почему бы не сделать схему из схем?
using MyBigPacket = Schema<MyPacket, IntegerField, MyPacket>;
MyBigPacket::serialize(std::cout, 11, 22, 'a', 33, 44, 55, 'b');
Компилируем ииии… получаем no matching function for call to 'serialize'. В чём же дело?Дело в том, что
Schema::serialize
съедает все аргументы, что ей даны. Внешняя схема видит, что Schema::serialize
можно вызвать со всеми подкинутыми аргументами, ну и вызывает. Компилятор компилирует и видит, что последние четыре аргумента остаются не у дел (candidate function template not viable: requires 1 argument, but 5 were provided), ну и сообщает об ошибке.Преимущество SFINAE выползло здесь как недостаток. Компилятор не компилирует функцию прежде чем определить, можно её вызвать с заданными аргументами или нет; он лишь смотрит на её тип. Чтобы устранить это нежелательное поведение, мы должны заставить
Schema::serialize
быть невалидного типа, если ей переданы неподходящие аргументы.Делать это будем сразу для
Schema
и SchemaSerializer
— так проще. Предположим, что для Schema
это уже сделано, и него функция serialize
имеет невалидный тип при невалидных аргументах. Модифицируем некоторые специализации нашего класса SchemaSerializer
:template<typename F, typename NextSerializer, typename OS>
struct SchemaSerializer<F, NextSerializer, OS, TypeList<>, true> {
template<typename... TailArgs>
static auto serialize(OS& out, TailArgs&&... targs)
-> decltype(NextSerializer::serialize(out, std::forward<TailArgs>(targs)...))
{
F::serialize(out);
out << delimiter;
NextSerializer::serialize(out, std::forward<TailArgs>(targs)...);
}
};
template<typename F, typename NextSerializer, typename OS, typename... Types>
struct SchemaSerializer<F, NextSerializer, OS, TypeList<Types...>, true> {
template<typename... TailTypes>
static auto serialize(OS& out, Types... args, TailTypes&&... targs)
-> decltype(NextSerializer::serialize(out, std::forward<TailTypes>(targs)...))
{
F::serialize(out, std::forward<Types>(args)...);
out << delimiter;
NextSerializer::serialize(out, std::forward<TailTypes>(targs)...);
}
};
Что произошло? Во-первых, мы использовали новый синтаксис. Начиная с С++11, эквивалентны следующие способы задания типа результата функции:
type func(...) { ... }
auto func(...) -> type { .. }
Зачем это нужно? В ряде случаев так удобнее. Например, мы смогли добиться желаемого, не используя снова фокус с
std::declval
, потому что во втором варианте синтаксиса в выражении для type
нам уже доступны аргументы нашей функции, а в первом — нет.А чего мы, собственно, добились? А вот чего: если рекурсия ломается и
NextSerialize::serialize
нельзя вызвать с предоставленными аргументами, вызов NextSerialize::serialize(out, std::forward<TailTypes>(targs)...)
по нашему предположению вызовет ошибку подстановки. Тип возвращаемого значения (а значит, и тип всей функции) вычислить будет невозможно; таким образом и вызов нашего SchemaSerializer::serialize
вызовет ошибку подстановки. Ошибка будет подниматься, пока не поднимется на самый верх и не скажет пользователю, что вызвать Schema::serialize
с такими-то аргументами нельзя, на этапе определения типа функции. Остаётся аналогично модифицировать специализацию Schema
:template<typename F, typename... Fields>
struct Schema<F, Fields...> {
// шаблонный using (снова привет, С++11!)
template<class OutputStream, typename... Types>
using Serializer = SchemaSerializer<
F, // текущее поле
Schema<Fields...>, // рекурсия по числу полей
OutputStream&, // тип потока вывода
TypeList<Types...>, // типы всех имеющихся аргументов
can_serialize<F, TypeList<OutputStream&, Types...>>::value // !!!
>;
template<
typename OS, // любой поток вывода
typename... Types // сколько угодно каких угодно аргументов
> static auto serialize(OS& out, Types&&... args)
-> decltype(Serializer<OS, Types...>::serialize(out, std::forward<Types>(args)...) )
{
Serializer<OS, Types...>::serialize(out, std::forward<Types>(args)...);
}
// . . .
};
Отлично! Теперь чуть менее простой код
using MyPacket = Schema<
IntegerField,
IntegerField,
CharField
>;
using MyBigPacket = Schema<
MyPacket,
IntegerField,
MyPacket
>;
MyBigPacket::serialize(std::cout, 11, 22, 'a', 33, 44, 55, 'b');
компилируется и радостно печатает
11 22 a 33 44 55 b
Мы сделали это!
Заключение
C++ проделал большой путь, и стандарт C++11 был особенно большим шагом. Мы планомерно использовали почти все его нововведения, чтобы реализовать чистый и красивый сериализатор, чего только не поддерживающий. Он терпит произвольное число аргументов для каждого поля, терпит произвольное количество шаблонных и нешаблонных перегрузок функции
serialize
в каждом поле; он терпит в качестве полей другие сериализаторы; главное, на мой взгляд — он не убивает приведение типов, аккуратно донося все аргументы до их адресатов. Легко сообразить, как написать вспомогательный класс SchemaDeserializer
, реализующий функцию deserialize
— я опустил это за тривиальностью. Немного погружения в тему — и с помощью манипуляторов можно написать универсальные сложные поля (форматированный вывод, поле с проверкой диапазона, поле с фиксированной шириной в битах для сжатия в двоичном формате и т.д.), легко расширяемые на новые реализации потоков ввода/вывода.Побаловаться с кодом можно в репозитории на Github.
Об ошибках и неточностях непременно пишите в комментарии или (лучше) в личку. Возможно, статья сможет освободиться от них и даже стать приличным учебным материалом! Спасибо за внимание.