Ещё один подход к сериализации на С++

Автор: Сабельников Андрей Николаевич
Источник: RSDN Magazine #1-2006
Опубликовано: 23.05.2006
Версия текста: 1.0
Для чего?
Как использовать
Как это устроено

Примеры к статье

Для чего?

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

Если вы пишете на С++, то ваша программа, скорее всего, состоит из объектов классов, которые в своей совокупности образуют некую систему данных, и кода, работающего с этими данными. И практически всегда вы хотите в какой-то момент сохранить в том или ином виде эти данные – будь то результат многолетних вычислений программы или просто текущее состояние каких-то компонентов системы, а потом снова загрузить эти данные назад, в программу, как будто бы и ничего не происходило. Или, скажем, отправить эти данные по сети, другой программе. И при этом очень не хочется тратить много времени на программирование сохранения/загрузки, упаковку структур в какие-то изобретённые сегодня утром форматы, отладку всего этого, модификацию в связи с появлением в структурах данных новых полей, документирование и прочую головную боль.

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

Как использовать

Использовать невероятно просто. Предположим, у вас имеется некоторая, произвольно придуманная структура классов:

      struct A
{
  CStringA          m_strA;  
  CStringW          m_strW;  
  DWORD             m_dwrd_val;
  GUID              m_guid_val;
  std::list<DWORD>  m_list_of_dwords;
  std::vector<GUID> m_vector_of_guids;

  // какие-то функции 
bool a();
  int  b();
  void c();
};

struct B {
  CStringW     m_strw_in_b;
  A            m_a;
  std::list<A> m_listof_a;
  //какие-то функции
bool e();
  int  f();
  void g();

};

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

Вот как теперь будут выглядеть объявления классов A и B:

      #include 
      "NamedSerialize.h"
      
      
struct A
{
  CStringA          m_strA;  
  CStringW          m_strW;  
  DWORD             m_dwrd_val;
  GUID              m_guid_val;
  std::list<DWORD>  m_list_of_dwords;
  std::vector<GUID> m_vector_of_guids;

  BEGIN_NAMED_SERIALIZE_MAP()
    N_SERIALIZE_ANSI_STRING(
      m_strA, "Here is some text indentifying entry")
    N_SERIALIZE_UNICODE_STRING (
      m_strW, "It’s better to use the name of variable as entry name, "
              "as following")
    N_SERIALIZE_POD               (m_dwrd_val, "m_dwrd_val")
    N_SERIALIZE_POD               (m_guid_val, "m_guid_val")
    N_SERIALIZE_STL_CONTAINER_POD (m_list_of_dwords, "m_list_of_dwords")
    N_SERIALIZE_STL_CONTAINER_POD (m_list_of_guids, "m_vector_of_guids")
  END_NAMED_SERIALIZE_MAP()
// Некие функции
bool a();
  int  b();
  void c();
};

struct B 
{
  CStringW     m_strw_in_b;
  A            m_a;
  std::list<A> m_listof_a;

  BEGIN_NAMED_SERIALIZE_MAP()
    N_SERIALIZE_UNICODE_STRING    (m_strw_in_b, "m_strw_in_b")
    N_SERIALIZE_T                 (m_a,  "m_a")
    N_SERIALIZE_STL_CONTAINER_T   (m_listof_a, "m_listof_a")
  END_NAMED_SERIALIZE_MAP()
// некие функции
bool e();
  int  f();
  void g();
};
СОВЕТ

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

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

ПРЕДУПРЕЖДЕНИЕ

Опасно включать в карту указатели на какие-то объекты в памяти – если объект сохраняется или передаётся по сети на другой компьютер, то, скорее всего, после раскрутки буфера этот указатель будет указывать неизвестно куда, а это никогда не приводило ни к чему хорошему.

Теперь вы можете легко сохранять и загружать данные объектов типа B (собственно говоря, так же легко вы можете сохранять/загружать объекты типа А).

Как это сделать? Ещё проще. Предположим, вы хотите банально сохранять ваши данные в файл или передавать по сети. Тогда нужно, чтобы данные упаковывались в непрерывный кусочек памяти, который потом и употребить «по назначению».

      // Тут содержится реализация элементарного контейнера данных,
// структурированного в виде дерева в памяти 
#include "InMemStorage.h" 
bool DoSaveLoadBData(B& dataToLoadOrSave, CMemoryObject& inMemObj)
{
  // Некий объект, сохраняющий в памяти (в древовидной форме) данные, которые
  // в него помещают (древовидное структурированое хранилище)
  StorageNamed::CInMemStorage storage;
  // Сохранение данных из B в хранилище
  dataToLoadOrSave.Store(&storage);
  // Упаковывание данных из древовидной формы в непрерывный кусок памятиsize_t paked_size = storage.PackToSolidBuffer(&inMemObj);
  if(!paked_size) returnfalse;
  //========================================================================// Теперь вы можете делать с данными всё, что угодно, 
  // например, сохранить в файл или послать по сети.
  // Так получаете указатель на буфер://  void* pBuf       = inMemObj.get(0);    
  // А так получаете его длину://  size_t bufferLen = inMemObj.get_size();//  ---------------------------------------------------------// При получении данных (из сети или из файла) 
  // загружаете обратно в классы следующим образом.
  // Сначала копируете их в объект-обёртку для буфера памяти://  inMemObj.set(pBuf, lenght); // (или подключаетесь через функцию inMemObj.attach(void* pbuf, size_t cb);// Потом загружаете в хранилище: //  storage2.LoadFromSolidBuffer(inMemObj);// Из хранилища в объект://  dataToLoadOrSave.Load(&storage2);
//===========================================================================
  StorageNamed::CInMemStorage storage2;
  // Тут происходит загрузка данных из непрерывного куска памяти 
  // в древовидное структурированое хранилище:size_t parsed_len = storage2.LoadFromSolidBuffer(&inMemObj);
  if(!parsed_len) 
    returnfalse;
  // Загрузка данных из структурированного хранилища 
  // в соответствующие поля структуры Breturn dataToLoadOrSave.Load(&storage2);
}

Как видите, ничего сложного. Но, допустим, что вы вдруг захотели тот же самый B, кроме всего прочего, уметь сохранять, например, в реестр:

      bool DoSaveBDataInReg(B& b_data)
{
  // Некий объект, сохраняющий непосредственно в реестре данные, // которые в него помещают, в конструкторе передаётся путь в реестре,
  // куда будут сохраняться (или откуда будут загружаться) данные
  StorageNamed::CInRegStorage storage(
    HKEY_LOCAL_MACHINE, "Software\\SomeFirm", true);
  // Сохранение данных из того же самого объекта типа B в реестрreturn b_data.Store(&storage);
}

Загрузка исполняется обратным образом:

      bool DoLoadBDataFromReg(B& dataToLoad)
{
  // Некий объект, сохраняющий/загружающий в непосредственно в реестре данные,// которые в него помещают,в конструкторе передаётся путь в реестре, куда
  // будут сохраняться или откуда будут загружаться данные.
  StorageNamed::CInRegStorage storage(
    HKEY_LOCAL_MACHINE, "Software\\SomeFirm", true);
  // Загрузка данных из реестра в соответствующие поля Breturn dataToLoad.Load(&storage);
}
ПРИМЕЧАНИЕ

Если в очередной версии вашей программы вы решите добавить пару новых полей в структуру B, вы также сможете загрузить состояние объекта B из «старой версии». Значения новых полей останутся нетронутыми, а после последующего сохранения, когда информация о них сохранится в абстрактное хранилище (файл, реестр или xml), они начнут загружаться, как и все остальные. Точно так же вы можете избавиться от ставших ненужными полей. Эта деталь поможет вам избежать головной боли, связанной с различными версиями сохранённых данных.

Одна из основных идей заключается в том, чтобы отделить описание схемы данных, выраженное картами, от особенностей того или иного способа сохранения данных. Т.е. вы один раз описываете картой, какие элементы нужно сохранять/загружать (и под какими именами – т.к. каждый элемент идентифицируется именем), а потом, используя это описание, сохраняете данные в той или иной форме, которая требуется в данный момент. В качестве рабочего примера реализованы две стратегии сохранения – сохранение в непрерывный участок памяти (употребимо при сохранении в файл или передачу по сети) и сохранение в системный реестр Это также употребимо при сохранении конфигурации и т.п. (собственно, это то, что было непосредственно необходимо автору статьи для работы :) ). Заглянув в реализацию StorageNamed::CInRegStorage (стратегия сохранения в реестр), вы сразу поймёте, что аналогичным образом можно написать стратегию сохранения/загрузки данных для вашего xml-парсера (или просто написать xml-парсер самому, как это принято у некоторых).

Теперь остановлюсь на элементах самой карты сериализации. Ниже перечислены типы макросов, с помощью которых можно включать различные элементы в карту.

BEGIN_NAMED_SERIALIZE_MAP() и END_NAMED_SERIALIZE_MAP() Открывает и закрывает карту сериализации.
N_SERIALIZE_POD(variable, val_name) Включает переменную, являющуюся POD-типом.
N_SERIALIZE_T(variable, val_name) Включает переменную любого типа, имеющего функции-члены template<>Load() и template<>Store(), т.е. в самом простом случае тоже имеющего карты сериализации
N_SERIALIZE_ANSI_STRING(variable, val_name) Включает строковую переменную. Тип не имеет значения, главное, что бы он поддерживал operator(const char*) и operator=(const char*)
N_SERIALIZE_UNICODE_STRING(variable, val_name) Включает строковую переменную. Тип не имеет значения, главное, чтобы он поддерживал operator(const wchar_t*) и operator=(const wchar_t *)
N_SERIALIZE_STL_ANSI_STRING(variable, val_name) Включает строковую переменную. Тип не имеет значения (как правило, std::string), главное, чтобы он поддерживал const char* c_str() и operator=(const char*)
N_SERIALIZE_STL_UNICODE_STRING(variable, val_name) Включает строковую переменную. Тип не имеет значения(как правило, std::wstring), главное, чтобы он поддерживал const wchar_t* c_str() и operator=(const wchar_t*)
N_SERIALIZE_STL_CONTAINER_POD(variable, val_name) Включает переменную, являющуюся контейнером stl (или очень на него похожую по интерфейсу), хранящую внутри себя POD-типы, std::list или std::vector (map, set и т.п. не подойдут).
N_SERIALIZE_STL_CONTAINER_T(variable, val_name) Включает переменную, являющуюся контейнером stl (или очень на него похожую по интерфейсу), хранящую внутри себя объекты типов, имеющих функции-члены template<>Load() и template<>Store(), т.е. в самом простом случае тоже имеющих карты сериализации, std::list или std::vector (map, set и т.п. не подойдут).
N_SERIALIZE_MEM_BLOCK(variable, val_name) Включается для переменной, хранящей кусок памяти произвольной длины, аналогичный по интерфейсу CMemoryObject
BEGIN_NAMED_SERIALIZE_MAP_OVERRIDE_STORE()
BEGIN_NAMED_SERIALIZE_MAP_OVERRIDE_LOAD()
BEGIN_NAMED_SERIALIZE_MAP_OVERRIDE()
Открывает карту сериализации, позволяя переопределить функцию Store().
Открывает карту сериализации, позволяя переопределить функцию Load().
Открывает карту сериализации, позволяя переопределить функции Load() и Store().

Само по себе подключение файла "NamedSerialize.h" не вносит никаких дополнительных зависимостей, несмотря на то, что он умеет работать с stl или ATL::CString, благодаря использованию модного в наши времена «полиморфизма времени компиляции». Другими словами, все скрытые за макросами функции - шаблонные, и они ожидают лишь наличия соответствующих функций-членов у параметров (см. описание макросов карты сериализации выше).

Макросы N_SERIALIZE_ANSI_STRING и N_SERIALIZE_UNICODE_STRING можно использовать в большинстве реализаций классов-обёрток для строк, в том числе ATL::CString (N_SERIALIZE_STL_ANSI_STRING и N_SERIALIZE_STL_UNICODE_STRING соответственно для версий std::string и std::wstring, которые сделаны отдельно ввиду отсутствия у них operator(const char*)). Макросы N_SERIALIZE_STL_XXXXXX очень удобно использовать, если в качестве контейнеров используется stl::vector<>, stl::list<> или контейнеры, обладающие соответствующими функциями вставки/удаления элементов и итераторами. Даже при использовании своего контейнера с оригинальным интерфейсом, без труда можно написать специальный макрос, отражающий особенности работы с этим контейнером. Сделать это будет проще, если прочитать следующую часть статьи о внутреннем устройстве карт сериализации.

Открывая карту сериализации этими макросами BEGIN_NAMED_SERIALIZE_MAP_OVERRIDE_XXXX, вы получаете возможность «перехватить» соответствующие функции: BEGIN_NAMED_SERIALIZE_MAP_OVERRIDE_STORE() – если хотите написать свою функцию Store(), BEGIN_NAMED_SERIALIZE_MAP_OVERRIDE_LOAD() – если нужна своя функция Load(), и BEGIN_NAMED_SERIALIZE_MAP_OVERRIDE() – если хотите сами определить и ту, и другую. Выглядеть это должно будет примерно так:

      template<class StorageType> 
  bool Store(
    StorageType* pStorage, 
    typename StorageType::HSSECTION hParentSection = NULL)
  {
    // Делаем что-то здесьbool res =  this->Store_(pStorage, hParentSection);
    // или здесьreturn res;
  }

  template<class StorageType> 
  bool Load (
    StorageType* pStorage, 
    typename StorageType::HSSECTION hParentSection = NULL)
  {
    // Делаем что-то здесьbool res = this->Load_(pStorage, hParentSection = NULL);
    // или здесьreturn res;
  }

Как это устроено

Собственно, ничего нового с картами я, конечно, не придумывал. Совершенно аналогичным образом устроены, например, карты обработки сообщений в WTL.

При включении карты сериализации в описание структуры/класса в него добавляются три шаблонные функции: Load(*), Store(*) и SerializeMap(*). Вот что получается из объявления класса B после того, как препроцессор развернёт определения макросов:

      struct B {
  CStringW m_strw_in_b;
  A m_a;
  std::list<A> m_listof_a;

  //==========================================================================
//BEGIN_NAMED_SERIALIZE_MAP template<class StorageType> 
  bool Store(StorageType* pStorage, 
    typename StorageType::HSSECTION hParentSection = NULL)
  {
    return SerializeMap(pStorage, hParentSection, true);
  }
  
  template<class StorageType> 
  bool Load (StorageType* pStorage, 
    typename StorageType::HSSECTION hParentSection = NULL)
  {
    return SerializeMap(pStorage, hParentSection, false);
  }
  
  template<class StorageType> 
  bool SerializeMap(StorageType* pStorage, 
    typename StorageType::HSSECTION hParentSection, bool bSerialze)
  {
    int count = 0;
    bool res = 0;
  //N_SERIALIZE_UNICODE_STRING(m_strw_in_b, "m_strw_in_b")if(bSerialze) 
      res |= CStorageSerialize::SerializeStringUnicode(
        m_strw_in_b, pStorage, hParentSection, "m_strw_in_b");
    else 
      res |= CStorageSerialize::UnSerializeStringUnicode(
        m_strw_in_b, pStorage, hParentSection, "m_strw_in_b");
    
  //N_SERIALIZE_T(m_a, "m_a")if(bSerialze)
      res |= CStorageSerialize::SerializeT(
        m_a, pStorage, hParentSection, "m_a");
    else
      res |= CStorageSerialize::UnSerializeT(
        m_a, pStorage, hParentSection, "m_a");
    
  //N_SERIALIZE_STL_CONTAINER_T (m_listof_a, "m_listof_a")if(bSerialze) 
      res |= CStorageSerialize::SerializeSTL_T(
        m_listof_a, pStorage, hParentSection, "m_listof_a");
    else 
      res |= CStorageSerialize::UnSerializeSTL_T(
        m_listof_a, pStorage, hParentSection, "m_listof_a");
  
  //END_NAMED_SERIALIZE_MAP()return res;
  }
//==========================================================================

};

Как видно, функции Load(*) и Store(*) вызывают SerializeMap(*) с флаговой переменной для того, чтобы указать, происходит загрузка или сохранение. В функции SerializeMap(*) непосредственно формируется карта сериализации с помощью последовательного включения блоков для каждой переменной. Каждый блок содержит пару вызовов соответствующих функций из CStorageSerialize. В конечном счете, макросы разворачиваются в вызовы функций из пространства имён CStorageSerialize, в котором определены шаблоны функций для различных ожидаемых типов объектов. Эти функции в свою очередь работают с неким абстрактным объектом, тип которого они, конечно, тоже точно не знают до компиляции, но хотят, чтобы он реализовывал следующие функции:

      class CInSomeStorage
{
public:
  // handle-like aliasestypedef xxx      HSSECTION;  
  typedef xxx      HSVALARRAY;
  typedef xxx      HSSECARRAY;

  HSSECTION  OpenSection(
    constchar* pSectionName, HSSECTION hParentSection = NULL, 
    bool createIfNotExist = false);
  bool GetValue(
    constchar* pValueName, CMemoryObject* pTargetObj, 
    HSSECTION hParentSection = NULL, unsignedchar* pTipFlags = NULL);
  bool SetValue(
    constchar* pValueName, const CMemoryObject* pTargetObj, 
    HSSECTION hParentSection = NULL, unsignedchar  tipFlags = NULL);

  // serial access for arrays of values and sections--------------------------//values
  HSVALARRAY GetFirstValue(
    constchar* pValueName, CMemoryObject* pTargetObj, 
    HSSECTION hParentSection = NULL, unsignedchar* pTipFlags = NULL);
  bool GetNextValue(HSVALARRAY hValArray, CMemoryObject* pTargetObj);
  HSVALARRAY InsertFirstValue(
    constchar* pValueName, const CMemoryObject* pTargetObj, 
    HSSECTION hParentSection = NULL, unsignedchar  tipFlags = NULL);
  bool InsertNextValue(HSVALARRAY hValArray, const CMemoryObject* pTargetObj);
  //sections
  HSSECARRAY GetFirstSection(
    constchar* pSectionName, HSSECTION* phChildSection, 
    HSSECTION hParentSection = NULL);
  bool GetNextSection(HSSECARRAY hSecArray, HSSECTION* phChildSection);
  HSSECARRAY InsertFirstSection(
    constchar* pSectionName, HSSECTION* phInsertedChildSection, 
    HSSECTION hParentSection = NULL);
  bool InsertNextSection(
    HSSECARRAY hSecArray, HSSECTION* phInsertedChildSection);

  template<class THandle>
    bool CloseStorageHandle(THandle  storageHandle);
  //-----------------------------------------------------------------------
};

Это именно тот “интерфейс”, который придется реализовать для сериализации в xml (слово «интерфейс» взято в кавычки, потому что речь не идёт о традиционных интерфейсах С++, чисто виртуальных классах).


Рисунок 1.

Смысл заключается примерно в том, что некое произвольное “хранилище” должно уметь сохранять данные в иерархичной структуре – секции, у которых могут быть подсекции и значения, доступ к значениям и открытие секций осуществляется по именам, аналогично системному реестру windows. Кроме того, должна поддерживаться возможность хранения массивов значений и массивов секций – это может показаться странным на первый взгляд, но, тем не менее, это крайне необходимая и очень удобная вещь. Она позволяет без труда сериализовывать контейнеры, хранящие совершенно разные объекты, опять же не заботясь об организации того или иного метода хранения.

Два примера таких реализаций приведены в файлах InMemStorage.h и InRegStorrage.h.

ПРИМЕЧАНИЕ

Поставляемая реализация CInMemStorage использует boost::shared_ptr, поэтому если вам непременно захочется её использовать, придётся позаботиться о подключении библиотеки boost или о написании собственного shared_ptr.

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

В тестовом проекте вы найдёте работающий пример, который иллюстрирует основные аспекты работы.


Эта статья опубликована в журнале RSDN Magazine #1-2006. Информацию о журнале можно найти здесь