Написать этот пост меня вдохновила замечательная статья в блоге 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 с аргументами этих типов. Например, равно 1, как иcan_serialize<IntegerField, TypeList<BitStream&, int> >::value
(потому чтоcan_serialize<IntegerField, TypeList<BitStream&, char&> >::value
char& прекрасно конвертируется в int), однако, равно 0, так как вcan_serialize<IntegerField, TypeList<BitStream&> >::value
IntegerField не предусмотрено метода serialize, принимающего на вход только поток вывода.Как это работает? Более тонкий вопрос, давайте разберёмся.
Начнём с класса TypeList. Здесь мы используем обещанные Бендером variadic templates, то есть шаблоны с переменным количеством аргументов. Шаблон класса TypeList принимает произвольное количество аргументов-типов, которые помещаются в parameter pack под именем Types. (О том, как использовать parameter packs, я писал подробнее в предыдущей статье.) Наш класс TypeList не делает ничего полезного, но вообще с parameter pack на руках мы можем сделать довольно многое. Например, конструкциядля parameter pack длины 4, содержащего типыstd::declval<Ts>()...
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 и новые правила вычисления типов. Теперь можно написать просто
Модификатор && в контексте вычисления типов имеет особый смысл (хотя его легко спутать с обычной rvalue-ссылкой). Если функции будет передана lvalue-ссылка на объект типаtemplate<typename T> void better_f(T&& arg) { std::cout << "I'm so much better..." << std::endl; // ? . . }
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-ссылка вместо непосредственно объекта, избавляя от лишних копирований.Продолжаем сериализатор
Итак, конструкция
осуществляет perfect forwarding, отправляя все «лишние» аргументы в неизменном виде дальше по цепочке сериализаторов.NextSerializer::serialize(out, std::forward<TailArgs>(targs)...);
Продолжим писать рекурсию для
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');
Но как такое десериализовать? Нужно всё-таки добавить пробелы. Приличный (то есть достаточно абстрактный для тру-C++) способ сделать это — запилить манипулятор-разделитель полей:7776666a
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
Вложенность
Раз наши схемы имеют такой же интерфейс, как и простые поля, почему бы не сделать схему из схем?
Компилируем ииии… получаем no matching function for call to 'serialize'. В чём же дело?using MyBigPacket = Schema<MyPacket, IntegerField, MyPacket>; MyBigPacket::serialize(std::cout, 11, 22, 'a', 33, 44, 55, 'b');
Дело в том, что
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.
Об ошибках и неточностях непременно пишите в комментарии или (лучше) в личку. Возможно, статья сможет освободиться от них и даже стать приличным учебным материалом! Спасибо за внимание.
