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

    Об ошибках и неточностях непременно пишите в комментарии или (лучше) в личку. Возможно, статья сможет освободиться от них и даже стать приличным учебным материалом! Спасибо за внимание.
    Поделиться публикацией
    Похожие публикации
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама
    Комментарии 32
    • +1
      Отлично. Отложу на прочтение с полным вниканием даже не за сериализацию, а за хорошее объяснение шаблонов.
      • +1
        Отличная статья, приятный код, новый стандарт! Пожалуй, не хватает только ссылки на github, чтобы в один клик самому начать возиться со всем этим (особенно в час ночи по DC, когда уже все влом).

        Посмотрел Вашу старую статью только что и расстроился — она осталась без внимания, хотя тоже написана очень интересно.

        В общем, если есть что рассказать — пишите, у вас отлично получается.
        • 0
          Спасибо! За ссылкой на github приглашаю в личку (это же не «Я пиарюсь», в самом деле!). Выслушаю все замечания к статье, починю всё, что можно, и ссылку всем отдам.
        • +3
          Сходу вопрос: почему решили пилить свой сериализатор и чем не устроил тот же, скажем, Google Protobuf? Последний, кстати говоря, используется в diablo 3, и вроде всё ок + куча клиентских библиотек и тд
          • +1
            То есть первая картинка в посте не даёт ответ на этот вопрос? :)
            • 0

              Шутки шутками, но чисто для уточнения: хотелось сделать сериализатор только на C++ и шаблонах (шаблоны — не C++ :-D) без использования внешних утилит (компилятора протобуфа)?


              Потому как у протобуфа, как минимум, следующие профиты:


              • генерация для разных языков из промежуточного представления, т.е. не нужно писать парсер для всех поддерживаемых языков
              • переменная длинна полей: то есть маленькие значения меньше занимают места (https://developers.google.com/protocol-buffers/docs/encoding — первый пример, где для 150 будет потрачено 3 байта, вместо 4х)
              • 0
                Хотелось минимальный оверхед. Насколько я понял, протобуф кучу всего делает динамически. А мы здесь фактически генерируем последовательность cout << something (или, в случае десериализатора, который я опустил, in >> something; if(in.rdstate()) return false;).

                Собственно, TerraDon ниже уже это отметил.
                • 0

                  Гм, у него только один комментарий и тот к статье в черновиках.

                  • +1
                    Упс, забыл одобрить. ¯\_(ツ)_/¯ Теперь он есть.
            • +1
              В данном случаи больше подходит flatbuffers от того же гугла. Не такой мощный, но быстрее. В случаи с реалтайм играми «рефлексия» в protobuf зло.
            • 0
              Можно подробнее для тупых и tl;dr:
              почему MyBigPacket::serialize(std::cout, 11, 22, 'a', 33, 44, 55, 'b');
              возвращает 1122a334455b
              а не hex(0000000b00000016...)?
              Я, конечно. понимаю. что для простоты у вас простейшая реализация stream << value; но эта вишенка портит ваш роскошный торт. Хотя бы тем, что люди, читающие первый и последний параграф (или сразу с примеров кода), возмущённо воскликнут: «А как же десериализация?!» Лично я так и сделал.
              • +1
                Достаточно написать stream << value << " ";, чтобы всё починить. На деле такой способ не очень хорош (зачем совать пробел в двоичный буфер?), поэтому я делаю так:
                // манипуляторы!11
                template< class CharT, class Traits >
                std::basic_ostream<CharT, Traits>& delimiter( std::basic_ostream<CharT, Traits>& os ) {
                    return os << CharT(' ');
                }
                
                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; // ничего не надо делать -- ни при чтении, ни при записи
                }
                

                И, соответственно, в коде будет вроде
                serialize(...) {
                    F::serialize(out, ...);
                    out << delimiter;
                    NextSerializer::serialize(out, ...);
                }
                deserialize(...) {
                    if(!F::deserialize(in, ...)) return false;
                    in >> delimiter; // вдруг будет вид потока, требующий считать разделитель?
                    return !in.rdstate() // ничего не сломалось при считывании разделителя
                    && NextDeserializer::deserialize(in, ...); // ничего не сломалось в рекурсии
                }
                

                Возможно, этот кусок кода действительно не следовало опускать.
                • 0
                  БЭЭЭЭЭЭП!!! Неправильный ответ, у вас выпадает сериализация строк.
                  Давайте проще, вам нужно в разделе «Интерфейс сериализатора» упомянуть, что потоки могут иметь различный формат вывода, а потому для разного вида потоков может потребоваться написать дополнительный код. И, соответственно, добавить такое же описание в примерах или использовать двоичный вывод. Увы, из-за некоторой углублённости в Qt я оторвался от плюсовой жизни и стандартов, потому не помню, есть ли такие же пары, как QTextStream\QDataStream. Потому конкретые правильные формулировки остаются на вашей совести.
                  • 0
                    Нет, просто для строк я напишу специальный класс StringField, а не буду использовать подход в лоб. Буду в base64 их конвертить, например.
                    • 0
                      1) Я был немного не прав, строки будут нормально писаться.
                      2) Имеет смысл сделать RAW- и String- сериализаторы, которые будут подставляться в зависимости от типа потока и\или требования пользователя.
                      Но это всё хотелки, летать может и так.
              • 0
                успешно выводит
                7776666a



                А как это сможет десерелиазоваться? Как я понимаю: процесс должен быть обратимым.
                • 0
                  Раздел: «Интерфейс сериализатора»
                  Строка: «out << t; // просто скормить сериализуемый объект в поток!»
                  Смысл: поведение сериализатора очень зависит от класса потока.
                  Суть: автор путёвой либы сделал путёвую статью с плохим примером. Конкретнее, скармливал в него cin\cout. Гениально!
                  • 0
                    Вы правы, не подумал. Добавил в статью эту деталь реализации. :)
                  • 0
                    Ответил на ваш вопрос в ветке выше.
                  • 0
                    Возможно, статья сможет освободиться от них и даже стать приличным учебным материалом!


                    Можно сразу начинать делать в своё удовольствие потихоньку сборник статей, потихоньку удаляя ошибки из уже написанного (ну вот как здесь было сделано — https://habrahabr.ru/post/248153/) (только лучше сразу примерный план сборника составить и выложить на обсуждение).
                    • 0
                      Каковы дополнительные расходы на сериализацию классов — сколько дополнительного кода надо добавить в каждый из них?
                      Как будет выглядеть серилизатор для чего-нибудь простенького, вроде:
                      class Point {int x, y};
                      class Player {
                      Point a, b;
                      vector c;};
                      ?
                      • +1
                        struct PointField {
                            template<typename OS> serialize(OS& out, const Point& p) {
                                Schema<IntegerField, IntegerField>::serialize(out, p.x, p.y);
                            }
                        };
                        struct PlayerField {
                            template<typename OS> serialize(OS& out, const Player& pl) {
                                Schema<PointField, PointField, VectorField>::serialize(out, pl.a, pl.b, pl.c);
                            }
                        };
                        

                        (это если не нужно нигде никаких дополнительных проверок и нужно прям все данные в лоб передать)
                        • 0
                          С точки зрения использования — несколько недостатков:
                          1) сериализуемое поле и его схема разделены (то есть, например, VectorField и pl.c не стоят рядом с друг другом) — в два счёта перепутать что к чему относится.
                          2) что вообще надо указывать эту схему вручную, а не доверить это всё компилятору — при изменении будут вылезать проблемы
                          3) что разделены serialize и deserialize, т.е. проблема «Повторяющийся код» не решена.
                          • 0
                            1,2) На Хабре есть статья про генерацию простых сериализаторов для классов, которую вполне можно здесь привлечь. Пара дополнительных шаблонов, пара макросов — и генерировать сериализаторы для классов станет легко и удобно. Статья всё-таки чуть-чуть не об этом, тут мы занимаемся сериализацией пакетов.
                            3) На каком-то уровне они всё-таки должны быть разделены. Но я предлагаю разделять на самом базовом (числа, строки, ещё какие-то элементарные значения, массивы, деревья); на уровне больших классов всё-таки тактика «шаблоны+макросы» должна всё решить. (Хотя хороша ли идея пересылать большие объекты целиком в traffic-intensive сетевой игре?)
                      • 0
                        Чем вас не устраивает Boost.Serialize? Функционал похож. Можете пояснить, чем ваша реализация лучше?
                        • 0
                          Не могу. Я не утверждал, что она лучше.
                        • 0
                          Я с большим уважением отношусь к таким статьям, однако проблемы заявленные во вступлении не решены, кроме как использования возможностей обновленного стандарта.
                          Ну и имитация конца света с отключенными поисковыми системами и доступа к готовым библиотекам. (при отключенном интернете нам бы пришлось изобрести новый способ связи)
                          1) Например, причем тут безопасность? — Кроме использования шифрования в связке со сжатием (независимо от языка) далеко не уедешь.
                          В коде нет ничего об этом.

                          2) То что делает ваш сериализатор похоже на вывод CSV без запятых. А как быть с древовидными структурами?
                          Например в .net (да других платформах тоже) есть сериализатор работающий отлично от xml/json и т.п, все что нужно, так это добавить атрибут Serializable у своего класса (в java подобное делается через интрфейс). И никакого дублированного кода при высокой производительности. Причем сериализатор сразу позволяющий модифицировать удаленный объект (устанавливает удаленное подключение на заданный адрес при необходимости) ну или запишет в файл.

                          Даже если опустить прелюдию с готовыми решениями есть готовые паттерны (это про дублирующийся код) Например в книге GOF чтение дерева описывают в паттерне Visitor. И даже обыденное использование интерфейсов (абстрактных классов в c++ без реализации методов) лишит необходимости переопределять метод сериализации + полный контроль над тем что именно сериализуется для конкретного класса.

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

                          Большое спасибо за статью, однако она должна как минимум иметь название по-скромнее. «Необычная Сериализация в C++11». И не нужно говорить в начале что все на свете проблемы тут порешаны на 20 строках кода:)
                          • +1
                            1) Наверное, мне стоило больше цитировать статью Gaffer on Games, чтобы дать понять, причём тут безопасность. Безопасный код, парсящий пакет — это прежде всего код, в котором после каждого считывания стоит проверка, и могут стоять ещё всяческие дополнительные проверки (длина массива меньше мегабайта, число чего-то там больше нуля). Все эти проверки по отдельности могут быть вынесены в отдельные поля (ShortArrayField, RangeIntegerField, etc.), а приведённый шаблонный код успешно склеивает их на этапе компиляции в большую уродливую функцию с if(error) return false; в каждой строке, писать которую вручную нам было бы тошно.

                            2) Если вам нужно вывести древовидную структуру (как и любую другую хитрую структуру) — напишите сериализатор, выводящий её. Приведённый код не про сериализацию всего и вся (есть, например, статья про генерацию сериализаторов для классов — можно подключить её; паттерны для построения алгоритма вывода деревьев — да на здоровье), а про сериализацию для сетевых игр, то есть про склейку и расклейку пакетов.

                            Ну и да, библиотеки, читающие структуры из внешних файлов — это прекрасно, но на шаблонах оверхед всё-таки меньше. И проблемы порешаны, конечно же, не в 20 строках кода. Проблемы порешаны на идейном уровне. (И только поставленные, а не все, про какие только можно подумать.)
                            • 0
                              Не ожидал ответа, поэтому благодарен.

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

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

                              Я бы назвал эту технику как способ выхода из сложной ситуации. Создатели решили просто обойти ограничения c++ еще одним способом. Для пообного в других языкаъ давно существуют динамические типы и различные функции-делегаты (еще замыкания — Closures как JavaScript или Groovy).

                              И как я писал выше, старое доброе ООП решение тут бы подошло. Реализовать единый интерфейс и наследовать его теми классами которые сериализуются. И точно указывая какие поля сериализовать. А так получается, что сериализуемые объекты нужно выделять в отдельный класс т.к сериализатор берет объект целиком. В 99% случаев у ваших классов будут поля которые не нужно передавать при сериализации (в данном случае по сети), для той же безопасности или экономии трафика.

                              Я лично под сериализатором понимаю некий класс превращающий объекты в поток байт и обратно. Но если говорить про игру, любой читающий хотел бы увидеть нюансы конкретно для игр. Вот их в статье и не было.
                              В начале статьи стояла задача безопасности: «не доверяй данным присланным клиентом» — а хеширование куда пропало? Каким образом написание вроде-как-быстрого кода через использование Variadic Templates делает сообщение безопасным?
                              — Изначально, правильно писать игру так, чтобы клиент просто выводил данные и передавал команды игрока не имея возможности влиять на саму игру (кроме как на своем экране, если удастся изменить память игры или входящий трафик)
                              — Трафик должно быть непросто видоизменить (именно на это будут смотреть те кто будет давать вашей игре статус «безопасной») поэтому любой простейший метод шифрования необходим — для той же уверенности в достоверности сообщений. Ну и почему бы не сжать данные? это сэкономит трафик. (сжимают обычно до шифрования т.к сжатие зашифрованного файла ничего не сэкономит)

                              Вот эти детали ожидает человек, который видит название «сериализатор для сетевой игры». И не пишите пожалуйста — «если вам надо, то напишите» (из вашего ответа), мы говорили про статью, а не про то что мне надо:) Помоему, без возможности сериализовать вложенные объекты — вообще речи о решении задачи сериализации идти не может.

                              Всего хорошего и удачи в написании самых читаемых статей
                              • +2
                                Вы понимаете, что в статье не идет речи «решении задачи сериализации» в вакууме? Это статья навеяна циклом статей о написании сетевого протокола сетевой игры. Это кардинальным образом сужает то, что требуется от сериализатора пакетов. Вложенность далеко не всем нужна и в данном случае пишется сериализатор, который этого не реализует. То что вы ожидали не имеет никакого отношения к тому, что должно быть в статье. Поэтому «вам надо, то напишите» является единственным разумным ответом.

                                Вопрос безопасности тут решен полностью. Еще раз перечитайте, что здесь имеется ввиду под безопасностью. Не нужно шифрование, которое никак не решает задачи достоверности, никакие хеши никому не помогут. Речь о защите от неправильных пакетов. Какой-нить фаззер натравят на сервер, и он должен с этим справиться ниразу не упав. Фаззеры не просто мусор будут посылать, а проверять граничные случаи. Эти задачи решены. И решены так, чтобы минимизировать ручную работу.

                                Так что, давайте как-то разделять свои ожидания и хотелки от реальных недостатков статьи. Статья поставила задачи, она их решила. Вам недостаточно — ищите другие статьи.
                          • 0

                            А есть тесты насколько именно ваш сериализатор круче того же json'a? =)

                            • 0

                              Автору респект! Получилось идеоматично и компактно. Надеюсь статья позволит плодить меньше костылей в C++ проектах (ведь почти все изобретают свой сериализатор).


                              P.S. Окромя flatbuffers можно обратить внимание на Cap'n'Proto и Microsoft Bond. Возможно они решают задачу лучше.

                              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.