Пишем сериализатор для сетевой игры на C++11
Написать этот пост меня вдохновила замечательная статья в блоге Gaffer on Games «Reading and Writing Packets» и неуёмная тяга автоматизировать всё и вся (особенно написание кода на C++!).
Начнём с постановки задачи. Мы пишем сетевую игру (и сразу 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 в остальных случаях, и способность кушать манипуляторы); так же было бы здорово, если бы он умел писать и читать данные длины, не кратной восьми битам.
using byte = uint8_t;
class BitStream {
byte* bdata;
size_t position;
size_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*, size_t); // для чтения
~BitStream(void);
int rdstate(void) const;
// записать младшие how_much бит:
void write_bits(size_t how_much, uint64_t bits);
// прочитать how_much бит в младшие биты результата:
uint64_t read_bits(size_t how_much);
void* data(void);
BitStream& operator<<(BitStream&(*func)(BitStream&)); // вкусные
BitStream& operator>>(BitStream&(*func)(BitStream&)); // манипуляторы
};
template
typename std::enable_if::value, BitStream&>::type
operator<<(BitStream& out, const Int& arg); // записать 8*sizeof(Int) бит в поток
template
typename std::enable_if::value, BitStream&>::type
operator>>(BitStream& in, Int& arg); // прочитать 8*sizeof(Int) бит из потока
std::enable_if
проверяет условие condition
и, если оно выполнено (т.е. не равно нулю), определяет тип std::enable_if<...>::type
, равный указанному пользователем типу T
или (по умолчанию) void
. Если условие не выполнено, обращение к std::enable_if<...>::type
выдаёт undefined; такая ошибка помешает скомпилироваться нашему шаблону, но не помешает скомпилироваться программе, поскольку substitution failure is not an error (SFINAE) — ошибка при подстановке аргументов в шаблон не является ошибкой компиляции. Программа успешно скомпилируется, если где-то определена другая реализация operator<<
с подходящей сигнатурой, или скажет, что подходящей для вызова функции просто нет (умный компилятор, возможно, уточнит, что он пытался, но у него случилось SFINAE).
Интерфейс сериализатора
Понятно, что теперь нам нужны базовые «кирпичики» сериализатора: функции или объекты, умеющие сериализовывать и парсить целые числа или числа с плавающей точкой. Однако, мы (конечно же!) хотим расширяемости, т.е. чтобы программист мог написать «кирпичик» для сериализации любого своего типа данных и использовать его в нашем сериализаторе. Как такой кирпичик должен выглядеть? Я предлагаю простейший формат:
struct IntegerField {
template
static void serialize(OutputStream& out, int t) {
out << t; // просто скормить сериализуемый объект в поток!
} // эту функцию тоже можно заставить возвращать bool, но пока забьём
template
static bool deserialize(InputStream& in, int& t) {
in >> t; // просто вытащить считываемый объект из потока!
return !in.rdstate(); // вернуть true, если при чтении не произошло ошибок
}
};
Просто класс с двумя статическими методами и, возможно, неограниченным числом их перегрузок. (Так, вместо одного шаблонного метода допускается написать несколько: один для std::basic_ostream
, один для BitStream
, неограниченное количество для любых других стримов на вкус программиста.)
Например, для сериализации и парсинга динамического массива элементов интерфейс может выглядеть так:
template
struct ArrayField {
template
static void serialize(OutputStream& out, size_t n, const T* data);
template
static void serialize(OutputStream& out, const std::vector& data);
template
static bool deserialize(InputStream& in, size_t& n, T*& data);
template
static bool deserialize(InputStream& in, std::vector& data);
};
Вспомогательные шаблоны can_serialize
и can_deserialize
Далее нам потребуется возможность проверять, может ли такое-то поле запускать сериализацию/парсинг с такими-то аргументами. Здесь мы приходим к более подробному обсуждению variadic tempates и SFINAE.
Начнём с кода:
template
struct TypeList { // просто вспомогательный класс, статический «список типов»
static const size_t length = sizeof...(Types);
};
template class can_serialize;
template
class can_serialize>
{
template
static char func(decltype(U::serialize(std::declval()...))*);
template
static long func(...);
public:
static const int value = ( sizeof(func(0)) == sizeof(char) );
};
Что это? Это структура, на этапе компиляции определяющая по заданному классу F
и списку типов L = TypeList
, можно ли вызвать функцию F::serialize
с аргументами этих типов. Например,
can_serialize >::value
равно 1, как и
can_serialize >::value
(потому что char&
прекрасно конвертируется в int
), однако,
can_serialize >::value
равно 0, так как в IntegerField
не предусмотрено метода serialize
, принимающего на вход только поток вывода.
Как это работает? Более тонкий вопрос, давайте разберёмся.
Начнём с класса TypeList
. Здесь мы используем обещанные Бендером variadic templates, то есть шаблоны с переменным количеством аргументов. Шаблон класса TypeList
принимает произвольное количество аргументов-типов, которые помещаются в parameter pack под именем Types
. (О том, как использовать parameter packs, я писал подробнее в предыдущей статье.) Наш класс TypeList
не делает ничего полезного, но вообще с parameter pack на руках мы можем сделать довольно многое. Например, конструкция
std::declval()...
для parameter pack длины 4, содержащего типы T1, T2, T3, T4
, раскроется при компиляции в
std::declval(), std::declval(), std::declval(), std::declval()
Далее. У нас есть шаблон can_serialize
, принимающий класс F
и список типов L
, и частичная специализация, дающая нам доступ к самим типам в списке. (Если запросить can_serialize
, где L
не является списком типов, компилятор пожалуется на неопределённый шаблон (undefined template), и поделом.) В этой частичной специализации и просходит вся магия.
В её коде есть вызов func
внутри sizeof
. Компилятор вынужден будет определить, какая из перегрузок функции func
вызывается, чтобы вычислить размер возвращаемого в байтах, но он не станет пытаться скомпилировать её, и поэтому нас не ждёт ошибок типа «что-то я реализации вашей функции не нахожу» (равно как и ошибок «в теле функции какая-то лажа с типами», если бы это тело было). Сперва он попытается использовать первое определение func
, весьма замысловатого вида:
template
static char func( decltype( U::serialize( std::declval()... ) )* );
Конструкция decltype
выдаёт тип выражения в скобках; например, decltype(10)
есть то же самое, что int
. Но, как и sizeof
, она не компилирует его; это позволяет работать фокусу с std::declval
. std::declval
— это функция, делающая вид, что возвращает rvalue-ссылку требуемого типа; она делает выражение U::serialize( std::declval
имеющим смысл и мимикрирующим под настоящий вызов U::serialize
, даже если у половины аргументов нет конструктора по умолчанию и мы не можем написать просто U::serialize( Ts()... )
(не говоря уже о том, что эта функция может требовать lvalue-ссылки! кстати, в этом случае declval
выдаст lvalue-ссылку, потому что по правилам C++ T& &&
равно T&
). Реализации она, конечно, не имеет; написать в обычном коде
int a = std::declval();
— плохая идея.
Так вот. Если вызов внутри decltype
невозможен (нет функции с такой сигнатурой или её подстановка вызывает ошибку по каким-либо причинам) — компилятор считает, что случилась ошибка подстановки шаблона (substitution failure), которая, как известно, is not an error (SFINAE). И он спокойно идёт дальше, пытаясь использовать следующее определение func
, в котором никаких проблем уже не предвидится. Однако, другая функция возвращает результат другого размера, что легко можно отловить с помощью sizeof
.
В качестве пищи для самостоятельного размышления приведу также код шаблона can_deserialize
, который специально чуть-чуть сложнее: он не только проверяет, можно ли вызвать F::deserialize
с заданными типами аргументов, но и убеждается, что тип результата равен bool
.
template class can_deserialize;
template
class can_deserialize>
{
template static char func(
typename std::enable_if<
std::is_same()...)), bool>::value
>::type*
);
template static long func(...);
public:
using type = can_deserialize;
static const int value = ( sizeof(func(0)) == sizeof(char) );
};
Собираем пакеты из кирпичиков
Наконец, время заняться содержательной частью сериализатора. Вкратце, мы хотим получить шаблонный класс Schema
, который бы предоставлял функции serialize
и deserialize
, собранные из «кирпичиков»:
using MyPacket = Schema>;
MyPacket::serialize(std::cout, 10, 15, 0.3, 0, nullptr);
int da, db;
float fc;
std::vector my_vector;
bool success = MyPacket::deserialize(std::cin, da, db, fc, my_vector);
Начнём с простого — объявления шаблонного класса (с переменным числом аргументов, ня!) и конца рекурсии.
template
struct Schema;
template<>
struct Schema<> {
template
static void serialize(OutputStream&) {
// ничего не надо делать!
}
template
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
struct Schema {
template<
typename OutputStream, // любой поток вывода
typename... Types // сколько угодно каких угодно аргументов
> static void serialize(OutputStream& out, Types&&... args) {
// просто вызываем serialize вспомогательного класса:
SchemaSerializer<
F, // текущее поле
Schema, // рекурсия по числу полей
OutputStream&, // тип потока вывода
TypeList, // типы всех имеющихся аргументов
can_serialize>::value // !!!
>::serialize(out, std::forward(args)...);
}
// . . . (здесь должна быть аналогичная deserialize)
};
Теперь напишем рекурсию для SchemaSerializer
. Начнём с простого — с конца:
template
struct SchemaSerializer, false> {
// мы дошли до самого низа рекурсии, но ничего не получилось.
// без аргументов (кроме потока вывода) вызвать F::serialize
// тоже не получается. что поделать, просто не объвляем здесь
// ничего -- пользователь где-то накосячил, компилятор выдаст
// ему no such function serialize(...) и будет прав.
};
template
struct SchemaSerializer, true> {
// мы дошли до самого низа рекурсии и -- о чудо! -- F::serialize
// можно вызвать вообще без аргументов! (не считая потока вывода)
template // оставшиеся аргументы
static void serialize(OS& out, TailArgs&&... targs) {
F::serialize(out); // ну вызываем без аргументов, чо
// (здесь можно отправить в out какой-нибудь разделитель)
// рекурсия по числу полей понеслась дальше:
NextSerializer::serialize(out, std::forward(targs)...);
}
};
Здесь мы подошли ко второму концепту, обещанному Бендером — perfect forwarding. Нам пришли лишние аргументы (возможно, и ноль аргументов, но скорее всего нет), и мы хотим отправить их дальше, в NextSerializer::serialize
. В случае шаблонов это проблема, известная как perfect forwarding problem.
Perfect forwarding
Допустим, вы хотите написать враппер вокруг шаблонной функции f
, принимающей один аргумент. Например,
template
void better_f(T arg) {
std::cout << "I'm so much better..." << std::endl;
f(arg);
}
Выглядит неплохо, однако, незамедлительно ломается, если f
принимает на вход lvalue-ссылку T&
, а не просто T
: исходная функция f
получит на вход ссылку на временный объект, поскольку тип Т будет вычислен (deducted) как тип без ссылки. Решение просто:
template
void better_f(T& arg) {
std::cout << "I'm so much better..." << std::endl;
f(arg);
}
И опять-таки незамедлительно ломается, если f
принимает аргумент по значению: в исходную функцию можно было посылать литералы и прочие rvalues, а в новую — нет.
Придётся написать оба варианта, чтобы компилятор мог выбрать и полная совместимость присутствовала в обоих случаях:
template
void better_f(T& arg) {
std::cout << "I'm so much better..." << std::endl;
f(arg);
}
template
void better_f(const T& arg) {
std::cout << "I'm so much better..." << std::endl;
f(arg);
}
И весь этот цирк для одной функции с одним аргументом. С ростом числа аргументов число необходимых перегрузок для полноценного враппера будет расти экспоненциально.
Для борьбы с этим C++11 вводит rvalue reference и новые правила вычисления типов. Теперь можно написать просто
template
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
void better_f(T&& arg) {
std::cout << "I'm so much better..." << std::endl;
f(std::forward(arg));
}
std::forward
не трогает обычные ссылки и превращает объекты, переданные по значению, в rvalue-ссылки; таким образом, после первого же враппера дальше по цепочке врапперов (если такая есть) пойдет rvalue-ссылка вместо непосредственно объекта, избавляя от лишних копирований.
Продолжаем сериализатор
Итак, конструкция
NextSerializer::serialize(out, std::forward(targs)...);
осуществляет perfect forwarding, отправляя все «лишние» аргументы в неизменном виде дальше по цепочке сериализаторов.
Продолжим писать рекурсию для SchemaSerializer
. Шаг рекурсии для can_serialize = false
:
template
struct SchemaSerializer, false>:
// с такими аргументами вызвать F::serialize не получается --
// попробуем взять их поменьше; если получится, мы унаследуем
// работающую функцию serialize
public SchemaSerializer>::Result, // все аргументы, кроме последнего
can_serialize>::Result>::value // !!!
> {
// в самом классе делать нечего ¯\_(ツ)_/¯
};
template struct Head;
// нам потребуется ещё один вспомогательный класс...
template struct Concatenate;
// зато его имя говорит само за себя!
template<>
struct Concatenate<> {
using Result = EmptyList;
};
template
struct Concatenate> {
using Result = TypeList;
};
template
struct Concatenate, TypeList> {
using Result = TypeList;
};
template
struct Concatenate, Ts...> {
using Result = typename Concatenate<
TypeList,
typename Concatenate::Result
>::Result;
};
// к сожалению, в С++ нельзя написать
// template
// struct Head>, так что
// приходится идти менее красивым путём
template
struct Head> {
using Result = typename Concatenate, typename Head>::Result>::Result;
};
template
struct Head> {
using Result = TypeList;
};
template
struct Head> {
using Result = TypeList<>;
};
template<>
struct Head> {
using Result = TypeList<>;
};
Шаг рекурсии для can_serialize = true
:
template
struct SchemaSerializer, true> {
template // оставшиеся аргументы
static void serialize(OS& out, Types... args, TailTypes&&... targs) {
F::serialize(out, std::forward(args)...);
// (здесь можно отправить в out какой-нибудь разделитель)
// рекурсия по числу полей понеслась дальше:
NextSerializer::serialize(out, std::forward(targs)...);
}
};
Иииии… это всё! На этом наш сериализатор (в самых общих чертах) готов, и простейший код
using MyPacket = Schema<
IntegerField,
IntegerField,
CharField
>;
MyPacket::serialize(std::cout, 777, 6666, 'a');
успешно выводит
7776666a
Но осталась маленькая деталь…
Вложенность
Раз наши схемы имеют такой же интерфейс, как и простые поля, почему бы не сделать схему из схем?
using MyBigPacket = Schema;
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
struct SchemaSerializer, true> {
template
static auto serialize(OS& out, TailArgs&&... targs)
-> decltype(NextSerializer::serialize(out, std::forward(targs)...))
{
F::serialize(out);
NextSerializer::serialize(out, std::forward(targs)...);
}
};
template
struct SchemaSerializer, true> {
template
static auto serialize(OS& out, Types... args, TailTypes&&... targs)
-> decltype(NextSerializer::serialize(out, std::forward(targs)...))
{
F::serialize(out, std::forward(args)...);
NextSerializer::serialize(out, std::forward(targs)...);
}
};
Что произошло? Во-первых, мы использовали новый синтаксис. Начиная с С++11, эквивалентны следующие способы задания типа результата функции:
type func(...) { ... }
auto func(...) -> type { .. }
Зачем это нужно? В ряде случаев так удобнее. Например, мы смогли добиться желаемого, не используя снова фокус с std::declval
.
А чего мы, собственно, добились? А вот чего: если рекурсия ломается и NextSerialize::serialize
нельзя вызвать с предоставленными аргументами, вызов NextSerialize::serialize(out, std::forward
по нашему предположению вызовет ошибку подстановки. Тип возвращаемого значения (а значит, и тип всей функции) вычислить будет невозможно; таким образом и вызов нашего SchemaSerializer::serialize
вызовет ошибку подстановки. Ошибка будет подниматься, пока не поднимется на самый верх и не скажет пользователю, что вызвать Schema::serialize
с такими-то аргументами нельзя, на этапе определения типа функции. Остаётся аналогично модифицировать специализацию Schema
:
template
struct Schema {
// шаблонный using (снова привет, С++11!)
template
using Serializer = SchemaSerializer<
F, // текущее поле
Schema, // рекурсия по числу полей
OutputStream&, // тип потока вывода
TypeList, // типы всех имеющихся аргументов
can_serialize>::value // !!!
>;
template<
typename OS, // любой поток вывода
typename... Types // сколько угодно каких угодно аргументов
> static auto serialize(OS& out, Types&&... args)
-> decltype(Serializer::serialize(out, std::forward(args)...) )
{
Serializer::serialize(out, std::forward(args)...);
}
// . . .
};
Отлично! Теперь чуть менее простой код
using MyPacket = Schema<
IntegerField,
IntegerField,
CharField
>;
using MyBigPacket = Schema<
MyPacket,
IntegerField,
MyPacket
>;
MyBigPacket::serialize(std::cout, 11, 22, 'a', 33, 44, 55, 'b');
компилируется и радостно печатает
1122a334455b
(главным образом потому, что у нас так и не дошли руки добавить разделитель; для тестов с std::cout
можно просто впихнуть пробел, но вы же понимаете, что для абстрактного потока вывода нужно более абстрактное решение…).
Мы сделали это!
Заключение
C++ проделал большой путь, и стандарт C++11 был особенно большим шагом. Мы планомерно использовали почти все его нововведения, чтобы реализовать чистый и красивый сериализатор, чего только не поддерживающий. Он терпит произвольное число аргументов для каждого поля, терпит произвольное количество шаблонных и нешаблонных перегрузок функции serialize
в каждом поле; он терпит в качестве полей другие сериализаторы; главное, на мой взгляд — он не убивает приведение типов, аккуратно донося все аргументы до их адресатов. Легко сообразить, как написать вспомогательный класс SchemaDeserializer
, реализующий функцию deserialize
— я опустил это за тривиальностью. Немного погружения в тему — и с помощью манипуляторов можно написать универсальные сложные поля (форматированный вывод, поле с проверкой диапазона, поле с фиксированной шириной в битах для сжатия в двоичном формате и т.д.), легко расширяемые на новые реализации потоков ввода/вывода.
Об ошибках и неточностях непременно пишите в комментарии или (лучше) в личку. Возможно, статья сможет освободиться от них и даже стать приличным учебным материалом! Спасибо за внимание.