天方夜谭VCL: 多态

 天方夜谭VCL: 多态

我们中国人崇拜龙,所谓“龙生九种,九种各别”。哪九种?《西游记》里西海龙王对孙悟空说:“第一个小黄龙,见居淮渎;第二个小骊龙,见住济渎;第三个青背龙,占了江渎;第四个赤髯龙,镇守河渎;第五个徒劳龙,与佛祖司钟;第六个稳兽龙,与神官镇脊;第七个敬仲龙,与玉帝守擎天华表;第八个蜃龙,在大家兄处砥据太岳。此乃第九个鼍龙,因年幼无甚执事,自旧年才着他居黑水河养性,待成名,别迁调用,谁知他不遵吾旨,冲撞大圣也。”(注:鼍龙是文雅的说法,民间叫法是猪婆龙,也就是扬子鳄。)如果您冲着这九位说一声“Let’s go”,那场面可壮观了,有天上飞的,有水里游的,也有地上爬的。同样是“go”,“go”的具体形式却各不相同,这正是多态“一个接口,多种实现”的典型例子。

多态的实现方法很多,其中C++直接支持的方式有:通过关键字virtual提供虚函数进行迟后联编,以及通过模板(template)实现静态多态性,它们都各有用武之地。我们比较熟悉的是虚函数,这是建构类层次的重要手段,我们也已经分析过虚函数的原理[1]。然而在有些情况下,虚函数的性能并不是最优,故VCL还提供了一种动态(dynamic)函数,用法和虚函数一模一样,只要把virtual换成DYNAMIC就可以了。VCL的帮助文件里说,动态函数跟虚拟函数相比,空间效率占优,时间效率不行,真的吗?其实现原理又是如何呢?我们又应该如何权衡这两者的使用呢?我们将从一个相当一般的角度来讨论这些问题。

虚函数的苦恼
如下类层次来自一个图形绘制程序的一部分。为了方便管理,界面与具体的图形设计分离。各种图形以动态连接库的方式提供,作为插件的形式。这样可以在不重新编译主程序的情况,增加或减少各种图形。


图1 Shape类层次

最初Shape的声明是

class Shape {
private:
 int x0, y0;
protected:
        Shape();
        virtual ~Shape();
public:
 int x() const;
 int y() const;
        virtual void draw(void *) = 0;
        virtual int move(int, int);
};

后来因为功能扩充,添加了两个虚函数。
class Shape {
private:
 int x0, y0;
protected:
        Shape();
        virtual ~Shape();
public:
 int x() const;
 int y() const;
 virtual int move(int, int);
        virtual void draw(void *) = 0;
        virtual void save(void *) const = 0;
        virtual void load(void *) = 0;
};

后来又作过一些修改,又添加了若干虚函数。问题就在于,虚函数一但增加,虚拟函数表VFT就会发生变化,这时候,主程序就必须重新编译。更糟糕的是,一旦版本升级,派生自不同版本Shape的图形绝对不可以混用[2]。所以我们可以看到硬盘里充斥着mfc20.dll、mfc40.dll、mfc42.dll……却一个也不能删除,这就是MFC升级所带来的DLL垃圾。怎么办?
初步解决
我在网上问过这样的问题,得到的答复主要有:

用COM;
预先多写一些无用的虚函数,留出扩充空间。
其实上面的方法都能很好地解决这个问题。但是推广看来,也有一定局限性。COM不适合解决类层次过深的情况,预留的空间则是不折不扣的“鸡肋”。

追根究底,这个局限性是因为父类和子类的虚拟函数表VFT之间过强的关联性:子类的VFT的前面一部分必须与父类相同。而当父类和子类不在同一个DLL或EXE中的时候,这个要求是很难满足的。父类一旦改变,子类如果不重新编译,就将导致错误。解决的方法,当然就是取消父类和子类VFT之间的关联性。我设计了一个很笨的解决办法,但可以取消这个关联性,使虚函数保证始终只有2个。

#define Dynamic // Dynamic什么都不是,只是好看一点

struct point
{
 int x, y;
};

class dispatch_error{};

class Shape {
private:
 int x0, y0;
protected:
        Shape();
        virtual ~Shape();
 virtual void dispatch(int id, void* in, void* out);
 // in和out是函数的输入输出参数,id是每个函数唯一的标记符号,即代号
 // 实际运用中,id不一定是整数,也可以是128位UUID,或者字符串等等
public:
 int x() const;
 int y() const;
 Dynamic int move(int dx, int dy)
 {
   int r;
   point p = {dx, dy};
   dispatch(-1, &p, &r);
   return r;
 }
        Dynamic void draw(void *hdc)    {dispatch(-2, hdc, 0);}
        Dynamic void save(void* o) const  {dispatch(-3, o, 0);}
        Dynamic void load(void* i)    {dispatch(-4, i, 0);}
};

void Shape::dispatch(int id, void* in, void* out)
{
        switch(id)
        {
                case -1:
                        ...
                case -2:
                        ...
   ...
                default:
                        throw(dispatch_error()); // 若函数不存在则抛出异常
        }
}

如果子类Triangle要改写Shape::draw,那么只需要
void Triangle::dispatch(int id, void* in, void* out)
{
        switch(id)
        {
                ...
                case -2:  // 改写Shape::draw
                        ...
   ...
                default:
                        Shape::dispatch(id, in, out); //函数不存在则向父类找
        }
}

这样的“Dynamic函数”就解决了前面的问题,只有析构和dispatch这两个虚函数。父类和子类的VFT之间没有关联性,可以自由改动而不会互相影响。
评头论足
我们来对这种解决方案作了评价:的确解决了虚函数的问题,但是也付出了不小的代价:时间效率和可读性,由此也决定了该方案的应用面不广,一般用于

虚函数很少或几乎不需改写的情况。这样有助于减少VFT的大小。至于运行速度则没有什么提高,毕竟VFT的访问速度是常数级[3];
父类需要经常更新而子类不方便同步更新,对效率要求又不高的情况。一般的应用程序都可以使用。
从模式(Patterns)的角度来看,这种方法是典型的职责链(Chain of Responsibility)模式[4]:调用请求从最低层子类开始一层层往上传递,直到被处理或者最后抛出异常。这种模式运用非常广泛,比如VCL消息映射[5]和COM中IDispatch接口[6],与上述解决方案的形式都非常相似。

这个解决方案还可以作进一步的完善,以更好地适用于单根结构的框架。比如单根结构的类库,如MFC和VCL,通过RTTI可以找到唯一的父类,那么可以分离数据(函数代号和指针)和代码(调配部分),以简化结构。解决的方法就是典型的表格驱动,有不少书[7,8]都用此来优化COM中IUnkown接口的QueryInterface。我们引入类DMT来储存函数的代号和指针。

#include
using namespace std;

class DMT {
        char* const ptr;
        const DMT* const parent;
public:
        DMT(const DMT* const, const int, ...);
        ~DMT() {delete []ptr;}
        short size() const  {return *(short*)ptr;}
        const void* find(int) const;
};


图2 类DMT图解

需要特别注意的是DMT::ptr所分配的空间。在32位系统上,对于n个“Dynamic函数”,需要sizeof(short)字节储存n(红色部分),sizeof(void*)*n字节储存函数代号(黄色部分),以及sizeof(void*)*n字节储存函数指针(蓝色部分),一共是sizeof(short) + 2*n*sizeof(void*)字节。子类和父类的DMT可以通过链表形式连接起来。下面我们看看DMT::find和DMT::DMT的实现。

const void* DMT::find(int i) const
{
 const int* begin = (int*)(ptr + sizeof(short)), *p;
 for(p = begin; p < begin + size(); ++p)
   if(*(int*)p == i)
     return *(void**)(p+ size());
     // 找到对应的函数代号后,向前跳DMT::size()则是相应的函数指针
 return (parent)? parent->find(i): 0;
}

DMT::DMT(const DMT* const p, const int n, ...)
 : parent(p), ptr(new char[sizeof(short)+2*n*sizeof(void*)])
         // ptr分配的空间大小如前所述
{
 int* i = (int*)(ptr + 2), c;
 *(short*)ptr = n;    // 往头sizeof(short)字节写入n(红色部分)

 va_list ap;
 va_start(ap, n);

 for(c = 0; c < n; ++c)    // 往黄色部分写入函数代号
   *(i++) = va_arg(ap, int);

 typedef void (DMT::*temp_type)();
 temp_type temp;

 for(c = 0; c < n; ++c)    // 往蓝色部分写入函数指针
 {
   temp = va_arg(ap, temp_type);
   *(i++) = *(int*)&temp;
 }

 va_end(ap);
}

下面我们在Shape类层次中应用DMT类。
class Shape {
private:
 int x0, y0;
 void int f_move(void* dx, void* dy) {...}
protected:
 static const DMT dmt_Shape;      // Shape类的DMT
 const DMT* const dmt;        // 指向该类DMT的指针
 Shape() : dmt(&dmt_Shape) {...}
        virtual ~Shape();
 void dispatch(int id, void* in, void* out)  // 这次不是虚函数!
 {
   void (A::*f)(void*, void*);
   *(const void**)&f = dmt->find(id);
   (this->*f)(in, out);
 }
public:
 int x() const;
 int y() const;
 Dynamic int move(int dx, int dy)
 {
   int r;
   point p = {dx, dy};
   dispatch(-1, &p, &r);
   return r;
 }
        Dynamic void draw(void *hdc)    {dispatch(-2, hdc, 0);}
        Dynamic void save(void* o) const  {dispatch(-3, o, 0);}
        Dynamic void load(void* i)    {dispatch(-4, i, 0);}
};

const DMT Shape::dmt_Shape =
        DMT(0, 4, -1, -2, -3, -4, &Shape::f_move, 0, 0, 0);

背景突出部分就是有改动的地方。如果子类Triangle要改写Shape::draw,那么只需要

class Triangle {
 private:
   void f_draw(void*);
   ...
 protected:
   static const DMT dmt_Triangle;
   ...
 public:
   Triangle()  {dmt = &dmt_Triangle; ...}
   ...
};

const DMT Triangle::dmt_Triangle =
 DMT(Shape::dmt_Shape, ..., -2, ..., &Triangle::f_draw...);

这就是对“Dynamic函数”的另一种实现,这样可以分离数据和代码。当然这个示例并不具备实际应用价值,在静态成员初始化、调用约定、可读性等诸多设计上都有不少问题,仅仅起演示作用。
动态(dynamic)函数
Object Pascal提供了两种函数实现多态:一种是我们熟悉的虚拟(virtual)函数,另外一种则是动态(dynamic)函数,其实就是对前面的“Dynamic函数”提供的语言级别的支持。

可能有些用C++ Builder的朋友说,C++ Builder里怎么看到啊?在C++ Builder里,标识动态函数的宏(macro)是DYNAMIC,也就是__declspec(dynamic),这是Borland对C++的扩充。像TControl::Click、TControl::MouseMove等等都是动态函数。DYNAMIC的用法和virtual基本一致,我所发现的不同仅仅是,当子类改写父类相应函数时,子类中virtual可以省略,而DYNAMIC则不行。

那么,每个类的动态函数的入口在哪里呢?上次,我们已经挖出了VMT的分布图,里面就有vmtDynamicTable = 0xffffffd0这么一句,字面就告诉我们,这是动态方法表DMT(Dynamic Method Table)的入口。不妨检验一下。

#include
#include
struct A: private TObject
{
        DYNAMIC void f1() = 0;
        void f3() {}
        virtual void f4() {}
        DYNAMIC void f2() = 0;
};
struct B: A
{
        DYNAMIC void f1() {}
        DYNAMIC void f2() {}
};
void main()
{
        A* p = new B;
   std::cout<<(void*)p<f1();
        p->f2();
        delete p;
}

这个程序会输出“0118095C”。当然不同的机器上这个数值可能有所不同,总之先记下了。
其中p->f1();的汇编代码是

push dword ptr [ebp-0x30]
or edx,-0x01          ;这句其实就相当于mov edx,0xffffffff
mov eax,[ebp-0x30]
call System::FindDynaInst(void *, int)
call eax
pop ecx

p->f2();的汇编代码是
push dword ptr [ebp-0x30]
mov edx,0xfffffffe
mov eax,[ebp-0x30]
call System::FindDynaInst(void *, int)
call eax
pop ecx

程序很简单,我们说明一下。
or edx,-0x01跟mov edx,0xffffffff的效果是完全一样的,任何数和0xffffffff进行“或”运算的结果当然都是0xffffffff;
这两段唯一的不同就是mov edx,0xffffffff(也就是or edx,-0x01)和mov edx,0xfffffffe,我们上次已经说过了补码表示法,这里其实就是分别传递的A::f1和A::f2的函数代号–1和–2;
执行过mov eax,[ebp-0x30]这一句后,我们可以发现eax的值就是刚才我们记下的数(0118095C),这里包含了指向VMT入口的指针;
向System::FindDynaInst传入的两个参数就分别是包含指向VMT入口的指针,以及相应函数的代号,分别在eax和edx里;
显然System::FindDynaInst把对应函数代号的函数指针放在eax里,call eax就调用相应的函数。
这就是整个大的流程。现在我们关心的是,System::FindDynaInst(void*, int)到底做了些什么。我们可以跟踪进去,再跳一层,我们来到了函数中,源代码就是Source/Vcl/system.pas中的_FindDynaInst。
procedure       _FindDynaInst;
asm
 PUSH  EBX
 MOV  EBX,EDX        ;EBX储存了函数的代号
 MOV  EAX,[EAX]      ;EAX获得VMT入口地址
 CALL  GetDynaMethod      ;调用GetDynaMethod
 MOV  EAX,EBX
 POP  EBX
 JNE  @@exit
 POP  ECX
 JMP  _AbstractError
@@exit:
end;

那么我们还得看看GetDynaMethod的源代码。
procedure       GetDynaMethod;
{function GetDynaMethod(vmt: TClass; selector: Smallint) : Pointer;}
asm
 { ->  EAX     vmt of class      }
 {    BX      dynamic method index  }
 { <-  EBX pointer to routine      }
 {    ZF = 0 if found      }
 {    trashes: EAX, ECX    }

 PUSH  EDI
 XCHG  EAX,EBX        ;交换eax和ebx的值
 JMP  @@haveVMT      ;交换后ebx是VMT入口地址,eax是函数代号
@@outerLoop:
 MOV  EBX,[EBX]      ;取地址
@@haveVMT:
 MOV  EDI,[EBX].vmtDynamicTable  ;EDI是DMT的入口
 TEST  EDI,EDI        ;测试是否存在DMT(EDI是否为0)
 JE  @@parent      ;若DMT不存在,在父类中继续找
 MOVZX  ECX,word ptr [EDI]    ;取头两个字节,即动态函数个数
 PUSH  ECX
 ADD  EDI,2        ;跳至黄色部分(见后面的图)
 REPNE  SCASW        ;查找eax
 JE  @@found        ;若找到则跳转
 POP  ECX
@@parent:
 MOV  EBX,[EBX].vmtParent    ;在父类中继续
 TEST  EBX,EBX        ;是否有父类
 JNE  @@outerLoop      ;有则继续查找
 JMP  @@exit        ;无则跳转
@@found:
 POP  EAX
 ADD  EAX,EAX        ;以下两步是清除ZF,其中ECX值为0
 SUB  EAX,ECX         { this will always clear the Z-flag ! }
 MOV  EBX,[EDI+EAX*2-4]    ;edi-1是函数代号所在处
@@exit:
 POP  EDI
end;

看汇编头晕吧?嘿嘿,对着注释看看这个图就清楚了。vmtDynamicTable所指向的地址,就是一个DMT,而它的结构,我们前面已经分析过了。唯一需要说明的是
ADD  EAX,EAX          ;EAX值为n,自加后为2*n
SUB  EAX,ECX          ;ecx值已经递减为0,这句仅仅是清除ZF标志位
MOV  EBX,[EDI+EAX*2-4]      ;

清除ZF是因为_FindDynaInst要由此判断是否找到相应的函数。而edi-4为函数代号所在的地方,edi-4+4*n即为函数指针所在,也就是edi+eax*2-4。
其实不需要与汇编纠缠,在前面我们已经知道了其原理,大同小异罢了。

结束语
C++的重用性是对源代码级而言,而对二进制级重用性的支持则捉襟见肘。特别是动态连接库DLL的广泛运用,更显出解决这个问题的重要性。COM的口号之一正是COM as a Better C++7。讲COM的书中往往指出若干C++的不足,其实不少是可以解决的。比如

问题:不同编译器的名字粉碎机制不同,导致不同编译器编译的模块不能顺利连接。
解决:使用DEF文件。
代价:操作麻烦,增加维护负担,但对程序效率没有任何影响。
问题:不同版本的类大小不一样,主要原因是成员变量增加或减少,导致分配空间时出错。
解决:隐藏实现,成员变量仅保留一个指针void *,在运行时动态申请空间。
代价:可读性和性能均受影响。
添加普通成员函数没有什么大的问题,但是添加虚函数则影响VFT,可能导致程序错误甚至系统崩溃。解决的办法在前面已经说明,其中良好的设计是必不可少的。建议

根类的设计一定要慎重,VCL从开始至今,TObject类的变化始终很少,否则牵一发而动全身,维护性就大打折扣;
类层次应尽可能浅,尽量避免使用继承等耦合性很强的关系,严格遵循Liskov替换原则LSP[9];
如果程序只在WINDOWS下运行,可以考虑使用COM;
如果始终使用Borland的编译器,并对性能要求不高,可以考虑使用动态(dynamic)函数;
多写几个无用的虚函数占位,也是个不错的方法。
动态函数应用在合适的地方,这一点可以参考VCL各个类中动态函数的使用情况。另外,动态函数所节约的VFT空间微不足道,在有的情况反而DMT的空间占得更多。总体来说,动态函数在时间上吃亏,空间上占的便宜也不大。在我看来,解除了父类和子类VFT之间的关联性,才是动态函数最大的好处。

不论是辨证唯物主义,还是道家思想,都强调事物的两面性。不论什么方法,都是一把双刃剑,所谓“祸兮福之所倚,福兮祸之所伏”。我们要做的,就是权衡利弊,结合具体的环境,扬长避短。

参考开门
虫虫

前言
如果你爱他,让他学VCL,因为那是天堂。
如果你恨他,让他学VCL,因为那是地狱。
──《天方夜谭VCL》

传说很久很久以前,中国和印度之间有个岛。那里的国王每天娶一个女子,过夜后就杀,闹得鸡犬不宁,最后宰相的女儿自愿嫁入宫。第一晚,她讲了一个非常有意思的故事,国王听入了迷,第二天没有杀她。此后她每晚讲一个奇特的故事,一直讲到第一千零一夜,国王终于幡然悔悟。这就是著名的《一千零一夜》,也就是《天方夜谭》。印度和中国陆地接壤,那么相信传说中所指的岛,必然是在南中国海-马六甲海峡-印度洋某个地方。现在我也算是在这其间的一个海岛上,正值夜晚,也就借借“天方夜谭”的大名吧。

初中我最喜欢的编程环境是Turbo C 2.0,高一开始用Visual Basic。后来用了没多久就发现,如果想做一个稍微复杂的东西,就需要不停地查资料来调用API,得在最前面作一个长得可怕的API函数声明。于是我开始怀念简洁的C语言。有位喜欢用Delphi的师哥,知道我极为愤恨Pascal,把我引向C++ Builder。即使对于C++中的继承、多态这些简单概念都还是一知半解,我居然也开始用VCL编一些莫名其妙的小程序(VCL上手倒真容易),开始熟悉VCL的结构,同时也了解了MFC和SDK,补习C++的基础知识。后来我才觉得,VCL易学易用根本是个谎言。其实VCL相当难学,甚至比MFC更麻烦。

不知道为什么,C++ Builder的资料出奇地少,也许正是这个原因,C++ Builder论坛上的人情味也特别浓。不管是我初学VCL时常问些莫名其妙白痴问题的天极论坛,还是现在我经常驻足的CSDN,C++ Builder论坛给人的感觉总是很温馨。每次C++ Builder都比同等版本Delphi晚出,每次用C++还不得不看Object Pascal的脸色,我想这是很多人心里的感受。CLX已经出现在Delphi6中,C++ Builder6的发布似乎还遥遥无期。CLX会代替VCL吗?看来似乎不会,后面还会提到。我也看过不少要号召把VCL用C++改写的帖子,往往雷声大雨点小。看看别人老外,说干就干,一个FreeCLX项目就这么启动了。

用MFC的人比用VCL的运气好,他们有Microsoft的支持,有Inside Visual C++、Programming Windows 95 with MFC、MFC Internals这些天王巨星的英文名著和中文翻译,也有诸如侯捷先生的《深入浅出MFC》(即Dissecting MFC)这些出色的中文原创作品。使用Delphi的人也远比使用C++ Builder的命好,关于Delphi的精彩资料远远比C++ Builder多,很无奈,真的很无奈。

C++ View杂志的主编向我约稿,我很为难,因为时间和技术水平都成问题。借用侯捷先生一句话,要拒绝和你住在同一个大脑同一个躯壳的人日日夜夜旦旦夕夕的请求,是很困难的。于是我下决心,写一系列分析VCL内部原理的文章。所谓“天方夜谭”,当然对初学者不会有立杆见影的帮助,甚至于会让您觉得“无聊”。这些文章面向的朋友应该比较熟悉VCL,有一定C++的基础(当然会Object Pascal和汇编更好),比如希望知道VCL底层运作机制的朋友,和希望自己开发应用框架或者想用C++重写VCL的朋友。同时我更希望大家交流一下解剖应用框架的经验,让我们不局限于VCL或者MFC,能站在更高的角度看问题,共同提高自己的能力。

在深入探讨VCL之前,先得把VCL主要的性质说一下。

同SmallTalk和Java所带的框架一样,VCL是Object Pascal的一部分,也就是说语言和框架之间没有明确的界限。比如Java带有JDK,任写一个类都是java.lang.Object的子类。VCL和Object Pascal是同样的道理。当然,Object Pascal为了兼容以前的Pascal,依然允许某个类没有任何父类,但本系列文章将不再考虑这种情形。
同大多数框架一样,VCL采取的是单根结构。也就是说,VCL的结构是以一棵TObject为根的继承树,除TObject外的所有VCL类都是TObject直接或间接的子类。
由于Object Pascal的语言特性,整个结构中只使用单继承。
所以,VCL的本质是一个Object Pascal类库,提供了Object Pascal和C++两个接口。在剖析的过程中,请时刻牢记这一点。

文章的组织结构是就事论事,一次一个话题。由于VCL并不像MFC是一个独立的框架,它与Object Pascal、IDE、编译器结合非常紧密,所以在剖析过程中不免会提到汇编。当然不会汇编的朋友也不用怕,我会把汇编代码都解释清楚,并尽量用C++改写。

文中有很多图是表示类的内存结构,如图所示。其中方框表示一个变量,两端伸出表示还有若干个变量,椭圆标注是说明虚线圆圈中的整个对象(在后面虚线圆圈不会画出)。


图1 图例

文中的程序,如非特别说明,均可以在Console Application模式下(如果使用了VCL类则需要复选“Use VCL”)编译通过。

开门
倒霉者如愚公,开门就见太行、王屋山。在一怒之下他开始移山,最后幸亏天神帮忙搬走了。中国人不喜欢开门见山的性格可能就是愚公传下来的,说话做事老爱绕弯。当然我也不能免俗,前面废话了一大堆,现在接着来。

提起RTTI(runtime type identification,运行时间类型辨别),相信大家都很熟悉。C++的RTTI功能相当有限,主要由typeid和dynamic_cast提供[1]。至于这两者的实现方式[2],不是我们今天的话题,我们所关注的,乃是VCL所提供的“高级”RTTI的底层机制。

熟悉框架的朋友都知道,框架往往会提供“高级”的RTTI功能。我曾看过一个论调,说Java和Object Pascal比C++好,原因是因为它们的RTTI更“高级”。且不论滥用RTTI极为有害,事实上,C++用宏(macro)亦可以模拟出相同功能的RTTI[3]。

不过对于VCL类来说,您清楚其RTTI机制的运作情况吗?对于如下

class A: public TObject
{
        ...
}
 ...
 A* p = new A;

为什么p->ClassName();就能返回类A的名字“A”呢?
为什么A::ClassName(p->ClassParent())就可以返回A的基类名“TObject”呢?
为什么……?
其实这都是编译器暗箱操作的结果。说白了,编译器先在某个地方把类名写好,到时候去取出来就行。关键在于,如何去取出来呢?显然有指针指向这些数据,那么这些指针放在什么地方呢?

记得《阿里巴巴和四十大盗》的故事吧?宝藏是早就存在的,如果知道口诀“芝麻,开门吧”,就可以拿到宝藏。同样,类的相关信息是编译器帮我们写好了的,我们所关心的,就是如何获取这些信息的“口诀”。

不过这一切,要从虚函数开始,我们得先复习一下C/C++的对象模型。

虚拟函数表VFT
C语言提供了基于对象(Object-Based)的思维模型,其对象模型非常清晰。比如

struct A
{
 int i;
 char c;
};

 

图 2 结构的内存布局  

在32位系统上,变量i占用4个字节,变量c占用1个字节。编译器可能还会在后面添加3个字节补齐。那么,sizeof(A)就是8。

C++提供了面向对象(Object-Oriented)的思维模型,其对象模型建立在C的基础上。对于没有虚函数的类,其模型与C中的结构(struct)完全一样。但如果存在虚函数,一般在类实体的某个部分会存在一个指针vptr,指向虚拟函数表VFT(Virtual Function Table)的入口。显然,对于同一个类的所有对象,这个vptr都是相同的。例如

class A
{
private:
 int i;
 char c;
public:
 virtual void f1();
 virtual void f2();
};

class B: public A
{
public:
 virtual void f1();
 virtual void f2();
};

当我们作如下调用的时候
A* p;
...
p->f2();

程序本身并不知道它会调用A::f还是B::f或是其它函数,只是通过类实体中的vptr,查到VFT的入口,再在入口中查询函数地址,进行调用。由于Borland C++编译器把vptr放在类实体的头部,因此下面均有此假设。
为了更充分地说明问题,我们从汇编级来分析一下。假设我们采用的是Borland C++编译器。

p->f2();

这句的汇编代码是
mov eax,[ebp-0x04]
push eax
mov edx,[eax]
call dword ptr [edx+0x04]
pop ecx



图3 C++类实体的内存布局

第一句ebp-0x04是指针变量p的地址,第一句是把p所指向的对象的地址传送到eax;
第二句不用管它;
第三句是把对象头部的指针vptr传到edx,即已取得VFT的入口;
第四句是关键,edx再加4(32位系统上一个指针占4个字节),也就是调用了从VFT入口算起的第二个函数指针,即B::f2;
第五句不用管它。

相信大家对VFT和C++的对象模型有一个更深刻的认识吧?对于VFT的实现,各个编译器是不一样的。有兴趣的朋友不妨可以自行探索一下Microsft Visual C++和GCC的实现方法,比较一下它们的异同。

知道了VFT的结构,那么想想下面这个程序的结果是什么。

#include
using namespace std;

class A
{
 int c;
        virtual void f();
public:
 A(int v = 0) { c = v;}
};

void main()
{
        A a, b(20);
        cout << *(void**)&a << endl;
        cout << *(void**)&b << endl;
}

我想您应该能理解其中*(void**)&a吧?这是取得vptr的值,也就是a所在内存空间的前4个字节,一个指针。下面我们还会使用类似的语句。
无庸质疑,结果是输出两个完全相同的值。前面我们已经说过,对于同一个类的所有对象,其vptr值都是相同的。

那么这个VFT到底有什么作用呢?现在看来,似乎就是储存虚函数的地址。

虚拟方法表VMT
如何通过类的实体来找到类的相关RTTI信息呢?显然,VFT是同一个类的所有实体共享的数据,而RTTI正好也是。那么,把RTTI放在VFT里,就是个不错的选择。

往哪儿放呢?VFT从入口开始往后是各个虚函数的指针,那么RTTI只能放在两个地方:入口以前或者所有虚函数指针之后。显然,放在入口以前更好,至少我们不用关心虚函数的多少,RTTI的位置也可以相对确定。

VCL就采用了这个办法来放置RTTI,不过把VFT换了名字,叫虚拟方法表VMT(Virtual Method Table)。VMT的结构是怎样的呢?Borland所提供的帮助文件里没有任何相关资料,不过我们在Include/Vcl/system.hpp中就能找到如下蛛丝马迹。

static const Shortint vmtSelfPtr = 0xffffffb4;
static const Shortint vmtIntfTable = 0xffffffb8;
static const Shortint vmtAutoTable = 0xffffffbc;
static const Shortint vmtInitTable = 0xffffffc0;
static const Shortint vmtTypeInfo = 0xffffffc4;
static const Shortint vmtFieldTable = 0xffffffc8;
static const Shortint vmtMethodTable = 0xffffffcc;
static const Shortint vmtDynamicTable = 0xffffffd0;
static const Shortint vmtClassName = 0xffffffd4;
static const Shortint vmtInstanceSize = 0xffffffd8;
static const Shortint vmtParent = 0xffffffdc;
static const Shortint vmtSafeCallException = 0xffffffe0;
static const Shortint vmtAfterConstruction = 0xffffffe4;
static const Shortint vmtBeforeDestruction = 0xffffffe8;
static const Shortint vmtDispatch = 0xffffffec;
static const Shortint vmtDefaultHandler = 0xfffffff0;
static const Shortint vmtNewInstance = 0xfffffff4;
static const Shortint vmtFreeInstance = 0xfffffff8;
static const Shortint vmtDestroy = 0xfffffffc;

注意这些常数值中的负数采用的是补码表示法。求一个负数的补码,先写出相应正数的补码表示,再按位求反,最后(在最低位)加1即可。对于求32位负数的补码,也可以用它本身减去0xffffffff再减1即可。以0xfffffffc为例,0xfffffffc – 0xffffffff – 1 = – 0x04,这就是结果。我们还可以从Borland提供的原始码Source/Vcl/system.pas获得,其中就是用负数表示。
看着这份表格,从这些变量名中,我们已经猜到了其大概的分布情况。这些数字之间的间隔都是[4],可以猜想这些都是指针:函数指针或者数据指针。从这些常数的名字我们就可以知道它们的作用,比如vmtClassName自然就是储存类名的指针。入口0以前,就是VCL对象的关键数据。无疑,它们蕴涵了TObject乃至VCL对象关键的秘密,也就是VMT的分布结构。

这以上只是我们的推测,我们还应该验证一下。我们知道的事实是,每一个对象必然都包含了其所属类的相关信息。比如任何一个C++类的实体,都包含一个指向虚拟函数表VFT的指针。VCL类的实体必然也包含一个指向虚拟方法表VMT的指针。

#include
#include
using namespace std;

class A: public TObject
{
        int x;
   virtual void f1() {}
   virtual void f2() {}
public:
   A(int v = 0): x(v) {}
};

void main()
{
        A* p = new A;, * q = new A(100);
        void* a = *(void**)p, * b = *(void**)q;
        void* c = p->ClassType(), * d = q->ClassType();
        cout << a << ' ' << b << endl;
        cout << c << ' ' << d << endl;
        cout << __classid(A) << endl;
 delete p;
 delete q;
}

结果很有意思,输出的五个指针地址完全一样!a和b相同,从前面的例子我们就可以知道。然而TObject的ClassType方法和__classid操作符的返回值也跟这两者相同,这就有点意思了。查查帮助就可以知道,__classid是C++ Builder中新增的扩展关键字,返回类的VMT的入口地址;而TObject的ClassType方法则是返回对象的类信息,返回类型是TClass(也就是TMetaClass*)。这说明,每个VCL类实体的头部包含的指针,就是指向VMT的入口地址。而这个位置,也就是TObject的成员函数ClassType的返回值,亦即运算符__classid返回的类A的信息,只不过这个返回值是以TClass(即TMetaClass*)的形式存在。


图4 VCL类的VMT入口

我们已经知道了VMT的结构,现在又找到了其入口,此时的兴奋不亚于阿里巴巴知道“芝麻,开门吧”这句咒语时的感受。既然知道了开门的咒语,还不赶快进去拿宝藏?

牛刀小试
乘着东风,我们来模拟一下VCL简单的RTTI功能。为方便起见,我们仿造TObject,写一个类FObject(呵呵,如果把TObject看成True Object,我们的FObject就是False Object)。要问下面这段代码从哪里来?大部分都Copy&Paste自Include/Vcl/systobj.h文件。

class FObject
{
public:
        FObject(); /* Body provided by VCL {} */
        Free();
        TClass    ClassType();
        void    CleanupInstance();
        void *    FieldAddress(const ShortString &Name);

 /* class method */
 static TObject * InitInstance(TClass cls, void *instance);
 static ShortString ClassName(TClass cls);
 static bool ClassNameIs(TClass cls, const AnsiString string);
 static TClass ClassParent(TClass cls);
 static void * ClassInfo(TClass cls);
 static long InstanceSize(TClass cls);
 static bool InheritsFrom(TClass cls, TClass aClass);
 static void * MethodAddress(TClass cls, const ShortString &Name);
 static ShortString MethodName(TClass cls, void *Address);
         
 /* Hack: GetInterface is an untyped out object parameter and
 * so is mangled as a void*. In practice, however, it is
 * really a void**. Be sure when using this method to provide
 * two levels of indirection and cast away one of them.
 */

        bool GetInterface(const TGUID &IID, /* out */ void *Obj);

        /* class method */
        static PInterfaceEntry GetInterfaceEntry(const TGUID IID);
        static PInterfaceTable * GetInterfaceTable(void);

        ShortString ClassName()
        {
                return ClassName(ClassType());
        }

        bool ClassNameIs(const AnsiString string)
        {
                return ClassNameIs(ClassType(), string);
        }

        TClass ClassParent()
        {
                return ClassParent(ClassType());
        }

        void * ClassInfo()
        {
                return ClassInfo(ClassType());
        }

        long InstanceSize()
        {
                return InstanceSize(ClassType());
        }

        bool InheritsFrom(TClass aClass)
        {
                return InheritsFrom(ClassType(), aClass);
        }

        void * MethodAddress(const ShortString &Name)
        {
                return MethodAddress(ClassType(), Name);
        }

        ShortString MethodName(void *Address)
        {
                return MethodName(ClassType(), Address);
        }

        virtual HResult SafeCallException(TObject *, void *);
        virtual void AfterConstruction();
        virtual void BeforeDestruction();
        virtual void Dispatch(void *Message);
        virtual void DefaultHandler(void* Message);

private:
        virtual TObject* NewInstance(TClass cls);

public:
        virtual void FreeInstance();
        virtual ~FObject();  /* Body provided by VCL {} */
};

当然FObject::ClassType我们已经会写了,那就是
TClass FObject::ClassType()
{
        return *(TClass*)this;
}

我们会在后面陆续把这些成员函数填充完整。先举个例,拿类名(ClassName)开刀吧。
查查VMT表,vmtClassName = 0xffffffd4,我们就从这里下手。主要的步骤是:

找到VMT的入口;
通过vmtClassName找到储存类名的地址;
获取类名。
0xffffffd4也就相当于– 44,也就是VMT入口指向的地址开始,倒数第44字节到倒数第41字节这4个字节所代表的指针,指向类名。假设入口指向的地址是cls,那么vmtClassName所代表的地址就是(char*)cls – 44,亦即(char*)cls + vmtClassName。

注意一个字符串格式的问题,VCL既然是用Object Pascal写的,其中储存类名的字符串的格式必然是Pascal传统方式,也就是第1个字节为字符串的长度,紧接着为字符串的实际内容。在C++ Builder中,与之对应的类型是ShortString。



图5 TObject::ClassName的运作方式

代码如下:

ShortString FObject::ClassName(TClass cls)
{
        ShortString* r = *(ShortString**)((char*)cls + vmtClassName);
        return *r;
}

我们不妨测试一下。
#include
#include
#include
using namespace std;
... 插入FObject相应的代码...
void main()
{
        auto_ptr list(new TList);
        FObject* p = (FObject*)list.get();
        cout << AnsiString(p->ClassName()).c_str() << endl;
        cout << AnsiString(list->ClassName()).c_str() << endl;
}

输出结果在我们意料之内,都是“TList”。
对于函数ClassNameIs,我们就可以轻而易举地完成了。

bool FObject::ClassNameIs(TClass cls, const AnsiString string)
{
        return string==ClassName(cls);
}

有朋友可能奇怪,你怎么知道TObject::ClassName是这样的呢?
三种办法:

猜,用经验推测;
看Borland提供的原始码;
看编译以后的汇编码。
在Borland提供的原始码中,我们可以看到TObject::ClassName的实现如下:

class function TObject.ClassName: ShortString;
asm
        { ->  EAX VMT              }
        {    EDX Pointer to result string  }
        PUSH    ESI
        PUSH    EDI
        MOV     EDI,EDX
        MOV     ESI,[EAX].vmtClassName
        XOR     ECX,ECX
        MOV     CL,[ESI]
        INC     ECX
        REP     MOVSB
        POP     EDI
        POP     ESI
end;

熟悉汇编的朋友就可以由此写出相应的C/C++代码来。对于不会的朋友,根据我们的讲解,相信也可以轻而易举地完成吧。
希望您在看这段的时候,不妨先用第1种办法,然后结合2、3看看,一定收获不小。

势如破竹
接下来就太简单了,我们不再举例,把相应的成员函数补充完整即可。您不妨先自己试着写写,探索一下,再与汇编代码和文中的代码作比较,一定乐趣无穷。

TObject::ClassInfo是做什么的?问我啊?我也不知道。VCL的帮助里说,用ClassInfo可以访问包含对象类型、祖先类和所有published属性信息的RTTI表。这个表只是内部使用,TObject提供了其它方法来访问RTTI信息。我们先写出它的实现。



图6 TObject::ClassInfo的运作方式

void * FObject::ClassInfo(TClass cls)
{
        return *(void**)((char*)cls + vmtTypeInfo);
}

Borland的说法可信吗?这个函数返回值的类型是void *,明摆着不愿意透露更多的信息。您不妨按上面ClassName的方法测试一下,对于TList,ClassInfo输出的结果居然是0!也就是一个空指针!什么东东?别急,后面我们会掀开这个void *的面纱,现在姑且卖个关子。
VCL框架中只存在单继承,这是由Object Pascal语言的特性决定的。这样,每一个类只有唯一一个父类,函数TObject::ClassParent就能帮您把父类找出来。

TClass FObject::ClassParent(TClass cls)
{
        TClass* r = *(TClass**)((char*)cls + vmtParent);
        return (r)? (*r) : 0;
}

由此,我们也能很轻松地模拟TObject::InheritsForm的实现。
bool FObject::InheritsFrom(TClass cls, TClass aClass)
{
        while(aClass)
        {
                if(aClass==cls)return true;
                cls = ClassParent(cls);
        }
        return false;
}

要知道一个对象所占的字节数,TObject::InstanceSize就可以达到目的。
long FObject::InstanceSize(TClass cls)
{
        return *(long*)((char*)cls + vmtInstanceSize);
}

有朋友可能说,C++不是有sizeof操作符吗?为什么不用呢?在VCL中,sizeof有两个缺陷。首先sizeof是完全静态的,也就是说,如果您写sizeof(...),编译以后,这会被替换为一个常数,没有任何的求值过程,因此不能动态求值;其次,VCL类必须与指针或引用的形式存在。所以对于
TObject *a;
...

sizeof(*a)这样的表达式是错误的。而且即使TObject不是VCL类,使用sizeof(*a)还是相当于sizeof(TObject),没有实际价值。
结束语
现在我们已经打开了通向VCL类秘密的大门。回头一看,VMT跟VFT有什么区别与联系呢?其实VMT可以算是VFT具体化的一个结果,也就是说,VMT是在VFT基础上发展出来的一种具有“规范”性质的结构,所有的VCL类都遵循这个“规范”。这很像COM与C++纯虚基类的关系。

通过VMT,VCL放置了一些重要的信息,由此来实现RTTI。所以“高级”RTTI功能其实是相当低级和简单的一项技术。就其实现方式而言,大致有三种。MFC用宏(macro)模拟算是一类,完全符合C++标准,不需要对语言进行扩充,也不依赖于特定的编译器,不过给人臃肿的感觉;VCL则是完全由编译器实现,同时扩充了C++的语言特性,必须在Borland的编译器上编译,但是很简洁;另外建构KDE基础的跨平台框架Qt[4],则采用了折中的方式,扩充了C++的关键字,书写很简洁,在编译之前必须用Qt提供的程序MOC进行预处理,把扩充部分的代码改写为符合C++标准的代码,然后才可以在任何符合C++标准的编译器上编译。

代表作 实现方式 不依赖特定编译器 简洁程度 编译次数
MFC 宏插入 是 一般 1
VCL 编译器生成 否 好 1
Qt 预编译程序生成 是 较好 2(包括MOC)


注:如果长期仅在Windows平台下进行开发的朋友,可能没有听说过Qt的大名。事实上在Linux世界里,这可是个响当当的名头。Qt是一套完善的C++框架,横跨Unix/Linux、Windows、Mac OS诸多平台,内部机制相当有趣。Borland最新的Kylix和Delphi6所采用的跨平台框架CLX(分为BaseCLX、VisualCLX、DataCLX、NetCLX四个部分,BaseCLX与VCL顶部几个类相同),其可视化部分VisualCLX就建构在Qt上,这多少让我感到失望和不满。Qt本身就跨平台,VisualCLX建构在Qt上,自然也跨平台;但是CLX是用Object Pascal包装了一个C++框架,我不敢想象,C++ Builder6中的CLX是否又用C++再来包装这个包装了C++框架的Object Pascal框架呢?如果真是如此,其效率和调试难度……

对于“高级”RTTI的实现形式,VCL用了TMetaClass(其中TClass就是TMetaClass*)来配合存储类的信息,也就是所谓“类的类”,这非常普遍。MFC中的CObject与CRuntimeClass,JDK中的java.lang.Object和java.lang.Class都是如此。比如对于一个TObject *p,如何获取其父类名呢?我们必须借助TMetaClass:可以先用p->ClassType返回父类的信息(是一个TMetaClass*类型),再以此为参数传入TObject::ClassName就可以获得结果,也就是TObject::ClassName(p->ClassType())即可。

同时我们也应该拆穿所谓“拥有更高级RTTI的语言本身也更高级”的谎言。至少从我那少得可怜的经验来看,对于一套框架,除非需要和IDE配合,否则在绝大部分情况下,RTTI是完全没有必要的,甚至是有害的[5]。希望使用和设计框架的朋友三思。

致谢
 生死
虫虫

生命是什么?科学和宗教都给出了不同的诠释。有句话也许说得更有意思:生命是这样一种东西,如果你把它当作一个开场或结局,那么它总是一样的;而当你把它当作一个过程,它总是不同的。其实,万事万物又何尝不是分别以生和死作为开场和结局呢?对象也不例外,不过生成以及销毁对象都需要健全的机制作保证。否则不仅对象本身遭殃,甚至会导致程序乃至整个系统崩溃。

传说中,东方的天、人、阿修罗、畜生、饿鬼、地狱六道轮回(以及由此演变出的丰都鬼城),和西方的地狱、炼狱、天堂,都有一套非常完整、严密、健全的机制,管理着时空中各种生命体。同样,一套框架也需要这么一套机制来管理记忆体中的对象,以保证正常运作。VCL自然也不例外。但虫虫这次并不准备详细分析涉及VCL对象生死的代码,相信大家对剖析涉及底层汇编都有了一定的经验。所以前面虫虫会对这方面提几句,把重点放在设计的结构和模式上,并解决一个在BBS上看到的问题。

对象生成
对象生成的方式几乎都是一样,一般流程如右图所示(VCL类的初始化是指初始化VMT和接口指针)。对象一般生存在两个地方,栈(stack)或自由存储区(free store)[1]中。由于Object Pascal只支持第二种方式,所以VCL类都在自由存储区中,表现在C++中就是必须使用new和delete分配、回收空间,速度自然会比存在于栈中的普通C++类要慢一些。

控制VCL对象生成过程的代码主要在TObject::InstanceSize、TObject::NewInstance、TObject::InitInstance几个成员函数中。有兴趣的朋友可对照右图分析一下,源代码在Source/Vcl/system.pas里,没有什么特别之处。我们将把重点主要放在分析动态生成(Dynamic Creation)机制上。

动态生成是一个相当实用的技术。比如上次我们提到的一个绘制图形的程序[2],具体的图形以插件的方式提供,主程序对相应的图形类一无所知,但是仍然需要“动态”地生成这些对象。又比如Delphi/C++ Builder的IDE对象设计器,也是一个很好的例子:鼠标双击,一个对象就动态生成在设计面板,可以供我们设计之用了。C++语言本身并没有也不可能提供对动态生成的支持,不过MFC中用宏(macro)模拟就可以取得令人满意的效果[3]。
 
图1 对象生成流程

仔细想来,动态生成是个很好笑的技术,它需要程序生成一个对其性质并不清楚的对象。您能造一个您不知道的东西吗?不可能。但是如果告诉您制造的原料和方法呢?那当然就很简单了。所以动态生成的关键是:留好事先约定的接口。MFC的宏模拟就是一种方式,Object Pascal则是使用了另一种方式。

“高级”RTTI方式往往会引入一个所谓“类的类”,即“元类(metaclass)”的概念[4]。在Object Pascal中,每个类都有一个代表其相应信息的类。代表TObject类信息的类是TClass,代表TPersistent类信息的类就是TPersistentClass……


图2 实现RTTI的metaclass

Object Pascal可以借此来实现对TComponent派生类的“动态生成(Dynamic Creation)”机制。

function CreateComponent(AOwner: TComponent;AClass: TComponentClass)
:TComponent;
var
  Instance: TComponent;
begin
  Instance := TComponent(AClass.NewInstance);
  {try}
    Instance.Create(Owner);
{except
     raise;
   end;}
  CreateComponent := Instance;
end;

但是在C++ Builder中,这一切就无效了。对于所谓的“元类”,C++ Builder的VCL中只提供TMetaClass类,并且功能很少,甚至根本没有提供NewInstance等方法,巧妇难为无米之炊啊。Borland怎么这么偏心眼呢?又怎么办呢?国内各大BBS上,我问过,也看别人问过这样的问题,可以总是没有答案。在国外的BBS上这样的问题也不少,而常见的解决方案(是,用Object Pascal写单元,再让C++调用,这也未免……
动态生成的难点
TMetaClass究竟是什么?我们已经知道,就是指向VMT入口的指针。假如给我们一个TMetaClass,我们能做到动态生成吗?对照对象生成的流程图,我们来分析

获取类实体大小:通过TMetaClass::InstanceSize可以做到;
分配空间:这个当然可以;
初始化:如果无特殊需要,直接调用TObject::NewInstance就行了。老弟,不是开玩笑吧?TObject::NewInstance是private!呵呵,想个办法绕过去,通过VMT表“开门”不就OK?)
调用构造函数:倒!这个怎么办?TMetaClass里可没记录过某个类的构造函数,再说,一个类的构造函数好象也不只一个吧?
唯一的问题,就出在构造函数上:我们需要一个形式固定的“虚”构造函数,也就是我们刚才说的:留好实现约定的接口。这一招的要点,跟MFC是一致的。

虚构造函数?C++的构造函数可以是虚(virtual)的吗?当然不可以。VCL类从TComponent类开始,构造函数就是虚的(难怪刚才我们只生成TComponent的派生类),Object Pascal所谓的虚构造是如何做到的呢?Scott Meyers在Virtualizing constructors and non-member functions一文[5]中详细说明了所谓虚构造的实现方式:事先约定一个普通的虚函数,其功能是构造函数而已。也就是说,Object Pascal所谓的虚构造函数(名字是Create),不过是一个事先约定好功能的普通虚成员函数罢了。MFC是这么做的,事实上,VCL也一定差不多。在设计模式中,这叫做FACTORY METHOD模式,正好又名VIRTUAL CONSTRUCTOR模式[6]。

那么,这个虚函数一定可以在VMT中找出来!问题不就解决了吗?在TComponent类的VMT入口开始的若干个指针地址中找出TComponent::TComponent(也就是Object Pascal中的TComponent.Create),相信是很容易的事情吧?

#include
#include
using namespace std;

void main()
{
        void** p = (void**)__classid(TComponent);
        for(int i = 0; i < 15; ++i)
                cout << *(p++) << '/t';
}

这几行程序能输出TComponent的VMT中前15个函数地址。在我的机器上输出结果是(也许在您那里有所不同):

40026BD4    40030A54    40026AF0    40030B2C    400309F8
40030B38    40030C30    40030F88    40030B48    40030B40
40030F90    400306CC    0000000E    00010000    45840000

再查查TComponent::TComponent的地址。IDE菜单View->Debug Windows->Modules,选择vcl50.bpl,找到TComponent::TComponent(如下图)。看到了吧?它的地址跟上面输出的第12个地址完全相同,这,就是我们的目标!(其实我们还有更偷懒的方法:看看前面那段Object Pascal程序的汇编代码就行啦!)

图3 寻找TComponent::TComponent的地址

现在写个我们自己的CreateComponent很容易了吧?从VMT入口算,第12个指针是构造函数的指针。而VCL类是“虚”构造的,所有TComponent的派生类的构造函数地址也会放在那里。所以,从VMT第一个指针往前数11个,就是我们需要的第12个指针了。

TComponent* CreateComponent(TComponent* AOwner, TClass cls)
{
        TComponent* r;
        const void* const fn = *(void**)((char*)cls + vmtNewInstance),
                  * const fc = *((void**)cls + 11);
   //fn是NewInstance的地址,
   //fc则是构造函数的地址,从第一个往后数11个,即第12个

 //下面为了偷懒,用几句汇编
 asm{
   mov eax, cls
   call fn      //调用NewInstance,执行图1流程前3步
   mov r, eax
   mov edx, AOwner
   call fc      //调用构造函数
 }
 return r;
}

我们不妨测试一下。
#pragma inline
#include
#include
using namespace std;

class TTestButton: public TButton
{
public:
        __fastcall TTestButton(TComponent* Owner): TButton(Owner)
        {
                cout << "Hello!" << endl;
        }
        __fastcall ~TTestButton()
        {
                cout << "Goodbye!" << endl;
        }
};

TComponent* CreateComponent(TComponent* AOwner, TClass cls)
{
       //...
}

void main()
{
        TComponent* p = CreateComponent(0, __classid(TTestButton));
        cout << AnsiString(p->ClassName()).c_str() << endl;
        delete p;
}

输出:
Hello!
TTestButton
Goodbye!

结果令人满意!
属主机制
这里简单提一下VCL类的组织形式:属主(Owner)机制。

从TComponent开始,VCL类的构造函数就带有一个TComponent*类型(Object Pascal中表现为TComponent)的参数AOwner。每个从TComponent继承的类的实体都拥有唯一一个所有者(Owner,亦即属主),这就决定了这些类之间是树形关系。例如

TForm* Form1 = new TForm(Application);
TForm* Form2 = new TForm(Application);
TButton* Button1 = new TButton(TForm1);


图4 树形结构

这样就形成了如下以Application为根(Root)的树形结构:

上图中的箭头表示从属关系,也就是下图的意思


图5  从属关系

于是当Application析构的时候,它会通知自己所拥有的对象Form1和Form2析构,Form1和Form2再通知自身所拥有的对象析构,如此递归下去,正如同推倒了多米诺骨牌(Domino):Form1和Form2以及它们所拥有的对象,不用显式调用析构函数,资源就自动回收了。

这种Owner机制就是设计模式中典型的结构型模式COMPOSITE(组合)[6],即某个对象拥有一系列类似的对象,而这一系列对象中的每一个又拥有一系列的对象,如此递归下去,管理起来很方便。由Application向其所拥有的对象发送析构的消息的方式,是典型的行为模式Observer(观察者),这也是VCL消息处理的基本模型,我们以后会详细讨论这一问题。

小结
本来开始想对对于VCL类实体具体的生成和销毁做汇编级的分析,后来发现没有多少新的东西,就留给有兴趣的朋友钻研吧。我不得不承认,这篇文章的“黑客”气太重了点儿,扩充性并不好。下次我们将逐步进入VCL消息处理机制,看看它的精华部分
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值