TN002:持久性对象数据格式
本说明介绍了支持持久 C++ 对象的 MFC 例程以及对象数据存储在文件中时对象数据的格式。 这仅适用于具有 DECLARE_SERIAL 和 IMPLEMENT_SERIAL 宏的类。
问题
持久性数据的 MFC 实现将许多对象的数据存储在文件的单个连续部分中。 对象的 Serialize
方法将对象的数据转换为压缩的二进制格式。
该实现保证使用 CArchive 类以相同的格式保存所有数据。 它使用 CArchive
对象作为转换器。 此对象从创建之时起一直保留,直到调用 CArchive::Close。 此方法可以由程序员显式调用,也可以在程序退出包含 CArchive
的范围时由析构函数隐式调用。
本说明介绍了 CArchive
成员 CArchive::ReadObject 和 CArchive::WriteObject 的实现。 可以在 Arcobj.cpp 中找到这些函数的代码,在 Arccore.cpp 中找到 CArchive
的主要实现。 用户代码不直接调用 ReadObject
和 WriteObject
。 相反,这些对象由特定于类的类型安全插入和提取运算符使用,这些运算符由 DECLARE_SERIAL 和 IMPLEMENT_SERIAL 宏自动生成。 以下代码演示如何隐式调用 WriteObject
和 ReadObject
:
class CMyObject : public CObject
{
DECLARE_SERIAL(CMyObject)
};
IMPLEMENT_SERIAL(CMyObj, CObject, 1)
// example usage (ar is a CArchive&)
CMyObject* pObj;
CArchive& ar;
ar <<pObj; // calls ar.WriteObject(pObj)
ar>> pObj; // calls ar.ReadObject(RUNTIME_CLASS(CObj))
将对象保存到存储 (CArchive::WriteObject)
CArchive::WriteObject
方法写入用于重新构造对象的标头数据。 此数据由两部分组成:对象的类型和对象的状态。 此方法还负责维护要写出的对象的标识,以便只保存一个副本,而不受指向该对象的指针数(包括循环指针)影响。
保存(插入)和还原(提取)对象依赖于几个“清单常量”。这些值存储在二进制文件中,并为存档提供重要信息(请注意,“w”前缀表示 16 位数量):
标记 | 说明 |
---|---|
wNullTag | 用于 NULL 对象指针 (0)。 |
wNewClassTag | 指示下面的类描述是此存档上下文中的新类 (-1)。 |
wOldClassTag | 指示已在此上下文中看到正在读取的对象的类 (0x8000)。 |
存储对象时,存档维护 (m_pStoreMap),这是从存储对象到 32 位永久标识符 (PID) 的映射。 PID 分配给保存在存档上下文中的每个唯一对象和每个唯一类名。 这些 PID 从 1 开始按顺序分发。 这些 PID 在档案范围之外没有任何意义,特别是不会与记录号码或其他标识项混淆。
在 CArchive
类中,PID 是 32 位的,但除非它们大于 0x7FFE,否则它们将写为 16 位。 大型 PID 写为 0x7FFF 后跟 32 位 PID。 这将保持与在早期版本中创建的项目之间的兼容性。
当请求将对象保存到存档时(通常使用全局插入运算符),将检查 NULL CObject 指针。 如果指针为 NULL,则 wNullTag 将插入到存档流中。
如果指针不是 NULL 并且可以序列化(该类是 DECLARE_SERIAL
类),则代码将检查 m_pStoreMap 以查看是否已保存该对象。 如果已保存,代码将与该对象关联的 32 位 PID 插入到存档流中。
如果以前未保存过该对象,则需要考虑两种可能性:对象和对象的确切类型(即类)都是此存档上下文中的新对象,或者该对象是已见过的精确类型。 若要确定是否已看到该类型,代码将在 m_pStoreMap 中查询与正在保存的对象关联的 CRuntimeClass
对象匹配的 CRuntimeClass 对象。 如果存在匹配项,WriteObject
将插入一个标记,该标记是 wOldClassTag 和此索引的按位 OR
。 如果 CRuntimeClass
是此存档上下文的新内容,WriteObject
会为该类分配一个新的 PID 并将其插入到存档中,前面是 wNewClassTag 值。
然后使用 CRuntimeClass::Store
方法将此类的描述符插入到存档中。 CRuntimeClass::Store
插入类的架构编号(见下文)和类的 ASCII 文本名称。 请注意,使用 ASCII 文本名称并不能保证存档在应用程序之间的唯一性。 因此,应标记数据文件以防止损坏。 插入类信息后,存档将对象放入 m_pStoreMap,然后调用 Serialize
方法插入特定于类的数据。 在调用 Serialize
之前将对象放入 m_pStoreMap 可防止将对象的多个副本保存到存储中。
返回到初始调用方(通常是对象网络的根目录)时,必须调用 CArchive::Close。 如果计划执行其他 CFile 操作,则必须调用 CArchive
方法 Flush 以防止存档损坏。
注意
此实现对每个存档上下文 0x3FFFFFFE 索引施加了硬性限制。 此数字表示可以保存在单个存档中的唯一对象和类的最大数量,但单个磁盘文件可以具有无限数量的存档上下文。
从存储 (CArchive::ReadObject) 加载对象
加载(提取)对象使用 CArchive::ReadObject
方法,与 WriteObject
相反。 与 WriteObject
一样,ReadObject
不是由用户代码直接调用的;用户代码应调用类型安全提取运算符,该运算符使用预期的 CRuntimeClass
调用 ReadObject
。 这可确保提取操作的类型完整性。
由于 WriteObject
实现分配了递增的 PID,从 1 开始(0 预定义为 NULL 对象),因此 ReadObject
实现可以使用数组来维护存档上下文的状态。 从存储区读取 PID 时,如果 PID 大于 m_pLoadArray 的当前上限,ReadObject
知道接下来是新对象(或类描述)。
架构编号
当遇到类的 IMPLEMENT_SERIAL
方法时分配给类的架构编号是类实现的“版本”。 架构是指类的实现,而不是给定对象成为持久性(通常称为对象版本)的次数。
如果打算随着时间的推移维护同一类的多个不同实现,则在修改对象的 Serialize
方法实现时递增架构将使你能够编写可以使用旧版本的实现加载存储的对象的代码。
CArchive::ReadObject
方法在持久性存储中遇到与内存中类描述的架构编号不同的架构编号时,将引发 CArchiveException。 从此异常中恢复并不容易。
可以将 VERSIONABLE_SCHEMA
与架构版本(按位 OR)结合使用,以防止引发此异常。 通过使用 VERSIONABLE_SCHEMA
,代码可以通过检查 CArchive::GetObjectSchema 的返回值,在其 Serialize
函数中执行适当的操作。
直接调用序列化
在许多情况下,不需要 WriteObject
和 ReadObject
的常规对象存档方案的开销。 这是将数据序列化为 CDocument 的常见情况。 在这种情况下,直接调用 CDocument
的 Serialize
方法,而不是使用提取或插入运算符。 文档的内容可以反过来使用更通用的对象存档方案。
直接调用 Serialize
具有以下优点和缺点:
在序列化对象之前或之后,不会向存档中添加额外的字节。 这不仅使保存的数据更小,而且让你能实现可以处理任何文件格式的
Serialize
例程。MFC 已优化,因此
WriteObject
和ReadObject
实现以及相关集合将不会链接到应用程序中,除非出于其他目的需要更通用的对象存档方案。代码不必从旧的架构编号中恢复。 这使得文档序列化代码负责对架构编号、文件格式版本号或在数据文件开头使用的任何标识号进行编码。
通过直接调用
Serialize
序列化的任何对象都不能使用CArchive::GetObjectSchema
,或者必须处理指示版本未知的返回值 (UINT)-1。
由于 Serialize
是直接在文档上调用的,因此文档的子对象通常无法存档对其父文档的引用。 必须为这些对象显式提供指向其容器文档的指针,或者必须使用 CArchive::MapObject 函数将这些 CDocument
指针映射到 PID,然后再存档这些返回指针。
如前文所述,在直接调用 Serialize
时,应自行对版本和类信息进行编码,以便以后更改格式,同时仍保持与旧文件之间的后向兼容性。 CArchive::SerializeClass
函数可以在直接序列化对象之前或调用基类之前显式调用。