MFC 六大关键技术 ( 第四部分 ) ——永久保存(串行化)
先用一句话来说明永久保存的重要:弄懂它以后,你就越来越像个程序员了!
如果我们的程序不需要永久保存,那几乎可以肯定是一个小玩儿。那怕我们的记事本、画图等小程序,也需要保存才有真正的意义。
对于 MFC 的很多地方我不甚满意,总觉得它喜欢拿一组低能而神秘的宏来故弄玄虚,但对于它的连续存储( serialize )机制,却是我十分钟爱的地方。在此,可让大家感受到面向对象的幸福。
MFC 的连续存储( serialize )机制俗称串行化。“在你的程序中尽管有着各种各样的数据, serialize 机制会象流水一样按顺序存储到单一的文件中,而又能按顺序地取出,变成各种不同的对象数据。”不知我在说上面这一句话的时候,大家有什么反应,可能很多朋友直觉是一件很简单的事情,只是说了一个“爽”字就没有下文了。
要 实现象流水一样存储其实是一个很大的难题。试想,在我们的程序里有各式各样的对象数据。如画图程序中,里面设计了点类,矩形类,圆形类等等,它们的绘图方 式及对数据的处理各不相同,用它们实现了成百上千的对象之后,如何存储起来?不想由可,一想头都大了:我们要在程序中设计函数 store() ,在我们单击“文件 / 保存”时能把各对象往里存储。那么这个 store() 函数要神通广大,它能清楚地知道我们设计的是什么样的类,产生什么样的对象。大家可能并不觉得这是一件很困难的事情,程序有能力知道我们的类的样子,对象也不过是一块初始化了存储区域罢了。就把一大堆对象“转换”成磁盘文件就行了。
即使上面的存储能成立,但当我们单击“文件 / 打开”时,程序当然不能预测用户想打开哪个文件,并且当打开文件的时候,要根据你那一大堆垃圾数据 new 出数百个对象,还原为你原来存储时的样子,你又该怎么做呢?
试 想,要是我们有一个能容纳各种不同对象的容器,这样,用户用我们的应用程序打开一个磁盘文件时,就可以把文件的内容读进我们程序的容器中。把磁盘文件读进 内存,然后识别它“是什么对象”是一件很难的事情。首先,保存过程不像电影的胶片,把景物直接映射进去,然后,看一下胶片就知道那是什么内容。可能有朋友 说它象录像磁带,拿着录像带我们看不出里面变化的磁场信号,但经过录像机就能把它还原出来。
其实不是这样的,比如保存一个矩形,程序并不是把矩形本身按点阵存储到磁盘中,因为我们绘制矩形的整个过程只不过是调用一个 GDI 函数罢了。它保存只是坐标值、线宽和某些标记等。程序面对“ 00 FF ”这样的东西,当然不知道它是一个圆或是一个字符!
拿 刚才录像带的例子,我们之所以能最后放映出来,前提我们知道这对象是“录像带”,即确定了它是什么类对象。如果我们事先只知道它“里面保存有东西,但不知 道它是什么类型的东西”,这就导致我们无法把它读出来。拿录像带到录音机去放,对录音机来说,那完全是垃圾数据。即是说,要了解永久保存,要对动态创建有 深刻的认识。
现在大家可以知道困难的根源了吧。我们在写程序的时候,会不断创造新的类,构造新的对象。这些对象,当然是旧的类对象(如 MyDocument )从未见过的。那么,我们如何才能使文档对象可以保存自己新对象呢,又能动态创建自己新的类对象呢?
许多朋友在这个时候想起了 CObject 这个类,也想到了虚函数的概念。于是以为自己“大致了解”串行化的概念。他们设想:“我们设计的 MyClass (我们想用于串行化的对象)全部从 CObject 类派生, CObject 类对象当然是 MyDocument 能认识的。”这样就实现了一个目的:本来 MyDocument 不能识别我们创建的 MyClass 对象,但它能识别CObject 类对象。由于 MyClass 从 CObject 类派生,我产的新类对象“是一个 CObject ”,所以 MyDocument 能把我们的新对象当作 CObiect 对象读出。或者根据书本上所说的:打开或保存文件的时候, MyDocument 会调用 Serialize (), MyDocument 的 Serialize ()函会呼叫我们创建类的 Serialize 函数 [ 即是在MyDocument Serialize ()中调用:m_pObject -> Serialize() ,注意:在此m_pObject 是CObject 类指针,它可以指向我们设计的类对象] 。最终结果是 MyDocument 的读出和保存变成了我们创建的类对象的读出和保存,这种认识是不明朗的。
有意思还有,在网上我遇到几位自以为懂了 Serialize 的朋友,居然不约而同的犯了一个很低级得让人不可思议的错误。他们说: Serialize 太简单了! Serialize ()是一个虚函数,虚函数的作用就是“优先派生类的操作”。所以 MyDocument 不实现 Serialize ()函数,留给我们自己的 MyClass 对象去调用 Serialize ()……真是哭笑不得,我们创建的类 MyClass 并不是由 MyDocument 类派生, Serialize ()函数为虚在 MyDocument 和 MyClass 之间没有任何意义。 MyClass 产生的 MyObject 对象仅仅是 MyDocument 的一个成员变量罢了。
话说回来,由于 MyClass 从 CObject 派生,所以CObject 类型指针能指向 MyClass 对象,并且能够让 MyClass 对象执行某些函数(特指重载的 CObject 虚函数),但前提必须在 MyClass 对象实例化了,即在内存中占领了一块存储区域之后。不过,我们的问题恰恰就是在应用程序随便打开一个文件,面对的是它不认识的 MyClass 类,当然实例化不了对象。
幸好我们在上一节课中懂得了动态创建。即想要从CObject 派生的MyClass 成为可以动态创建的对象只要用到DECLARE_DYNAMIC/IMPLEMENT_DYNAMIC 宏就可以了(注意:最终可以Serialize 的对象仅仅用到了DECLARE_SERIAL/IMPLEMENT_SERIAL 宏,这是因为DECLARE_SERIAL/IMPLEMENT_SERIAL 包含了DECLARE_DYNAMIC/IMPLEMENT_DYNAMIC 宏)。
从解决上面的问题中,我们可以分步理解了:
1、 Serialize 的目的:让 MyDocument 对象在执行打开 / 保存操作时,能读出(构造)和保存它不认的 MyClass 类对象。
2、 MyDocument 对象在执行打开 / 保存操作时会调用它本身的 Serialize ()函数。但不要指望它会自动保存和读出我们的 MyClass 类对象。这个问题很容易解决,就直接在 MyDocument:: Serialize (){
// 在此函数调用MyClass 类的Serialize ()就行了!即
MyObject. Serialize ();
}
3、 我们希望 MyClass 对象为可以动态创建的对象,所以要求在MyClass 类中加上DECLARE_DYNAMIC/IMPLEMENT_DYNAMIC 宏。
但目前的Serialize 机制还很抽象。我们仅仅知道了表面上的东西,实际又是如何的呢?下面作一个简单深刻的详解。
先看一下我们文档类的Serialize ()
void CMyDoc::Serialize(CArchive& ar)
{
if (ar.IsStoring())
{
// TODO: add storing code here
}
else
{
// TODO: add loading code here
}
}
目前这个子数什么也没做(没有数据的读出和写入),CMyDoc 类正等待着我们去改写这个函数。现在假设CMyDoc 有一个MFC 可识别的成员变量m_MyVar, 那么函数就可改写成如下形式:
void CMyDoc::Serialize(CArchive& ar)
{
if (ar.IsStoring()) // 读写判断
{
ar<<m_MyVar; // 写
}
else
{
ar>>m_MyVar; // 读
}
}
许多网友问:自己写的类(即 MFC 未包含的类)为什么不行?我们在 CMyDoc 里包含自写类的头文件MyClass.h ,这样CMyDoc 就认识MyDoc 类对象了。这是一般常识性的错误,MyDoc 类认识MyClass 类对象与否并没有用,关键是CArchive 类,即对象ar 不认识MyClass (当然你梦想重写CArchive 类当别论)。“>> ”、“<< ”都是CArchive 重载的操作符。上面ar>>m_MyVar 说白即是在执行一个以ar 和m_MyVar 为参数的函数,类似于function(ar,m_MyVar) 罢了。我们当然不能传递一个它不认识的参数类型,也因此不会执行function(ar,m_MyObject) 了。
[ 注:这里我们可以用指针。让MyClass 从Cobject 派生,一切又起了质的变化,假设我们定义了:MyClass *pMyClass = new MyClass; 因为MyClass 从CObject 派生,根据虚函数原理,pMyClass 也是一个CObject* ,即pMyClass 指针是CArchive 类可认识的。所以执行上述function(ar, pMyClass) ,即ar << pMyClass 是没有太多的问题(在保证了MyClass 对象可以动态创建的前提下)。]
回过头来,如果想让 MyClass 类对象能 Serialize ,就得让MyClass 从CObject 派生,Serialize ()函数在CObject 里为虚,MyClass 从CObject 派生之后就可以根据自己的要求去改写它,象上面改写CMyDoc::Serialize ()方法一样。这样MyClass 就得到了属于MyClass 自己特有的Serialize ()函数。
现在,程序就可以这样写:
……
#include “MyClass.h”
……
void CMyDoc::Serialize(CArchive& ar)
{
// 在此调用 MyClass 重写过的 Serialize()
m_MyObject. Serialize(ar); // m_MyObject 为 MyClass 实例
}
至此,串行化工作就算完成了,一即简单直观:从 CObject 派生自己的类,重写 Serialize () 。在此过程中,我刻意安排:在没有用到 DECLARE_SERIAL/IMPLEMENT_SERIAL 宏,也没有用到CArray 等模板类的前提下就完成了串行化的工作。我看过某些书,总是一开始就讲DECLARE_SERIAL/IMPLEMENT_SERIAL 宏或马上用CArray 模板,让读者觉得串行化就是这两个东西,导致许多朋友因此找不着北。
大家看到了,没有DECLARE_SERIAL/IMPLEMENT_SERIAL 宏和CArray 等数据结构模板也依然可以完成串行化工作。
现在可以腾出时间讲一下大家觉得十分抽象的 CArchive 。我们先看以下程序(注:以下程序包含动态创建等,请包含DECLARE_SERIAL/IMPLEMENT_SERIAL 宏)
void MyClass ::Serialize(CArchive& ar)
{
if (ar.IsStoring()) // 读写判断
{
ar<< m_pMyVar; // 问题:ar 如何把m_pMyVar 所指的对象变量保存到磁盘?
}
else
{
pMyClass = new MyClass; // 准备存储空间
ar>> m_pMyVar;
}
}
要回答上面的问题,即“ ar<<XXX ”的问题。和 我们得看一下模拟 CArchive 的代码。
“ar<<XXX ”是执行CArchive 对运算符“<< ”的重载动作。ar 和XXX 都是该重载函数中的一参数而已。函数大致如下:
CArchive& operator<<( CArchive& ar, const CObject* pOb)
{
…………
// 以下为CRuntimeClass 链表中找到、识别pOb 资料。
CRuntimeClass* pClassRef = pOb->GetRuntimeClass();
// 保存pClassRef 即类信息(略)
((CObject*)pOb)->Serialize();// 保存MyClass 数据
…………
}
从上面可以看出,因为 Serialize() 为虚函数,即“ar<<XXX ”的结果是执行了XXX 所指向对象本身的Serialize() 。对于“ar>>XXX ”,虽然不是“ar<<XXX ”逆过程,大家可能根据动态创建和虚函数的原理料想到它。
至此,永久保存算是写完了。在此过程中,我一直努力用最少的代码,详尽的解释来说明问题。以前我为本课题写过一个版本,并在几个论坛上发表过,但不知怎么在网上遗失(可能被删除)。所以这篇文章是我重写的版本。记得第一个版本中,我是对DECLARE_SERIAL/IMPLEMENT_SERIAL 和可串行化的数组及链表对象说了许多。这个版本中我对DECLARE_SERIAL/IMPLEMENT_SERIAL 其中奥秘几乎一句不提,目的是让大家能找到中心,有更简洁的永久保存的概念,我觉得这种感觉很好!