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

Сериализация в C++

Время на прочтение14 мин
Количество просмотров42K
В данной статье речь пойдет об автоматизации процесса сериализации в C++. В начале будут рассмотрены базовые механизмы, позволяющие упростить чтение/запись данных в потоки ввода-вывода, после чего будет дано описание примитивной системы генерации кода на основе libclang. Ссылка на репозиторий с демонстрационным вариантом библиотеки расположена в конце статьи.

На ruSO периодически встречаются вопросы, касающиеся сериализации данных в C++, иногда эти вопросы носят общий характер, когда TC в принципе не знает, с чего начать, иногда — это вопросы, описывающие конкретную проблему. Цель написания данной статьи заключается в кратком изложении одного из возможных способов имплементации сериализации в C++, которое позволит проследить этапы построения системы с начальных шагов до некоторого логического завершения, когда данной системой уже можно будет пользоваться на практике.

1. Начальные сведения


В данной статье будет использоваться бинарный формат данных, структура которых определяется на основании типов сериализуемых объектов. Такой подход избавляет нас от использования сторонних библиотек, ограничиваясь лишь теми средствами, которые предоставляет стандартная библиотека C++.

Так как процесс сериализации заключается в преобразовании состояния объекта в поток байтов, который, очевидно, должен сопровождаться операциями записи, последние будут использоваться вместо термина “сериализация” при описании низкоуровневых деталей. Аналогично для чтения/десериализации.

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

2. Поддерживаемые типы


Вначале стоит определиться с типами, которые мы планируем поддерживать — от этого напрямую зависит то, как будет реализована библиотека.

Например, если выбор ограничивается фундаментальными типами C++, то будет достаточно шаблона функции (который представляет собой семейство функций для работы со значениями целочисленных типов) и его явных специализаций. Первичный шаблон (используется для типов std::int32_t, std::uint16_t и т.д.):

template<typename T>
auto write(std::ostream& os, T value) -> std::size_t
{
    const auto pos = os.tellp();
    os.write(reinterpret_cast<const char*>(&value), sizeof(value));
    return static_cast<std::size_t>(os.tellp() - pos);
}

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

Специализация для bool:

constexpr auto t_value = static_cast<std::uint8_t>('T');
constexpr auto f_value = static_cast<std::uint8_t>('F');

template<>
auto write(std::ostream& os, bool value) -> std::size_t
{
    const auto pos = os.tellp();
    const auto tmp = (value) ? t_value : f_value;
    os.write(reinterpret_cast<const char*>(&tmp), sizeof(tmp));
    return static_cast<std::size_t>(os.tellp() - pos);
}

Данный подход определяет следующее правило: если значение типа T может быть представлено в виде последовательности байт длинной sizeof(T), для него может быть использовано определение первичного шаблона, в противном случае — требуется определить специализацию. Данное требование может быть продиктовано особенностями представления объекта типа T в памяти.

Рассмотрим контейнер std::string: очевидно, что мы не можем взять адрес объекта указанного типа, привести его к указателю на char и записать в поток вывода — значит, нам требуется специализация:

template<>
auto write(std::ostream& os, const std::string& value) -> std::size_t
{
    const auto pos = os.tellp();
    const auto len = static_cast<std::uint32_t>(value.size());
    os.write(reinterpret_cast<const char*>(&len), sizeof(len));
    if (len > 0)
        os.write(value.data(), len);
    return static_cast<std::size_t>(os.tellp() - pos);
}

Здесь необходимо сделать два важных замечания:

  1. В поток вывода записывается не только содержимое строки, но и ее размер.
  2. Приведение std::string::size_type к типу std::uint32_t. В данном случае стоить обратить внимание не на размер целевого типа, а на то, что он — фиксированной длины. Такое приведение позволит избежать проблем в случае, например, если данные передаются по сети между машинами у которых отличается размер машинного слова.

Итак, мы выяснили, что значения фундаментальных типов (и даже объекты типа std::string) могут быть записаны в поток вывода с помощью шаблона функции write. Теперь давайте проанализируем, какие изменения нам потребуется внести, если мы захотим добавить контейнеры в список поддерживаемых типов. У нас есть только один вариант для перегрузки — использовать параметр T как тип элементов контейнера. И если в случае с std::vector это сработает:

template<typename T>
auto write(std::ostream& os, const std::vector<T>& value) -> std::size_t
{
    const auto pos = os.tellp();
    const auto len = static_cast<std::uint16_t>(value.size());
    os.write(reinterpret_cast<const char*>(&len), sizeof(len));
    auto size = static_cast<std::size_t>(os.tellp() - pos);
    if (len > 0)
    {
        std::for_each(value.cbegin(), value.cend(), [&](const auto& e)
            { size += ::write(os, e); });
    }
    return size;
}

, то с std:map — нет, так как шаблон std::map требует минимум два параметра — тип ключа и тип значения. Таким образом, на данном этапе мы больше не можем использовать шаблон функции — нам нужно более универсальное решение. Прежде чем разбираться, как добавить поддержку контейнеров, давайте вспомним, что у нас еще есть пользовательские классы. Очевидно, что даже используя текущее решение, было бы не очень разумно перегружать функцию write для каждого класса, требующего сериализации. В лучшем случае мы бы хотели иметь одну специализацию шаблона write, который бы работал с пользовательскими типами данных. Но для этого необходимо, чтобы классы имели возможность самостоятельно управлять сериализацией, соответственно, у них должен появиться интерфейс, который бы позволил пользователю сериализовать и десериализовать объекты данного класса. Как выяснится чуть позже, данный интерфейс и послужит “общим знаменателем” для шаблона write при работе с пользовательскими классами. Давайте определим его.

class ISerializable
{
protected:
    ~ISerializable() = default;

public:
    virtual auto serialize(std::ostream& os) const -> std::size_t = 0;
    virtual auto deserialize(std::istream& is) -> std::size_t = 0;
    virtual auto serialized_size() const noexcept -> std::size_t = 0;
};

Любой класс, наследуемый от ISerializable, обязуется:

  1. Переопределить serialize — запись состояния (членов-данных) в поток вывода.
  2. Переопределить deserialize — чтение состояния (инициализация членов-данных) из потока ввода.
  3. Переопределить serialized_size — вычисление размера сериализованных данных для текущего состояния объекта.

Итак, вернемся к шаблону функции write: в общем случае, мы можем реализовать специализацию для класса ISerializable, но мы не сможем ей пользоваться, взгляните:

template<>
auto write(std::ostream& os, const ISerializable& value) -> std::size_t
{
    return value.serialize(os);
}

Нам каждый раз приходилось бы приводить тип наследника к ISerializable, чтобы воспользоваться данной специализацией. Напомню, что в самом начале мы поставили своей целью упростить написание кода, связанного с сериализацией, а не наоборот, усложнить его. Итак, если поддерживаемые нашей библиотекой типы не ограничиваются фундаментальными типами, то нам пора искать другое решение.

3. stream_writer


Использование шаблонов функций для реализации универсального интерфейса записи данных в поток оказалось не вполне пригодным решением. Следующий вариант, который нам стоит проверить — шаблон класса. Мы будем действовать по той же методике, которую использовали с шаблоном функции — первичный шаблон будет использоваться по умолчанию, а для поддержки необходимых типов будут добавлены явные специализации.

Кроме того, нам следует учесть все вышесказанное об ISerializable — очевидно, мы не сможем решить проблему с множеством классов-наследников, не прибегнув к type_traits: начиная с  С++11 в стандартной библиотеке появился шаблон std::enable_if, позволяющий игнорировать шаблонные классы при определенных условиях во время компиляции — и именно этой возможностью мы собираемся воспользоваться.

Шаблон класса stream_writer:

template<typename T, typename U = void>
class stream_writer
{
public:
    static auto write(std::ostream& os, const T& value) -> std::size_t;
};

Определение метода write:

template<typename T, typename U>
auto stream_writer<T, U>::write(std::ostream& os, const T& value) -> std::size_t
{
    const auto pos = os.tellp();
    os.write(reinterpret_cast<const char*>(&value), sizeof(value));
    return static_cast<std::size_t>(os.tellp() - pos);
}

Специализация для ISerializable будет выглядеть следующим образом:

template<typename T>
class stream_writer<T, only_if_serializable<T>>
    : public stream_io<T>
{
public:
    static auto write(std::ostream& os, const T& value) -> std::size_t;
};

, где only_if_serializable представляет собой вспомогательный тип:

template<typename T>
using only_if_serializable =
    std::enable_if_t<std::is_base_of_v<ISerializable, T>>;

Таким образом, если тип T является классом производным от ISerializable, то данная специализация будет рассмотрена в качестве кандидата на инстанцирование, соответственно, если тип T не находится в одной иерархии классов с ISerializable — она будет исключена из возможных кандидатов.

Довольно справедливо было бы задать здесь следующий вопрос: как это будет работать? Ведь первичный шаблон будет иметь те же значения типовых параметров, что и его специализация — <T, void>. Почему предпочтение будет отдано именно специализации, и будет ли? Ответ: будет, так как такое поведение предписано стандартом (источник):

(1.1) If exactly one matching specialization is found, the instantiation is generated from that specialization

Специализация для std::string теперь будет выглядеть следующим образом:

template<typename T>
class stream_writer<T, only_if_string<T>>
{
public:
    static auto write(std::ostream& os, const T& value) -> std::size_t;
};

template<typename T>
auto stream_writer<T, only_if_string<T>>::write(std::ostream& os, const T& value) -> std::size_t
{
    const auto pos = os.tellp();
    const auto len = static_cast<std::uint32_t>(value.size());
    os.write(reinterpret_cast<const char*>(&len), sizeof(len));
    if (len > 0)
        os.write(value.data(), len);
    return static_cast<std::size_t>(os.tellp() - pos);
}

, где only_if_string объявлен как:

template<typename T>
using only_if_string =
    std::enable_if_t<std::is_same_v<T, std::string>>;

Настало время вернуться к контейнерам. В данном случае мы можем использовать тип контейнера параметризированный каким-либо типом U, или <U, V>, как в случае с std::map, непосредственно в качестве значения параметра T шаблона класса stream_writer. Таким образом, в интерфейсе у нас ничего не меняется — к этому мы и стремились. Однако, встает вопрос, каким должен быть второй параметр шаблона класса stream_writer, чтобы все работало корректно? Об этом — в следующей главе.

4. Концепты


Для начала я дам краткое описание использованных концептов, а уже потом покажу обновленные примеры.

template<typename T>
concept String = std::is_same_v<T, std::string>;

Честно говоря, данный концепт был определен для махинации, которую мы увидим уже на следующей строке:

template<typename T>
concept Container = !String<T> && requires (T a)
{
    typename T::value_type;
    typename T::reference;
    typename T::const_reference;
    typename T::iterator;
    typename T::const_iterator;
    typename T::size_type;
    { a.begin() } -> typename T::iterator;
    { a.end() } -> typename T::iterator;
    { a.cbegin() } -> typename T::const_iterator;
    { a.cend() } -> typename T::const_iterator;
    { a.clear() } -> void;
};

Container содержит требования, которые мы “предъявляем” типу, чтобы действительно убедиться, что он представляет собой один из контейнерных типов. Это именно тот набор требований, который понадобится нам при реализации stream_writer, стандарт предъявляет гораздо больше требований, разумеется.

template<typename T>
concept SequenceContainer = Container<T> &&
    requires (T a, typename T::size_type count)
{
    { a.resize(count) } -> void;
};

Концепт для последовательных контейнеров: std::vector, std::list и т.д.

template<typename T>
concept AssociativeContainer = Container<T> && requires (T a)
{
    typename T::key_type;
};

Концепт для ассоциативных контейнеров: std::map, std::set, std::unordered_map и т.д.

Теперь, чтобы определить специализацию для последовательных контейнеров все, что нам остается сделать, наложить ограничения на тип T:

template<typename T> requires SequenceContainer<T>
class stream_writer<T, void>
{
public:
    static auto write(std::ostream& os, const T& value) -> std::size_t;
};

template<typename T> requires SequenceContainer<T>
auto stream_writer<T, void>::write(std::ostream& os, const T& value) -> std::size_t
{
    const auto pos = os.tellp();
    // to support std::forward_list we have to use std::distance()
    const auto len = static_cast<std::uint16_t>(
std::distance(value.cbegin(), value.cend()));
    os.write(reinterpret_cast<const char*>(&len), sizeof(len));
    auto size = static_cast<std::size_t>(os.tellp() - pos);
    if (len > 0)
    {
        using value_t = typename stream_writer::value_type;
        std::for_each(value.cbegin(), value.cend(), [&](const auto& item)
            { size += stream_writer<value_t>::write(os, item); });
    }
    return size;
}

Поддерживаемые контейнеры:

  • std::vector
  • std::deque
  • std::list
  • std::forward_list

Аналогично для ассоциативных контейнеров:

template<typename T> requires AssociativeContainer<T>
class stream_writer<T, void>
    : public stream_io<T>
{
public:
    static auto write(std::ostream& os, const T& value) -> std::size_t;
};

template<typename T> requires AssociativeContainer<T>
auto stream_writer<T, void>::write(std::ostream& os, const T& value) -> std::size_t
{
    const auto pos = os.tellp();
    const auto len = static_cast<typename stream_writer::size_type>(value.size());
    os.write(reinterpret_cast<const char*>(&len), sizeof(len));
    auto size = static_cast<std::size_t>(os.tellp() - pos);
    if (len > 0)
    {
        using value_t = typename stream_writer::value_type;
        std::for_each(value.cbegin(), value.cend(), [&](const auto& item)
            { size += stream_writer<value_t>::write(os, item); });
    }
    return size;
}

Поддерживаемые контейнеры:

  • std::map
  • std::unordered_map
  • std::set
  • std::unordered_set

В случае с map есть небольшой нюанс, он касается реализации stream_reader. value_type для std::map<K, T> представляет собой std::pair<const K, T>, соответственно, когда при чтении из потока ввода мы пытаемся привести указатель на const K к указателю на char — мы получаем ошибку компиляции. Решить данную проблему можно следующим образом: мы знаем, что для ассоциативных контейнеров value_type это либо одиночный тип K, либо std::pair<const K, V>, тогда мы можем написать небольшие шаблонные helper-классы, которые будут параметризироваться value_type и внутри себя определять нужный нам тип.

Для std::set все остается без изменений:

template<typename U, typename V = void>
struct converter
{
    using type = U;
};

Для std::map — убираем const:

template<typename U>
struct converter<U, only_if_pair<U>>
{
    using type = std::pair<std::remove_const_t<typename U::first_type>, typename U::second_type>;
};

Определение read для ассоциативных контейнеров:

template<typename T> requires AssociativeContainer<T>
auto stream_reader<T, void>::read(std::istream& is, T& value) -> std::size_t
{
    const auto pos = is.tellg();
    typename stream_reader::size_type len = 0;
    is.read(reinterpret_cast<char*>(&len), sizeof(len));
    auto size = static_cast<std::size_t>(is.tellg() - pos);
    if (len > 0)
    {
        for (auto i = 0U; i < len; ++i)
        {
            using value_t = typename converter<typename stream_reader::value_type>::type;
            value_t v {};
            size += stream_reader<value_t>::read(is, v);
            value.insert(std::move(v));
        }
    }
    return size;
}


5. Вспомогательные функции


Рассмотрим пример:

class User
    : public ISerializable
{
public:
    User(std::string_view username, std::string_view password)
        : m_username(username)
        , m_password(password)
    {}

    SERIALIZABLE_INTERFACE

protected:
    std::string m_username {};
    std::string m_password {};
};

Определение метода serialize(std::ostream&) для данного класса должно было выглядеть следующим образом:

auto User::serialize(std::ostream& os) const -> std::size_t
{
	auto size = 0U;
	size += stream_writer<std::string>::write(os, m_username);
	size += stream_writer<std::string>::write(os, m_password);
	return size;
}

Однако, согласитесь, это неудобно, каждый раз указывать тип объекта, который записывается в поток вывода. Напишем вспомогательную функцию, которая бы автоматически выводила тип T:

template<typename T>
auto write(std::ostream& os, const T& value) -> std::size_t
{
    return stream_writer<T>::write(os, value);
}

Теперь определение выглядит следующим образом:

auto User::serialize(std::ostream& os) const -> std::size_t
{
	auto size = 0U;
	size += ::write(os, m_username);
	size += ::write(os, m_password);
	return size;
}

Для завершающей главы потребуется еще несколько вспомогательных функций:

template<typename T>
auto write_recursive(std::ostream& os, const T& value) -> std::size_t
{
    return ::write(os, value);
}

template<typename T, typename... Ts>
auto write_recursive(std::ostream& os, const T& value, const Ts&... values)
{
    auto size = write_recursive(os, value);
    return size + write_recursive(os, values...);
}

template<typename... Ts>
auto write_all(std::ostream& os, const Ts&... values) -> std::size_t
{
    return write_recursive(os, values...);
}

Функция write_all позволяет перечислить сразу все объекты, подлежащие сериализации, в то время как write_recursive обеспечивает правильный порядок записи в поток вывода. Если бы для fold-expressions был определен порядок вычислений (при условии, что мы используем бинарный оператор +), можно было бы использовать их. В частности, в функции size_of_all (ранее не была упомянута, используется для вычисления размера сериализованных данных), используются именно fold-expressions ввиду отсутствия операций ввода-вывода.

6. Генерация кода


Для генерации кода используется libclang — C API для clang. Высокоуровнево данную задачу можно описать так: нам необходимо рекурсивно обойти директорию с исходным кодом, проверить все заголовочные файлы на наличие классов, помеченных специальным атрибутом, и если таковой присутствует — проверить члены-данные на наличие того же атрибута и скомпилировать строку из имен членов-данных, перечисленных через запятую. Все, что нам остается сделать, написать шаблоны определений для функций класса ISerializable (в которые нам остается поместить только перечисление необходимых членов данных).

Пример класса, для которого будет сгенерирован код:

class __attribute__((annotate("serializable"))) User
    : public ISerializable
{
public:
    User(std::string_view username, std::string_view password)
        : m_username(username)
        , m_password(password)
    {}

    User() = default;

    virtual ~User() = default;

    SERIALIZABLE_INTERFACE

protected:
    __attribute__((annotate("serializable")))
    std::string m_username {};
    __attribute__((annotate("serializable")))
    std::string m_password {};
};

Атрибуты записаны в GNU стиле, так как libclang отказывается распознавать формат атрибутов из C++20, и не аннотированные атрибуты он тоже не поддерживает. Обход директорий с исходным кодом:

for (const auto& file : fs::recursive_directory_iterator(argv[1]))
{
    if (file.is_regular_file() && file.path().extension() == ".hpp")
    {
        processTranslationUnit(file, dst);
    }
}

Определение функции processTranslationUnit:

auto processTranslationUnit(const fs::path& path, const fs::path& targetDir) -> void
{
    const auto pathname = path.string();

    arg::Context context { false, false };
    auto translationUnit = arg::TranslationUnit::parse(context, pathname.c_str(), CXTranslationUnit_None);

    arg::ClassExtractor extractor;
    extractor.extract(translationUnit.cursor());

    const auto& classes = extractor.classes();

    for (const auto& [name, c] : classes)
    {
        SerializableDefGenerator::processClass(c, path, targetDir.string());
    }
}

В данной функции интерес для нас представляет только ClassExtractor — все остальное необходимо для формирования AST. Определение функции extract выглядит следующим образом:



void ClassExtractor::extract(const CXCursor& cursor)
{
    clang_visitChildren(cursor, [](CXCursor c, CXCursor, CXClientData data)
        {
            if (clang_getCursorKind(c) == CXCursorKind::CXCursor_ClassDecl)
            {
			/* обработать класс */
			/* - получить информацию о членах-данных */

                        /* - получить информацию об атрибутах */
            }
            return CXChildVisit_Continue;
        }
        , this);
}

Здесь мы уже видим непосредственно С API функции для clang. Мы намеренно оставили только тот код, который необходим для понимания того, как используется libclang. Все, что остается “за кулисами”, не содержит важной информации — это всего лишь регистрация имен классов, членов-данных и т.п. Более подробный код может быть найден в репозитории.

Ну и, наконец, в функции processClass проверяется наличие атрибутов сериализации у каждого найденного класса, и, если таковой имеется — генерируется файл с определением необходимых функций. В репозитории представлены конкретные примеры: где взять имя/имена namespace’ов (данная информация хранится непосредственно в классе Class) и путь к заголовочному файлу.

Для вышеупомянутой задачи используется библиотека Argentum, которую я, к сожалению, Вам использовать не рекомендую — я начал ее разработку для иных целей, но ввиду того, что для данной задачи мне как раз понадобился функционал, который там был реализован, а я — ленив, я не стал переписывать код, а просто выложил ее на Bintray и подключаю в CMake файле через менеджер пакетов Conan. Все, что предоставляет эта библиотека — простые обертки над clang C API для классов и членов-данных.

И еще одно маленькое замечание — я не предоставляю готовую библиотеку, я лишь рассказываю, как ее написать.

UPD0: вместо libclang можно использовать cppast. Спасибо masterspline за предоставленную ссылку.

1. github.com/isnullxbh/dsl-habr
2. github.com/isnullxbh/Argentum
Теги:
Хабы:
Всего голосов 9: ↑7 и ↓2+12
Комментарии44

Публикации

Истории

Работа

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

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

25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань