В данной статье речь пойдет об автоматизации процесса сериализации в 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?
2. github.com/isnullxbh/Argentum

Комментарии (41)


  1. masterspline
    09.12.2019 22:50

    Думаю, тут будет полезна ссылка на CppAST и конкретно serialization.cpp в примерах.


    1. isnullxbh Автор
      09.12.2019 23:50

      Спасибо за ссылку! Мне не приходилось использовать данную библиотеку, но, похоже, она предоставляет более функциональный интерфейс чем libclang (я говорю именно о C API). Обязательно добавлю в пост.


  1. mapron
    09.12.2019 22:59

    Двойственное ощущение от статьи.
    Прежде всего не покидает ощущение, что цель «а давайте поиспользуем новых фич» была превыше «а давайте это все будет читаемо и расширяемо».
    Почему-то в статье никак не упомянуты существующие реализации, хотя бы обзорно как-то, например Boost.Serialization, Protobuf, QDataStream, etc, и хотя бы какое-то сравнение, чем наш «велосипед» лучше/хуже их (если честно, вообще не вижу по какому пункту он лучше. Ну ладно, по пункту «не надо тащить буст», объективно, лучше.).

    Так же вызывают вопросы многие детали реализации.


    // for support a std::deque we forced to use a std::distance()
    const auto len = static_cast<std::uint16_t>(
    std::distance(value.cbegin(), value.cend()));

    std::size — не то?

    std::ostream по дефолту не использует исключения. Неплохо бы проверять вообще результаты записи, например.

    auto stream_reader<T, void>::read(std::istream& is, T& value)
    Почему не аллоцировать весь контейнер сразу, например, чтобы не делать временные значения и push_back?

    stream_reader<std::string>::write
    Писатель читает, читатель пишет :) все логично.

    const auto len = static_cast<typename stream_writer::size_type>(value.size());
        os.write(reinterpret_cast<const char*>(&len), sizeof(len));
    

    совместимость сериализуемого size_t между 32 и 64 платформами?
    Может быть учет ByteOrder в сериализации?
    Да не, и так сойдет.

    В общем ваш пример приложения хорош для объяснения как можно применять новые средства языка, но не для полноценной переносимой сериализации.


    1. gudvinr
      09.12.2019 23:54

      Protobuf

      Не всем подойдёт. Например, он не очень удобный, когда объекты, с которыми работает программа, имеют много методов.
      Т.к. pb с наследованием не очень дружит, то обращение к внутренним его полям будет представлять собой не очень приятное зрелище.
      Плюс у него довольно жирный рантайм даже в минимальной версии, без рефлексии.
      Возможно, более подходящим в этом случае будет flatbuffers, но это опять же не избавит от необходимости писать обёртки над fb классами.


      QDataStream

      Нужен Qt, что не сказать что лучше буста.


      1. domix32
        10.12.2019 13:14
        +1

  1. isnullxbh Автор
    09.12.2019 23:31

    stream_reader<std::string>::write

     Спасибо, что заметили, исправлю.


    совместимость сериализуемого size_t между 32 и 64 платформами?

    Почему Вы решили, что там будет size_t?


    std::size — не то?

    А std::size точно умеет работать с std::forward_list?


    std::ostream по дефолту не использует исключения. Неплохо бы проверять вообще результаты записи, например.

    Проверять каким образом? Количество прочитанных/записанных байт возвращается. Маску для исключений пользователь может установить вне функции чтения/записи.


    Может быть учет ByteOrder в сериализации?

    Писал об этом в самом начале.


    а давайте поиспользуем новых фич

    Из новых фич здесь только концепты. Поправьте меня, если я не прав.


    Почему не аллоцировать весь контейнер сразу, например, чтобы не делать временные значения и push_back?

    Я так и делаю — для sequence контейнеров. Вы же, видимо, говорите о специализации для ассоциативных контейнеров — по очевидным причинам там не получится так сделать.


    1. mapron
      11.12.2019 02:59

      Да, про ассоциативные контейнеры мой косяк, согласен.

      Про size — ну так да, у него ж ровно такая же реализация через distance емнип.


      1. isnullxbh Автор
        11.12.2019 09:04

        Взято с https://en.cppreference.com/w/cpp/iterator/size:


        template <class C>
        constexpr auto size(const C& c) -> decltype(c.size());

        Посмотрите на тип возвращаемого значения — оно выводится через size.


  1. Ryppka
    10.12.2019 09:36
    +1

    Возможно, я отстал от жизни, а деструкторы абстрактных классов уже не принято объявлять виртуальными?


    1. isnullxbh Автор
      10.12.2019 09:47

      Принято. А про какой именно класс Вы говорите? ISerializable?


      1. Ryppka
        10.12.2019 09:50

        Да там вроде и нет другого совсем уж «обстрактнаго»)))


        1. isnullxbh Автор
          10.12.2019 09:54

          У него спецификатор доступа — protected. Если бы он был public — другое дело.
          Подробнее здесь: http://www.gotw.ca/publications/mill18.htm


          Guideline #4: A base class destructor should be either public and virtual, or protected and nonvirtual.


          1. Ryppka
            10.12.2019 10:03

            Т.е. Вы нигде не используете указатель на базовый класс и не удаляете объект производного класса через указатель на базовый? А зачем тогда виртуальные функции-члены, если у Вас каждый тип конкретный?


            1. isnullxbh Автор
              10.12.2019 10:11

              Т.е. Вы нигде не используете указатель на базовый класс и не удаляете объект производного класса через указатель на базовый?

              Нет, я не удаляю объекты классов-наследников ISerializable через указатель на ISerializable.


              А зачем тогда виртуальные функции-члены, если у Вас каждый тип конкретный?

              Каждый — это какой?


              1. Ryppka
                10.12.2019 10:19
                +3

                Каждое использование типа, наследующего от ISerializable. Просто публичный виртуальные функции-члены нужны только для того, чтобы через указатель на базовый тип вызывать реализацию фактического типа. Считайте, что ссылка в данном контексте — это тоже указатель. В этом случае у Вас так или иначе создается объект подкласса и его адрес сохраняется в указателе на базовый класс. Его надо так или иначе очистить и тут-то и необходим виртуальный деструктор.
                Если же Вы нигде не вызываете виртуальные методы через адрес/ссылку на базовый класс, то и виртуальные, а тем более абстрактные методы не нужны, тут как раз концепты, шаблоны, CRTP и т.д. уместны.
                Кстати, упомянутый Вами GOTW — как раз про чистые миксины, там, действительно, виртуальный деструктор противопоказан.
                Так что либо крестик снимите, либо…


                1. isnullxbh Автор
                  10.12.2019 10:28

                  А почему Вы решили, что я создаю объекты подобным образом?
                  Вы говорите о следующем:
                  ISerializable* ptr = new UserInfo();
                  Разве Вы видели, чтобы я в коде таким образом использовал ISerializable?


                  Простите, какой крестик я должен снять?


                  1. Ryppka
                    10.12.2019 10:46
                    +3

                    Это неважно, я говорю о том, что интерфейс внутренне несогласован.
                    Ваше определение не содержит концептуальной ошибки только при одном сценарии использования, когда использовать полиморфизм времени компиляции не хочется, а полиморфизм нужен, вот и используем позднее связывание:
                    class User: public Iserializable {...};
                    void do_something(ISerializable& is);

                    // owning context

                    User user;
                    do_somthing(user);


                    Ну, может быть, так и надо, но мне представляется спорным решением, поскольку:

                    • Полиморфизм времени компиляции может оказаться уместнее, а тут уже vtbl и все такое...
                    • Нет никаких языковых гарантий, что кто-то кое-где у нас порой не сделает new/delite с указателем на базовый класс. К сожалению в C++ способа такого избежать.

                    Мне кажется, что это то самое inheritance, которое по словам Шона Пэрента is root cause of evil…
                    Как по мне, то я представляю концептуально правильное решение так:
                    1. Используем позднее связывание и указатель на базовый класс — деструктор должен быть виртуальным, хотя бы на всякий случай.
                    2. Используем полиморфизм времени компиляции. Базовый класс тут не нужен, современный C++ позволяет использовать концепты и т.д.
                    3. Нам нужен полиморфирм, но мы хотим за счет позднего связывания избежать распухания кода — теоретически можно сделать статический защищенный деструктор, но как мы будем это гарантировать. Минимум — тут нужен комментарий, документирующий наше решение и ограничения на способ использования. И этот комментарий становится «неотъемлемой частью интерфейса»

                    Я не увидел в объявлении ISerializable ни одного из трех вариантов, на что и указал. Вот и все.


                    1. isnullxbh Автор
                      10.12.2019 11:03
                      -1

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

                      А что за случай? Конкретно? Удаление по указателю на базовый класс? Так у Вас даже код не скомпилируется, ведь деструктор — protected.


                      Используем полиморфизм времени компиляции. Базовый класс тут не нужен, современный C++ позволяет использовать концепты и т.д.

                      Соглашусь, можно было использовать концепты. Но я захотел наследование.


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

                      Без комментариев.


                    1. isnullxbh Автор
                      10.12.2019 11:09

                      del


                    1. isnullxbh Автор
                      11.12.2019 15:00

                      А что значит «несогласован»? Интерфейс предоставляет библиотека, и она же предоставляет другие компоненты, которые работают с этим интерфейсом. Если пользователь решил использовать интерфейс не по назначению — вопросы к пользователю.


                      Почему деструктор не public? А зачем ему таким быть? Хорошо, давайте его сделаем public — и будем где-то в куче хранить объекты производных классов, а что мы с ними будем делать — сериализовать и десериализовать?


                      1. Ryppka
                        12.12.2019 09:20

                        Зря спорите, да еще и притягиваете рекомендацию (кстати, достаточно спорную) из другой области. Не надо защищать право создателя библиотеки на расстановку капканов и рытье волчьих ям для пользователя. ;)
                        Содержательно Ваш подход сводится к тому, что на Rust, например, выглядит как-то так:

                        struct S{};
                        trait T {};
                        impl T for S {};
                        fn f(s: impl T)...
                        

                        Ясный и понятный прием: мы явно запрещаем компилятору мономорфизацию в пользу динамической диспетчеризации.
                        К сожалению, явно выразить это в C++ невозможно. И при взгляде на абстрактный класс с виртуальными методами, по сути просто интефейс. В нем мы запрещаем инстанциацию через new? Нет, все-равно можно создать через new инстанс потомка и присвоить его указателю на абстрактный класс! А этого делать нельзя! И вместо того, чтобы просто документировать ограничения на использование Вашего кода, вы гордо заявляете «Сам дурак!».


                        1. isnullxbh Автор
                          12.12.2019 10:04
                          +1

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


                        1. dbagaev
                          12.12.2019 14:47

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

                          Проблема в том, что защищенный деструктор накладывает ограничения на пользование интерфейсом. Например, такие интерфейсы нельзя передать через shared_ptr. У нас такая необходимость возникает иногда при работе с rxcpp, где в силу большого количества копирования контролировать действительную точку удаления объекта явно невозможно.


                        1. PsyHaSTe
                          12.12.2019 16:03

                          Простите, но тут нет никакой динамической диспетчеризации. impl — это экзистенциальный тип, он всегда конкретный.


                          Чтобы была динамика должно быть fn f(s: dyn &T) или fn f(s: Box<dyn T>). в зависимости от того, даем мы ссылку на стековое значение или забокшенное соответственно.


                          Если в программе не написано dyn то никакой динамической диспетчеризации не будет. Это слово специально добавили в язык, чтобы был виден маркер "внимание: тут динамика".


                          1. Ryppka
                            12.12.2019 21:16

                            Конечно, Вы правы, спасибо, я слишком поверхносно знаю Rust и сомневался, как тут написать правильно. Действительно dyn &T, а не & dyn T? Но общая идея, надеюсь, была понятна?


                            1. PsyHaSTe
                              12.12.2019 21:24

                              Общая идея — понятна.


                              Я к тому, что fn f(s: impl T) это скорее запись для ленивых нормального варианта fn f<Type: T>(s: Type). До сих пор не понимаю, зачем в язык добавили еще один вариант записи одного и того же (видимо, чтобы всех запутать), но вот так.


  1. dbagaev
    10.12.2019 15:11

    Основная проблема с описанным сериализатором в том, что при сериализации он теряет семантику данных и заточен под конкретный бинарный формат данных. Что если сериализовать необходимо в разные форматы, например, бинарный, json, xml и тот же protobuf? Кроме того, есть еще одна важная задача: если сериализуемые данные достаточно сложны, необходимо обеспечивать сохранение и восстановление ссылок между объектами, а это не такая тривиальная задача, как может показаться.

    Мы используем другую идею для сериализации: для каждого класса или структуры данных, который предполагается сериализовать, существует структура-двойник, который используется только для передачи данных и состоит из открытых полей, но не содержит никакой логики вообще, кроме валидации. Сериализация состоит из двух дополнительных слоев: один отвечает за преобразования и валидацию данных между классами, реализующими поведение и открытыми структурами для сохранения, и еще один слой реализует сохранение в каждый конкретный формат данных (бинарный, xml, protobuf).

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


    1. isnullxbh Автор
      10.12.2019 15:50

      существует структура-двойник, который используется только для передачи данных и состоит из открытых полей

      Похоже на описание DTO


      Что если сериализовать необходимо в разные форматы, например, бинарный, json, xml и тот же protobuf?

      Спасибо, что указали на данную проблему, это интересно.


      Кроме того, есть еще одна важная задача: если сериализуемые данные достаточно сложны, необходимо обеспечивать сохранение и восстановление ссылок между объектами

      Это уже больше на репликацию похоже.


      1. dbagaev
        10.12.2019 16:35

        Похоже на описание DTO

        Да, это именно они и есть.

        Это уже больше на репликацию похоже.

        Нет, почему же, все зависит от сложности данных. В одной из компаний, где я работал, нужно было сохранить и прочитать довольно сложную структуру данных в файл. Ссылки сохранялись преобразованием указателей в аналоги реляционных идентификаторов и обратно на лету, но пляски начинались в тот момент, когда надо было восстановить дополнительное состояние объекта согласно ссылкам, которые он содержит. А еще объекты могли содержать огромные массивы данных, которые сохранялись в отдельные blob-секции в файле, и их тоже надо было дополнительно распаковать, но только после того, как объект прочитан из файла. В результате мы использовали двойной проход при загрузке: при де-сериализации объекты и ссылки восстанавливались, а второй проход запускал инициализацию. Я сейчас точно не вспомню, в чем была основная загвоздка, но в данном случае описанный вами интерфейс не позволял инициализировать всю структуру без второго прохода.

        Предполагаю, что DTO смогли бы решить эту проблему, но кодовая база на тот момент была уже такая большая (коду было 25 лет, он писался с начала 90-х), что никто бы не одобрил подобный масштабный рефакторинг. С оглядкой на этот опыт, я бы сейчас отнес реализацию ISerializable в классах бизнес-логики скорее к анти-паттернам. DTO хоть и выглядят громоздко и требуют какое-то количество boilerplate кода, впоследствии более гибкие и удобные в использовании.


        1. isnullxbh Автор
          10.12.2019 16:45

          Спасибо за информацию! Не хотите об это более подробно рассказать, с техническими деталями? Лично мне было бы очень интересно почитать.


          1. dbagaev
            11.12.2019 14:03

            Исходные данные — это иерархическая сложная CAD-структура, объекты могут ссылаться один на другого не только в вертикальных направлениях дерева иерархии, но еще и горизонтально. Геометрические объекты могут состоять из сотен тысяч элементов (точки, грани) или иметь большой объем (текстуры), которые часто удобнее сохранять в отдельном бинарном виде. Поэтому файл сохранения состоит из секций. Головная секция-заголовок содержит описание всей структуры в семантическом формате (например, json), а данные объектов хранятся в отдельных blob-секциях. Получившиеся файлы секций пакуются в zip-архив, который дополнительно шифруется.

            При сохранении все указатели просто запихиваются в пронумерованый сет, и каждому объекту сопоставляется уникальный индекс. При загрузки при создании объекта индексы заменяются обратно на указатели. Поскольку в момент загрузки каждого объекта окружение еще не создано полностью, указатели отрезолвить еще нельзя, поэтому нуден второй проход. Фактически, при начальной загрузке, объекты являются DTO, а второй проход преобразует их в живые экземпляры со всеми связями, поэтому я говорил, что DTO позволил бы упростить загрузку.

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


            1. Ryppka
              11.12.2019 14:34

              И главное то, что это быстрее, чем декомпозировать в какое-либо хранилище, а потом собирать, так?


              1. dbagaev
                11.12.2019 15:43

                С учетом гетерогенности данных и отсутствия 20 (уже почти 30) лет назад систем для их эффективного хранения, да, это быстрее и удобнее. Плюс компании нужен был надежный проприетарный формат для хранения данных на стороне клиента, потому что облаков тогда тоже не было.


            1. isnullxbh Автор
              11.12.2019 15:06

              Хорошо, спасибо! Если у меня позже возникнут вопросы, можно будет написать Вам в лс?


              1. dbagaev
                11.12.2019 15:43
                +1

                Да, конечно, пишите, с удовольствием отвечу


                1. isnullxbh Автор
                  11.12.2019 16:15

                  Спасибо!


  1. qw1
    10.12.2019 23:18
    +1

    А зачем везде писать

    auto write(...) -> size_t
    если
    size_t write(...)
    меньше занимает символов и читается привычнее?


    1. isnullxbh Автор
      11.12.2019 09:07

      читается привычнее?

      Субъективно.


      Извините за столь короткий ответ: мне так нравится.


    1. dbagaev
      11.12.2019 13:50

      Такая запись позволяет избежать, например, указания имени класса в типе возвращаемого значения, если оно относится к самом классу, ну например:

      class Foo
      { 
          struct Bar {}; 
          Bar func(); 
      };
      
      // А теперь можно так
      Foo::Bar Foo::func() {}
      
      // А можно и так
      auto Foo::func() -> Bar {}
      

      Но это, конечно, дело вкуса и соглашений.


      1. qw1
        11.12.2019 14:15
        +1

        Понятно, что задумывалось ради

        template <typename TA, typename TB>
        auto add(TA a, TB b) -> decltype(a+b) {
           return a+b;
        }
        Но для простых типов зачем так писать.
        Можно возразить, что лучше всё записывать единообразно, пусть и длиннее. Но тут количество существующего кода со старым синтаксисом перевесит любые новые попытки.


        1. dbagaev
          11.12.2019 15:39
          +1

          Я тоже не знаю, зачем так писать для простых типов и предпочитаю более короткую запись, но это дело вкуса, и я предпочитаю не придираться, разве что это мой код-ревью :-D