Ещё одна сериализация для C++

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

Хотя инструментов для сериализации существует достаточно много, я предлагаю вашему вниманию ещё один. Он не лучше и не хуже других, и был создан с акцентом на простоту (кто бы мог подумать?) и компактность (опять же!), не сильно влияющую на производительность работы с ранее сериализованными данными.


Привет! Я Андрей Коваленко (Keva). Я занимаюсь лингвистическими задачами и делаю поисковые движки. Первый Апорт!, движок Rambler образца 2000 года и украинская <META> - мои разработки. Недавно завершил работу над большой корпоративной поисковой системой МойОфис.

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

Инструмент исполнен как набор шаблонных методов, поддерживающих наиболее распространённые типы и классы данных, и может дополняться в коде проекта короткими определениями для отправки результатов в тот или иной коллектор. Всего декларируются 4 шаблонных метода в глобальном пространстве имён, где T - сериализуемый тип, O - коллектор, а s - источник:

template <class T> size_t  GetBufLen( const T& );
template <class O,
          class T> O*      Serialize( O*, const T& );
template <class S,
          class T> S*      FetchFrom( S*, T& );
template <class S,
          class T> S*      SkipToEnd( S*, const T* );

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

Serialize( O* o, const T& t ) записывает объект t в коллектор, типизированный указатель на который передаётся первым параметром.

FetchFrom( S* s, T& t ) извлекает из источника s объект t.

SkipToEnd( S* s, const T* ) проматывает объект заданного типа T без его создания, переходя к следующему сериализованному объекту. В данном случае T* используется лишь для указания типа, и обычное его использование - передача (const T*)nullptr.

Для накопителей "память (char*)" и "c-style файл (FILE*)" примитивы заданы сразу:

template <> auto  Serialize( char* o, const void* p, size_t l ) -> char*
  {  return o != nullptr ? l + (char*)memcpy( o, p, l ) : nullptr;  }
template <> auto  Serialize( FILE* o, const void* p, size_t l ) -> FILE*
  {  return o != nullptr && fwrite( p, sizeof(char), l, o ) == l ? o : nullptr;  }

template <> auto  FetchFrom( const char* s, void* p, size_t l ) -> const char*
  {  return s != nullptr ? (memcpy( p, s, l ), l + s) : nullptr;  }
template <> auto  FetchFrom( FILE* s, void* p, size_t l ) -> FILE*
  {  return s != nullptr && fread( p, sizeof(char), l, s ) == l ? s : nullptr;  }

template <> auto  SkipBytes( const char* s, size_t l ) -> const char*
  {  return s != nullptr ? s + l : s;  }
template <> auto  SkipBytes( FILE* s, size_t l ) -> FILE*
  {  return s != nullptr && fseek( s, l, SEEK_CUR ) == 0 ? s : nullptr;  }

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

class blackhole {};

template <> auto  Serialize( blackhole* o, const void*, size_t ) -> blackhole*
  {  return o;  }

Библиотека содержит примитивы кросплатформенной записи и чтения базовых типов (за исключением float и double - они пишутся as is), а также многих классов из стандартной библиотеки - vector, string, map, list, pair, tuple. Для собственных классов достаточно создать одноимённые методы, и они также будут успешно обрабатываться:

class storable
{
  int i;
  std::string s;
public:
  template <class O> O* Serialize( O* o ) const
  {
    return ::Serialize( ::Serialize( o, i ), s );
  }
  template <class S> S* FetchFrom( S* s )
  {
    return ::FetchFrom( ::FetchFrom( s, i ), this->s );
  }
};

...

storable object;

::Serialize( stdout, object );

Целочисленные значения от двух байт (uint16_t и выше) сериализуются с базовой компрессией - порциями по 7 бит для ненулевой части бит. То есть uint32_t(127) займёт 1 байт, а 128 уже два, а вот 65535 (16 значимых бит) уже 3 байта. На круг получается компактнее. Так, результатом сериализации вектора из пяти тридцатидвухбитных целых в примере ниже будет семибайтная последовательность:

  auto  my_vec = std::vector<uint32_t>{ 10, 20, 70, 100, 130 };
  char  serial[0x100];
  auto  endptr = ::Serialize( serial, my_vec );

  fprintf( stdout, "array of %u bytes: [", unsigned(endptr - serial) );

  for ( auto prefix = "", p = (const char*)serial; p != endptr; ++p, prefix = ", " )
    fprintf( stdout, "%s\\x%02x", prefix, (uint8_t)*p );

  fputs( "]\n", stdout );
array of 7 bytes: [\x05, \x0a, \x14, \x46, \x64, \x82, \x01]

В ней первый байт - размерность сериализованного вектора (5) и значения 10, 20, 70 и 100 занимают по 1 байту, а 130 (0x0102 = 128 + 2) записано двумя - младшим байтом (2 с флагом продолжения 0x80) и старшим (0x01).

Типизация в коллекторах не предусмотрена - это ответственность самой программы - знать, что читать. Поэтому при желании можно сериализовать std::vector<int>, а десериализовать std::list<int>; сериализовать const char[] = "...", а десериализовать std::string.

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

Ну а код доступен на github.

@Keva
07.03.2025 15:47 UTC
Первоисточник

Комментарии

@unreal_undead2
07.03.2025 13:23 UTC
0

Несколько неочевидно, что по крайней мере при сериализации в память (и, соответственно, в любом generic коде) нельзя написать просто

Serialize(out, v1); Serialize(out, v2); ...

а надо

out = Serialize(out, v1); out = Serialize(out, v2); ...

Может стоит [[nodiscard]] добавить с пояснением?

@Keva
08.03.2025 09:10 UTC
0

Согласен. Благодарю за дельное замечание.

@Sazonov
07.03.2025 13:44 UTC
+1

Зачем вам сырые указатели? Есть ли проверка на LE / BE? Будет ли работать с учётом возможного разного выравнивания в структурах?

В самой статье очень не хватает наглядных примеров использования.

@Keva
08.03.2025 09:16 UTC
0

Проверка LE/BE в данном случае не нужна, так как целочисленные значения сериализуются последовательно семибитными порциями со сдвигом вправо на 7 бит после каждого записанного байта:

- записываем-байт-как-младшие-семь-бит-и-старший-бит-если-есть-биты-старше
- сдвигаем-сериализуемое-значение-на-семь-бит-вправо
- если-не-ноль-повторяем-цикл

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

Как сказано выше, для ряда классов из std:: сделаны специализации сериализатора, а для собственных классов следует прописывать функцию-член Serialize.

Согласен, примеров использования стоило бы добавить.

08.03.2025 21:11 UTC
0

А куда вы восьмой бит записываете? Как я понял, с вашим подходом для записи 2 байт надо 19 бит, то есть 3 байта. Зачем такие сложности? Вы уверены что побитовая обработка положительно сказывается на быстродействии?

Если можно - сделайте еще бенчмарк и сравните что по размеру и производительности у вас в сравнении с flatbuffers.

Я, если честно, всё равно не понял, как оно корректно отработает если сериализовать на машине с LE и распаковать на машине с BE.

И всё таки, что насчёт сырых указателей?

09.03.2025 09:21 UTC
+1

А что не так с "сырыми" указателями?

Для записи числа, которое можно представить 2 байтами (0 - 65535) нужен 1 байт, если число меньше 128, 2 байта, если число меньше 16384 и три байта в остальных случаях. В моих задачах это даёт экономию размера примерно 7%, а для uint32_t и того больше. Впрочем, писать таким способом или иным - вопрос выбора.

Изначально это делалось для словарей, так что данные, созданные на одной платформе, должны верно читаться и на обратном порядке следования байт (см. словари libmorph). Порции для записи берутся арифметическими операциями (см. код https://github.com/big-keva/mtc/blob/557336ee9bfe935c4a7afe31a2a06ee8fa9ab189/serialize.h#L227).

Сравнивать же с flatbuffers или с protobuf некорректно - это совсем разный функционал. Этот код, конечно же, быстрее, чем flatbuffers/protobuf, компактнее, если не использовать компрессию (zlib) и не имеет и половины того функционала, что есть в названных библиотеках.

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

В следующей статье я покажу работу сериализованного базисного дерева, небольшое (~10%) замедление при работе по сравнению с его развёрнутой формой при снижении требований к памяти более чем на порядок.

09.03.2025 11:22 UTC
0

Не, я понимаю, можно на си писать. Но по поводу плюсов вот тут многое объясняется: https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#S-resource

09.03.2025 12:59 UTC
0

В данном случае замечание про владение очень не к месту.

Требовать здесь unique_ или shared_ptr - это примерно как возвращать из vector<>::data() или string::c_str() вместо указателя - std::*_ptr(), право!

::Serialize никоим образом и ни в один момент времени не является владельцем объекта по указателю.

09.03.2025 14:26 UTC
0

Или можно просто передать по ссылке и не заморачиваться.

10.03.2025 08:03 UTC
0

Нет. Это не так. Если передать по ссылке, то, например, для сериализации в память надо будет писать специальную обёртку, а не использовать простой char* - указатель, а вместо возврата nullptr в случае ошибки придётся бросаться exception'ами.

@Tyiler
08.03.2025 19:11 UTC
0

Приветствую.

Делал похожее, только с параметром "..." (template parameter pack). Получилось коротко довольно, но правда не заморачивался с байтовой обрезкой в завис от значения, как у вас.

Такой принцип:

  • в начале пишем общую длину

  • затем для скаляров (int, double..) сразу пишем значения

  • для массива пишем сначала размер, потом значения

  • для строк тоже сначала размер, потом значение

Напишу прямо тут немного кода:

Скрытый текст
template<typename... Fields>
class SerialReader{
public: 
    SerialReader(const std::string& m, Fields&... fields):
      in_(m){        
        const int allType = intSz_;
        inSize_ = int(m.size());         
        char* pData = (char*)m.data();
        if (inSize_ < allType || inSize_ != *(int*)(pData)){
            ok_ = false;
            return;
        }
        offs_ = allType;
        (readField(fields), ...);
    } 
    bool ok()const{
        return ok_;
    }
private:
    bool checkFieldSize(int fieldSize){
        if (offs_ + fieldSize > inSize_){
            ok_ = false;
        }
        return ok_;        
    }
    void readField(std::string& s){ 
        if (ok_){
            if (!checkFieldSize(intSz_)) return;
            const char* pData = in_.data();
            int strSz = *((int*)(pData + offs_));  offs_ += intSz_;
            
            if (!checkFieldSize(strSz)) return;
            s = std::string(pData + offs_, strSz);  offs_ += strSz;
        }        
    }
    void readField(int& v){
        if (ok_){
            if (!checkFieldSize(intSz_)) return;
            const char* pData = in_.data();
            v = *((int*)(pData + offs_));  offs_ += intSz_;
        }
    }
    void readField(std::vector<int>& out){
        if (ok_){
            if (!checkFieldSize(intSz_)) return;
            const char* pData = in_.data();
            int vsz = *((int*)(pData + offs_));  offs_ += intSz_;
            if (!checkFieldSize(vsz * intSz_)) return;
            out.reserve(vsz);
            memcpy(out.data(), pData + offs_, vsz * intSz_);
            offs_ += vsz * intSz_;
        }
    }
    const int intSz_ = 4;
    int inSize_{};    
    int offs_ = 0;
    bool ok_ = true;
    const std::string& in_;
};

template<typename... Fields>
class SerialWriter{
public: 
    SerialWriter(const Fields&... fields){
        (fieldSize(fields), ...);

        outSize_ += intSz_;
        out_.resize(outSize_);
        writeField(outSize_);

        (writeField(fields), ...);
    } 
    std::string out(){
        return out_;
    }
private:
    void fieldSize(const std::string& s){
        outSize_ += intSz_ + s.size();
    }
    void fieldSize(int){
        outSize_ += intSz_;
    }
    void fieldSize(const std::vector<int>& v){
        outSize_ += intSz_ + int(v.size()) * intSz_;
    }
    void writeField(const std::string& s){ 
        char* pOut = out_.data();
        const auto ssz = s.size();
        *((int*)(pOut + offs_)) = ssz;        offs_ += intSz_;
        memcpy(pOut + offs_, s.data(), ssz);  offs_ += ssz;
    }
    void writeField(int v){
        char* pOut = out_.data();
        *((int*)(pOut + offs_)) = v; offs_ += intSz_;
    }
     void writeField(const std::vector<int>& arr){
        char* pOut = out_.data();
        const int asz = int(arr.size());
        *((int*)(pOut + offs_)) = asz; offs_ += intSz_;
        memcpy(pOut + offs_, arr.data(), asz * intSz_);
        offs_ += asz * intSz_;
    }
    const int intSz_ = 4;
    int outSize_{};    
    int offs_ = 0;
    std::string out_;        
};

Там только типы int и string, любые другие понятно думаю как добавить.

Теперь как этим пользоваться, пусть есть структура:

struct MyStruct{
  std::string field1;
  int field2{};
  std::string field3;
  std::vector<int> field4;
  
  std::string serialn();
  bool deserialn(const std::string& m);
}

Добавили 2 метода ей: serialn и deserialn.

Вот что внутри пишем:

std::string MyStruct::serialn(){
  const auto out = SerialWriter(field1, 
                                field2,
                                field3,
                                field4).out();
  return out;
}

bool MyStruct::deserialn(const std::string& m){
  const auto ok = SerialReader(m,
                               field1,
                               field2,
                               field3,
                               field4).ok();
  return ok;
}  

Здесь этот код находится, он правда в контексте конкретном, то есть не вынесен в общий.

@Keva
08.03.2025 19:20 UTC
0

Ну да, похожий подход.

Я этот текст написал как материал, на который можно ссылаться в следующих статьях.

Сейчас пишу "Работу с сериализованными данными без десериализации". То есть вообще без резервирования памяти.

@cdriper
11.03.2025 15:29 UTC
0

в мире уже существует 100500 библиотек сериализации, с хорошей документацией, примерами, юнит тестами...

чем ваша то лучше? свой велосипед ближе к телу?

@Keva
11.03.2025 16:51 UTC
0

Эта была сделана в своё время для построения дампов словарных данных, по которым можно эффективно искать, не десериализуя.Где активно и используется (libmorph).

11.03.2025 16:54 UTC
0

я ж не спрашиваю, для чего была сделана

я спрашиваю, зачем выкладывать

чтобы кто-то начал использовать код в таком несъедобном виде, он должен обладать какими-то супер уникальными свойствами

11.03.2025 17:00 UTC
0

Выложил для того, чтобы было на что ссылаться при выкладке библиотеки полнотекстового поиска.

12.03.2025 04:43 UTC
+1

я спрашиваю, зачем выкладывать

Ну как зачем? Просто опытом поделится, вдруг кому-то будет полезно. Оно же тут не толкается, считаю пусть будет yet another статья или библиотека, чем не будет.

12.03.2025 05:05 UTC
0

Да ладно...

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

@Notevil
12.03.2025 04:50 UTC
0

У меня вопрос возник по поводу функций GetBufLen и SkipToEnd. В общем случае ведь, чтобы понять размер буфера требуемый для хранения структуры или пропустить ее в общем буфере. Нужно пройтись вглубь по всей структуре, разве это не будет равносильно по коду и времени сериализации/десериализации?

Пример есть класс, в котором вектор каких-то объектов.

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

Дополнительно про упакованные числа. Чтобы понять сколько байт нужно пропустить для числа в буфере, его ведь тоже нужно прочитать сначала?

@Keva
12.03.2025 05:02 UTC
0

Да, вы совершенно правы: если нужна оценка размера некоторой массивной и кучерявой структуры данных, то GetBufLen проделывает весь путь Serialize, а время на её исполнение пропорционально времени Serialize с коэффициентом, равным отношению времени операции суммирования ко времени операции записи в конкретный коллектор.

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

12.03.2025 06:14 UTC
0

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

Ну звучит как достаточно важная оптимизация, но потенциально время самого GetBufLen может ее нивелировать.