Сообщений 2    Оценка 0 [+1/-0]         Оценить  
Система Orphus

Сериализация в MFC

Скорость, гибкость, типонезависимость

Автор: Джим Биверидж
Перевод: Олег Быков
Источник: www.ddj.com
Опубликовано: 17.04.2001
Исправлено: 13.03.2005
Версия текста: 1.0

Я следил за разработкой многих коммерческих программных продуктов от начального проектирования до выпуска рабочей версии, поэтому я скептически отношусь к концепции "компонент - черный ящик", так как следовать ей все труднее и труднее по мере развития и усложнения проекта. Когда я впервые познакомился с механизмом сериализации в библиотеке MFC [адекватного перевода английского слова "serialization" нет, поэтому здесь я использовал транслитерацию. В статье этот термин применяется для обозначения процесса сохранения/восстановления данных - прим.пер.], мне стало интересно, насколько этот механизм гибок и производителен для коммерческого применения. В процессе исследования я обнаружил, что, несмотря на некоторые ограничения, механизм сериализации в MFC основан на современной теории объектно-ориентированного проектирования и более того, этот механизм не привязан к какому-то определенному типу и допускает свое дальнейшее развитие.

Использовать MFC-сериализации несложно. Любой класс, производный от CObject, может переопределить функцию Serialize(), принимающую в качестве параметра объект класса CArchive. В этой функции Вы можете добавить свой код для сохранения и восстановления любых данных Вашего класса.

Сериализация данных производится с помощью операторов operator<< и operator>>, совсем как в случае с классом iostream. Разница в том, что CArchive подразумевает только двоичный формат данных. Подобно iostream, в CArchive реализованы операторы для чтения и записи фундаментальных типов данных, таких как long и char. Отсутствие типа данных int упрощает переносимость между 16- и 32-битными платформами. Встроенные операторы также реализуют перестановку байтов для типов, которые это поддерживают. (За дополнительной информацией о совместимости между платформами с прямой и обратной записью байтов [Little-Endian и Big-Endian] обратитесь к книге "Endian-Neutral Software," by James R. Gillig, DDJ, October/November 1994).

Реализация сериализации в MFC выглядела очевидной и неинтересной до того момента, когда мне понадобилось создать несколько типов документов в одном приложении. Я заметил, что когда я открывал в программе файл, MFC корректно создавала объект документа нужного типа и вызывала соответствующую функцию Serialize(). Это происходило, несмотря на то, что я не написал ни строчки кода, чтобы помочь MFC в создании документов. По крайней мере, я так считал…

Проблемы, всюду проблемы

Чтобы создавать документ или любой другой вид объектов "на лету", MFC должна решить три проблемы:

Проблема 1. По мере необходимости должны создаваться объекты произвольных типов, но оператор new может работать только с явно указанным типом, поэтому для CObject нужно реализовать некое подобие "виртуальных конструкторов".

Проблема 2. Разработчики должны иметь возможность легко "обучать" CObject создавать новые типы классов. В идеале, это должно делаться в определении и/или реализации класса.

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

Как будет продемонстрировано далее, MFC элегантно решает эти проблемы, используя реестр с автоматической регистрацией типов [здесь и далее под реестром понимается программная структура, а не реестр WIndows - прим.пер.] и реализуя виртуальные конструкторы на основе зарегистрированных типов. Идентификация типов во время исполнения (RTTI) библиотеки MFC - вот краеугольный камень этой архитектуры.

Реестр типов

Чтобы осуществить идентификацию объектов во время выполнения, MFC создает в приложении реестр классов, унаследованных от CObject. Этот реестр никак не связан с OLE-реестром, но их концепции схожи. Реестр типов представляет собой связанный список структур CRuntimeClass, в котором каждая структура описывает один класс-наследник CObject. На листинге 1 показано внутреннее устройство структуры CRuntimeClass.

Листинг 1

struct CRuntimeClass
{
// Attributes
    LPCSTR m_lpszClassName;
    int m_nObjectSize;
    UINT m_wSchema;         // номер схемы загруженного класса
    void (PASCAL* m_pfnConstruct)(void* p); // NULL => abstract class
    CRuntimeClass* m_pBaseClass;
// Operations
    CObject* CreateObject();
// Implementation
    BOOL ConstructObject(void* pThis);
    void Store(CArchive& ar);
    static CRuntimeClass* PASCAL Load(
                CArchive& ar, UINT* pwSchemaNum);
    // объекты CRuntimeClass, связанные в простой список
    CRuntimeClass* m_pNextClass;// список зарегистрированных классов
};

Весь фокус в том, что типы из этого реестра не прописаны ни в одной таблице. Первый ключ к разгадке этого феномена находится в начале файла SCRIBDOC.H из MFC-примера "Scribble". Начало объявления класса выглядит так, как показано в примере 1(a).

Пример 1: (a) Начало объявления класса; (b) после обработки препроцессором макрос DECLARE_DYNCREATE разворачивается в несколько новых членов класса.

(a)
class CScribDoc : public CDocument
{
protected: // создавать только при сериализации
    CScribDoc();
    DECLARE_DYNCREATE(CScribDoc)
 ...
};

(b)
protected:
    static CRuntimeClass* __stdcall _GetBaseClass();
public:
    static  CRuntimeClass classCScribDoc;

   virtual CRuntimeClass* GetRuntimeClass() const;
    static void __stdcall Construct(void* p);

В документации сказано, что макрос DECLARE_DYNCREATE позволяет классам-наследникам CObject создаваться динамически во время выполнения. Хотя это определение абсолютно верно, то, что происходит внутри этого макроса, гораздо интереснее. После обработки препроцессором макрос DECLARE_DYNCREATE разворачивается в несколько новых членов класса, как показано в примере 1(b) (Все примеры взяты из MFC 3.1 и Visual C++ 2.1. Я выровнял код, сгенерированный препроцессором, для повышения читабельности).

Идентификация типов времени выполнения в MFC базируется на виртуальной функции GetRuntimeClass(). Информация о типе доступна для любого объекта - потомка CObject, который включает в себя макросы DECLARE_DYNAMIC, DECLARE_DYNCREATE, или DECLARE_SERIAL. Эта информация позволяет Вам определить, может ли объект быть приведен к типу унаследованного класса или принадлежат ли два объекта одному и тому же классу. Хотя Visual C++ и не поддерживает новый C++-оператор dynamic_cast [помним, что речь идет о Visual C++ 2.1 - прим.пер.], использование вышеописанной информации о типе времени выполнения даст тот же эффект.

Информация о типе времени выполнения объявляется при помощи статической переменной класса, в данном случае classCScribDoc. Это имя (без пробела внутри) создается в макросах DECLARE_xxx через оператор макро-конкатенации. Для доступа к этой переменной используются функции класса _GetBaseClass() и GetRuntimeClass(). GetRuntimeClass() является виртуальной, поэтому тип объекта может быть определен даже через указатель на CObject.

И наконец, статическая функция класса Construct() образует базис для использования реестра классов MFC как фабрики классов, которая может, когда требуется, создавать объекты произвольных типов. Для понимания работы Construct() необходимы дополнительные разьяснения.

Создание объекта

В книге Advanced C++: Programming Styles and Idioms (Addison-Wesley, 1992), Джеймс O. Коплин так описывает концепцию виртуального конструктора:

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

В MFC контекстом является информация, прочитанная из упорядоченного (serialized) архива [под архивом в этой статье понимается хранилище данных - прим.пер.]. Однако, виртуальный конструктор - это лишь концепция; никакая конструкция языка не реализует ее напрямую. Оператор new требует явного указания типа. Виртуальные конструкторы могут быть реализованы, если ввести в каждый класс статическую функцию, которая будет вызывать new для этого класса. Эта статическая функция-член будет вызываться при создании объекта определенного типа.

В MFC эта функция-член называется Construct(). Она создается макросами IMPLEMENT_DYNCREATE или IMPLEMENT_SERIAL. Один из этих макросов обязательно должен появиться в .cpp-модуле ровно один раз для каждого класса с поддержкой динамического создания. В случае со Scribble, выражение IMPLEMENT_DYNCREATE (CScribDoc, CDocument) появляется почти в самом начале файла SCRIBDOC.CPP. Первым аргументом идет класс, а вторым - его класс-родитель. Листинг 2 показывает код, сгенерированный препроцессором.

Когда MFC нужен документ или любой другой объект класса, унаследованного от CObject, она вызывает функцию CreateObject() класса CRuntimeClass. CreateObject() выделяет память, используя размер, указанный в структуре CRuntimeClass, и после этого вызывает ConstructObject(). ConstructObject() проверяет, поддерживает ли класс динамическое конструирование и вызывает функцию Construct() создаваемого класса.

Хотя в исходных текстах не дается пояснений, ясно, что такая схема четко разделяет конструирование объекта и выделение памяти. Все это кажется лишним, но в определенных ситуациях без такой организации не обойтись. Например, чтобы при создании массива его элементы располагались в одном блоке памяти, эту память нужно выделять одним вызовом функции malloc(). Используя ConstructObject(), Вы можете вручную инициализировать каждый элемент. Такой механизм позволяет принимать на этапе выполнения решения, которые в C++ обычно принимаются на этапе компиляции.

В примере 2 показана функция Construct(). Синтаксис вызова new немного необычен. На самом деле вызывается функция CObject::operator new(size_t, void*). Помните, что размер структуры - это подразумеваемый аргумент при вызове new, однако его следует явно описать в определении оператора. Эта версия new в CObject ничего не делает, но вызов new дает побочным эффектом вызов конструктора для этого объекта. Память уже была выделена вызовом CreateObject с использованием информации о размере из CRuntimeClass.

Пример 2: Функция Construct().

void __stdcall CScribDoc::Construct
(void* p)
{
    new(p) CScribDoc;
}

Используя реестр классов CRuntimeClass и функцию-член Construct(), MFC удается находить и создавать объекты новых типов на лету, что решает Проблему 1. Потенциально серьезные последствия данной техники в том, что при этом не поддерживаются множественное наследование и виртуальные базовые классы (см. MFC Technical Note #16).

Регистрация типов

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

Хоть это и неочевидно, именно макрос IMPLEMENT_DYNCREATE позволяет пользователям без проблем добавлять новые классы в реестр. После развертывания IMPLEMENT_DYNCREATE, как показано на листинге 2, статическая структура CRuntimeClass в CScribDoc инициализируется так, как показано в примере 3.

Листинг 2

void __stdcall CScribDoc::Construct(void* p)
{
    new(p) CScribDoc;
}
CRuntimeClass* __stdcall CScribDoc::_GetBaseClass()
{
    return (&CDocument::classCDocument);
}
CRuntimeClass CScribDoc::classCScribDoc = {
    "CScribDoc",
     sizeof(CScribDoc),
    0xFFFF,
    CScribDoc::Construct,
    &CScribDoc::_GetBaseClass, 0 };
static const AFX_CLASSINIT _init_CScribDoc(&CScribDoc::classCScribDoc);
CRuntimeClass* CScribDoc::GetRuntimeClass() const
{
    return &CScribDoc::classCScribDoc;
}

Пример 3: Инициализация статической структуры CRuntimeClass в CScribDoc.

CRuntimeClass CScribDoc::
classCScribDoc = {
    "CScribDoc",
     sizeof(CScribDoc),
    0xFFFF,
    CScribDoc::Construct,
    &CScribDoc::_GetBaseClass,
    0 };

Некоторые из элементов этой структуры мы уже рассматривали. В частности, выражение sizeof(CScribDoc) используется CreateObject для выделения нужного объема памяти; затем эта память инициализируется функцией, на которую указывает CScribDoc::Construct.

Следующая строка помещает эту информацию в реестр типов MFC: static const AFX_CLASSINIT _init_CScribDoc(&CScribDoc::classCScribDoc);. Это объявление конструирует объект типа AFX_CLASSINIT, используя конструктор, который принимает как аргумент CRuntimeClass. Из-за того, что этот объект находится в области видимости файла, он будет сконструирован до вызова функции main(). Конструктор AFX_CLASSINIT присоединяет переданную ему структуру типа CRuntimeClass к реестру типов MFC. У AFX_CLASSINIT нет своих данных, поэтому он не требует дополнительной памяти.

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

Часто разработчики задаются вопросом - в чем разница между различными макросами DECLARE и IMPLEMENT? Все макросы DECLARE_DYNAMIC и IMPLEMENT_DYNAMIC определяют статическую структуру CRuntimeClass, подобно DYNCREATE, описанному ранее, за одним исключением - поле Construct в этой структуре установлено в NULL. DECLARE_DYNCREATE и IMPLEMENT_DYNCREATE передают в структуру адрес функции Construct() для динамического создания типа. DECLARE_SERIAL и IMPLEMENT_SERIAL основываются на макросах DYNCREATE, но заменяют поле со значением 0xFFFF на номер схемы этой структуры.

Макросы SERIAL также определяют для класса operator>>. Этот оператор требует особого подхода, так как ему передается указатель на класс, но ни один из экземпляров этого класса не будет существовать, пока экземпляр не будет загружен из файла. Без экземпляра класса, MFC не может получить доступ к информации о классе времени выполнения для проверки на то, что загружаемый объект является объектом того же класса (или класса-наследника), что и переданный указатель. Перегружая operator>>, MFC получает возможность передавать указатель на информацию о типе времени выпонения, чтобы механизм сериализации не зависел от типа (typesafe serialization).

Создание типов из файла

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

Итак, при сохранении объекта в архиве можно записать туда имя класса и его данные. MFC так и делает, плюс проводит дополнительную работу для каждого сериализуемого класса. Имя класса берется из структуры CRuntimeClass, которая возвращается виртуальной функцией объекта. Определение типа производится динамически во время выполнения, поэтому структура типа Tiger будет корректно записана даже в случае, если MFC передается указатель на ее базовый класс типа Animal. Эта типонезависимость очень важна. Любая функция может без опасений сохранить объект в архиве, даже если точный тип объекта неизвестен.

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

Пример 4: Загрузка правильного типа документа из файла.

CDocument* pDoc;
CArchive& ar;
 ...
ar >> pDoc;

В реализации operator>> MFC загружает из файла имя класса, и ищет это имя в списке типов. Если этот тип присутствует в реестре и был описан либо как DECLARE_DYNCREATE, либо как DECLARE_SERIAL, MFC может сконструировать требуемый объект. Непосредственная загрузка данных этого объекта возлагается на сам объект вызовом его виртуальной функции Serialize(), что решает Проблему 3.

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

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

Оптимизация архивов

Когда я впервые просматривал этот код, мне виделись распухшие файлы с объектами и большие задержки при постоянном просмотре связанного списка. Но архивы MFC остаются маленькими, а скорость выполнения высокой благодаря хэш-идентификаторам.

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

Такое поведение также означает, что множественные ссылки на один и тот же объект обрабатываются корректно. Если объекты A и B при создании архива содержат указатели на один объект C, они оба будут указывать на один объект C после восстановления их из архива. MFC также корректно восстановит циклические меж-объектные ссылки.

В результате все работает гораздо быстрее, чем я ожидал. На 486/66, MFC смогла сохранить и восстановить архив размером более мегабайта с 10000 экземплярами CArray<DWORD,DWORD> менее, чем за 2 секунды.

Есть одно важное ограничение - хэш-таблица не может содержать больше 32766 классов и объектов в контексте одного архива. Это число включает в себя только классы, унаследованные от CObject и сериализуемые оператором operator<<, и не включает фундаментальные типы, например, short и long, CString и CPoint. (за дополнительной информацией о конструировании архивов обратитесь к MFC Technical Note 2: Persistent Object Data Format).

Версии схем сериализации

Одной из самых слабодокументированных особенностей 32-битной MFC 3.2 является поддержка версий схем сериализации (versionable schemas), когда MFC позволяет функции Serialize() обрабатывать разные версии одного класса вместо того, чтобы взводить исключение. Эта особенность очень важна для эволюционирующего проекта. И хотя я опишу, как реализовать поддержку версий, в Visual C++ 2.x этот механизм содержит ошибку и рушит программу при выполнении. Рекомендую написать в Microsoft, как сильно Вы ждете исправления этой ошибки [можно еще воспользоваться компилятором поновее - прим.пер.].

В MFC с каждой структурой, использующей DECLARE_SERIAL и IMPLEMENT_SERIAL, ассоциирован номер версии. Обычно этот номер установлен в 1, как показано в большинстве MFC-примеров; например, так - IMPLEMENT_SERIAL(CStroke, CObject, 1).

У каждой структуры или класса есть свой номер версии, который может изменяться независимо от остальных. MFC автоматически записывает в архив номер версии после идентификатора класса. До версии 3.0 в MFC не было полной поддержки механизма версий сериализации, поэтому старые версии MFC взводили исключение, когда номер схемы объекта в файле не совпадал с текущим номером схемы. Это рушило поддержку множественных схем.

В MFC 3.0 и более поздних версиях, такое поведение сохранилось, но его можно изменить. Если объединить оператором OR третий параметр макроса IMPLEMENT_SERIAL с константой VERSIONABLE_SCHEMA, MFC позволит работать с версией схемы сериализации в Вашей функции Serialize(). Например, чтобы установить номер версии документа в 3, используйте выражение DECLARE_SERIAL(CScribDoc, CDocument, VERSIONABLE_SCHEMA|3).

Чтобы использовать эту возможность, при загрузке данных из архива класс должен вызвать функцию GetObjectSchema() в своей функции Serialize(), как показано на листинге 3.

Листинг 3

class CSmallObject : public CObject {
   DECLARE_SERIAL(CSmallObject);
   DWORD m_value;   // было unsigned short в версии 1
};
IMPLEMENT_SERIAL(CSmallObject, CObject, VERSIONABLE_SCHEMA| 2);
CSmallObject::Serialize(CArchive& ar)
{
   if (ar.IsStoring()) {
      ...
   }
   else {
      DWORD nVersion = ar.GetObjectSchema();
      switch (nVersion) {
         case -1:
            // -1 показывает, что структура была создана с DYNCREATE, 
            // а не с SERIAL. Появление этого значения говорит об ошибке.
            break;
         case 1:    // Эта версия использовала unsigned short
            unsigned short oldval;
            ar >> oldval;
            m_value = oldval;
            break;
         case 2;
            // Текущая версия использует DWORD
            ar >> m_value;
            break;
         default:
            // несуществующее значение - скорее всего, данные испорчены.
            break;
      }
   }
}

Заключение

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


Любой из материалов, опубликованных на этом сервере, не может быть воспроизведен в какой бы то ни было форме и какими бы то ни было средствами без письменного разрешения владельцев авторских прав.
    Сообщений 2    Оценка 0 [+1/-0]         Оценить