Как стать автором
Обновить

Пишем сериализатор для сетевой игры на C++11

Время на прочтение18 мин
Количество просмотров25K
Написать этот пост меня вдохновила замечательная статья в блоге 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 в остальных случаях, и способность кушать манипуляторы); так же было бы здорово, если бы он умел писать и читать данные длины, не кратной восьми битам.
Возможный интерфейс класса 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.

Об ошибках и неточностях непременно пишите в комментарии или (лучше) в личку. Возможно, статья сможет освободиться от них и даже стать приличным учебным материалом! Спасибо за внимание.
Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
+36
Комментарии33

Публикации

Изменить настройки темы

Истории

Работа

Программист C++
123 вакансии
QT разработчик
6 вакансий

Ближайшие события

Weekend Offer в AliExpress
Дата20 – 21 апреля
Время10:00 – 20:00
Место
Онлайн
Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн