标题:Delphi 对象模型学习笔记
关键词:Delphi 对象模型
作者:dREAMtHEATER
难度:普通[] 中等[x] 高级[]
http://dREAMtHEATER.yeah.net
E-Mail:NoteXPad@163.com
完成日期:2004年08月21日
摘要
Borland Object Pascal 对象模型(现在已经正是命名为 Delphi 语言)与其他 OOP 语言一样,都提供了一些基础服务: 如对象创建服务、对象释放服务、对象识别服务、对象信息服务,除此之外在编译器和 VCL framework 级别上提供了一些额外的服务,例如对象消息分派服务。
前言
首先说一下,Delphi 对象模型涉及的概念非常多,因此在这篇笔记中,我无法将所有的知识点都点到,只是理出一条线方便后来人。可以说这部分内容不是很容易搞懂的,建议大家多看 VCL 源码,它可真是一座金山,有你挖不完的金子,每次你都会有意外收获的。另外有些概念恐怕看源码也不见得搞得懂,这时候你可以通过 Debug 看看反汇编的结果,通常会看到编译器为你做了很多幕后工作。有了这种钻研精神,我想恐怕没有什么问题解决不了的。
正文
Delphi 中万物之源是 TObject,不管你自定义的类是否指明了所继承的父类,一定都是 TObject 的子孙,一样具有 TObject 定义的所有特性[3]。由于在 TObject 中已经提供了大部分的对象基础服务,因此继承类自然而然也就具备了这些对象服务,强烈建议每一个学 Delphi 的朋友都要仔细研习一下 TObject 的源码。
一个对象的生命周期是从它被创建那一刻开始。通常我们都用类似 TMyObject.Create 这样的语句创建对象,那么你知道在这一行代码的后面到底发生了什么事情吗?可以说,发生了无数的事情,对象也从此有了生命。
一,对象创建服务
凡是声明为 Constructor 的方法都属于构造函数,不管你用不用 Create 作为方法名称,但建议只用 Create 作为方法名称;由此可知编译器是根据 Constructor 这个指示字(directive)来生成构造代码,而不是根据 Create 这个方法名称,相当一部分人对构造函数的实现过程存在误解。
一个类实例的生成需要经过对象内存分配、内存初始化、设置对象执行框架三个步骤。
编译器首先调用 System._ClassCreate 进行对象内存分配、内存初始化的工作。而 System._ClassCreate 调用 TObject 类的虚方法 NewInstance 建立对象的实例空间,继承类通常不需要重载 TObject.NewInstance,除非你使用自己的内存管理器,因此缺省是调用 TObject.NewInstance。TObject.NewInstance 方法将根据编译器在类信息数据中初始化的对象实例尺寸(TObject.InstanceSize),调用系统缺省的 MemoryManager.GetMem 过程为该对象在堆(Heap)中分配内存,然后调用 TObject.InitInstance 方法将分配的空间初始化。InitInstance 方法首先将对象空间的头4个字节初始化为指向对象类的 VMT 的指针,然后将其余的空间清零。如果类中还设计了接口,它还要初始化接口表格(Interface Table)。
当对象实例在内存中分配且初始化后,开始设置执行框架。所谓设置执行框架就是执行你在 Create 方法里真正写的代码。设置执行框架的规矩是先设置基类的框架,然后再设置继承类的,通常用 Inherited 关键字来实现。
上述工作都做完后,编译器还要调用 System._AfterConstruction 让你有最后一次机会进行一些事务的处理工作。System._AfterConstruction 是调用虚方法 AfterConstruction 实现的。 在 TObject 中 AfterConstruction 中只是个 Place Holder,你很少需要重载这个方法,重载这个方法通常只是为了与 C++ Builder 对象模型兼容。
最后,编译器返回对象实例数据的地址指针。
需要注意的是,构造函数是对象和类方法的混合,可以用对象引用或者类引用来调用它[4]。类引用模式会按照上面的步骤进行对象创建,对象引用模式只会执行设置执行框架这一步,TApplication.CreateForm 是对象引用的一个实例。
相关 TObject 方法:
TObject = class
// ...
constructor Create;
class function InitInstance(Instance: Pointer): TObject;
class function InstanceSize: Longint;
procedure AfterConstruction; virtual;
class function NewInstance: TObject; virtual;
// ...
end;
function _ClassCreate(AClass: TClass; Alloc: Boolean): TObject;
function _AfterConstruction(Instance: TObject): TObject;
// 别忘了看看这个创建对象的特殊范例,代码位于 Forms Unit
TApplication = class(TComponent)
public
procedure CreateForm(InstanceClass: TComponentClass; var Reference);
二,对象识别服务
对象识别服务的目的是让你可以确定目前使用的对象的类型以及继承架构[1],虽然有时候在你的代码中并没有直接使用这些类方法, 但你已经在间接调用这些方法了,比如经常使用的类操作符 is,它是调用 TObject.InheritsFrom 来实现的。
相关 TObject 方法:
TObject = class
// ...
class function ClassName: ShortString;
class function ClassNameIs(const Name: string): Boolean;
class function ClassParent: TClass;
class function InheritsFrom(AClass: TClass): Boolean;
class function InstanceSize: Longint;
// ...
end;
三,对象信息服务
TObject 中提供的对象信息服务可允许程序员深入追踪对象的构造信息,这些构造信息包括了对象的 VMT、VMT 的内容、对象支持的接口、接口之中的方法以及对象的方法地址等对象系统信息[1]。这方面的内容涉及了 RTTI、接口等诸多方面的知识,需另开文章进行阐述,请随时关注我的主页相关内容的文章发布。
特别注意:李维《Inside VCL》在解释 MethodName 方法时有误。经过验证,结论是凡是声明在类的 Published 部分的方法都可以通过调用 TObject.MethodName 获得方法的名字。 而不是只对 TComponent 继承下来的类的 Published 方法才有作用。
相关 TObject 方法:
TObject = class
// ...
function ClassType: TClass;
class function ClassInfo: Pointer;
class function MethodAddress(const Name: ShortString): Pointer;
class function MethodName(Address: Pointer): ShortString;
function FieldAddress(const Name: ShortString): Pointer;
function GetInterface(const IID: TGUID; out Obj): Boolean;
class function GetInterfaceEntry(const IID: TGUID): PInterfaceEntry;
class function GetInterfaceTable: PInterfaceTable;
function SafeCallException(ExceptObject: TObject;
ExceptAddr: Pointer): HResult; virtual;
// ...
end;
四,对象释放服务
凡是声明为 destructor 的方法都属于析构函数,不管你用不用 Destroy 作为方法名称,但建议只用 Destroy 作为方法名称;还有一点要记住:由于在 Destroy 在 TObject 中声明为虚方法,因此派生类如果也声明析构函数的话,别忘了用 override 标记。关于 Destroy 为什么设为虚方法,李维在《Inside VCL》中解释得太差劲了,用了数页的篇幅去分析这个问题,但始终也没有讲到点子上,其实最主要的原因只有一个:析构函数经常被多态调用,而这时对象引用的声明类型可能与对象的实际类会有不同[4]。
对象释放服务其实就是对象创建服务的逆过程,可以认为对象释放服务就是回收对象在创建过程中分配的资源。
当编译器遇到 destructor 关键字通常会这样编码:首先调用 System._BeforeDestruction,而 System._BeforeDestruction 继而调用虚方法 BeforeDestruction, 在 TObject 中 BeforeDestruction 中只是个 Place Holder,你很少需要重载这个方法,重载这个方法通常只是为了与 C++ Builder 对象模型兼容。
这之后,编译器调用你在 Destroy 中真正写的代码,如果当前你在撰写的类是继承链上的一员,不要忘记通过 inherited 调用父类的析构函数以释放父类分配的资源,但规矩是,先释放当前类的资源,然后再调用父类的,这和对象创建服务中设置对象执行框架的顺序恰好相反。
当前类及继承链中所有类中分配的资源全部释放后,最后执行的就是释放掉对象本身及一些特别数据类型占用的内存空间。编译器调用 System._ClassDestroy 来完成这件工作。System._ClassDestroy 继而调用虚方法 FreeInstance,继承类通常不需要重载 TObject.FreeInstance,除非你使用自己的内存管理器,因此缺省是调用 TObject.FreeInstance。TObject.FreeInstance 继而调用 TObject.CleanupInstance 完成对于字符串数组、宽字符串数组、Variant、未定义类型数组、记录、接口和动态数组这些特别数据类型占用资源的释放[4],最后 TObject.FreeInstance 调用 MemoryManager.FreeMem 释放对象本身占用的内存空间。
很有意思的是,对象释放服务与对象创建服务所用方法、函数是一一对应的,是不是有一种很整齐的感觉?
对象创建服务
对象释放服务
System._ClassCreate
System._ClassDestroy
System._AfterConstruction
System._BeforeDestruction
TObject.AfterConstruction(virtual)
TObject.BeforeDestruction(virtual)
TObject.NewInstance(virtual)
TObject.FreeInstance(virtual)
TObject.InitInstance
TObject.CleanupInstance
MemoryManager.GetMem
MemoryManager.FreeMem
还有一点要注意,通常我们不会直接调用 Destroy 来释放对象,而是调用 TObject.Free,它会在释放对象之前检查对象引用是否为 nil。
相关 TObject 方法:
TObject = class
// ...
procedure Free;
procedure CleanupInstance;
procedure BeforeDestruction; virtual;
procedure FreeInstance; virtual;
destructor Destroy; virtual;
// ...
end;
procedure _ClassDestroy(Instance: TObject);
function _BeforeDestruction(Instance: TObject; OuterMost: ShortInt): TObject;
五,对象消息分派服务
消息分派服务通常认为是跟 Windows 平台紧密结合的,但在 TObject 这一根类中可以认为消息分派与平台无关,因为只要具有 MessageID 的消息 TObject.Dispatch 都可以正确分派,大大扩展了 Windows 对消息的定义,关于 Delphi 消息机制可以阅读我的另一篇文章《Delphi 的消息机制学习笔记》。
相关 TObject 方法:
TObject = class
// ...
procedure Dispatch(var Message); virtual;
procedure DefaultHandler(var Message); virtual;
// ...
end;
六,虚拟方法表格和动态方法表格
Delphi 中虚方法是通过类的虚拟方法表格(Virtul Method Table,简称 VMT)或者动态方法表格(Dynamic Method Table,简称 DMT)实现的。
VMT 中存放着类及其基类声明的所有虚方法的指针[4]。每个类都具有一个唯一的 VMT,并且是在编译期间就确定的,而不象李维在《Inside VCL》中所说是在第一个类对象创建时才会具体的创建在内存中(李维为什么会犯这样的低级错误?),所有由该类创建的对象都共享同一个 VMT。
VMT 中除了一个虚方法表外,还包括其他有关一个类的信息[4]。一个 VMT 能衍生出很多故事,这不是我这篇笔记所有容纳的,关于 VMT的详细内容建议大家配合文章最后所列参考文献进行学习。
DMT 是 Delphi 中实现虚方法的另一种方式。利用 DMT 可以有效减小 VMT 的体积,但执行效率稍差。很少有资料将 DMT 的执行过程解释得很清楚,但你会在我这篇笔记中得到答案,准备好了吗?跟我来吧!
DMT 中存有类所声明的动态方法和消息句柄,但并不包括从祖先类继承的方法。用关键字 dynamic 或者 message 声明的方法都会以动态方法实现。从 VMT + vmtDynamicTable 偏移处取出的 DWORD 值就是指向类的 DMT 指针。DMT 的结构在 Delphi 中是 Undocumented 的,但通过阅读 VCL 源码和 Debug 很容易写出 DMT 在内存中的逻辑布局:
type
TDynmethodTable = packed record
Count: Word;
Indexes: packed array[1..Count] of SmallInt;
Addresses: packed array[1..Count] of Pointer;
end;
假如我们声明了如下的类:
TMyObject = class
private
procedure DM1; dynamic;
procedure DM2; dynamic;
procedure WMCommand(var Message); message WM_COMMAND;
public
procedure DM3; dynamic;
end;
凡是带 dynamic 的关键字的方法都会被编译器按照在它们在类中声明的顺序赋予一个编号,或者说 ID,编号用一个 SmallInt 值表示,顺序为 $FFFF,$FFFE,$FFFD...当编译器遇到 message 时,方法的编号则用消息 ID 表示,例如 WM_COMMAND 代表 $0111,有了这些分析我们很容易把这个类的 DMT 按照上面的内存布局描述出来:
4 // Count =4,即包括4个动态方法
$FFFF // DM1 的编号
$FFFE // DM2 的编号
$0111 // WMCommand 的消息 ID
$FFFD // DM3 的编号
DM1 入口地址
DM2 入口地址
WMCommand 入口地址
DM3 入口地址
这些方法是如何被调用的呢?对于带 dynamic 关键字的方法是通过调用 System._CallDynaInst 实现的。通常这样:
mov eax,ebx ; eax = point to current object
mov si,$ffff ; si 为方法编号
call @CallDynaInst
而 System._CallDynaInst 继而会调用 System.GetDynaMethod 获得对应方法编号的方法入口地址,并直接跳到方法入口地址,执行该方法:
procedure _CallDynaInst;
asm
PUSH EAX
PUSH ECX
MOV EAX,[EAX] ;注意调用时传递的是 VMT
CALL GetDynaMethod
POP ECX
POP EAX
JE @@Abstract
JMP ESI ; ESI 指向动态方法入口地址
@@Abstract:
POP ECX
JMP _AbstractError
end;
System.GetDynaMethod 则在 DMT 中寻找该动态方法的编号,如果找到了,就从 DMT 中取得该方法的入口地址;如果找不到,则继续在父类的 DMT 中寻找该方法编号直至 TObject 的 DMT(TObject 中指向 DMT 的指针为 nil,循环到这里自然会停止):
procedure GetDynaMethod;
{ function GetDynaMethod(vmt: TClass; selector: Smallint) : Pointer; }
asm
{ -> EAX vmt of class }
{ SI dynamic method index }
{ <- ESI pointer to routine }
{ ZF = 0 if found }
{ trashes: EAX, ECX }
PUSH EDI
XCHG EAX,ESI
JMP @@haveVMT
@@outerLoop:
MOV ESI,[ESI]
@@haveVMT:
MOV EDI,[ESI].vmtDynamicTable
TEST EDI,EDI
JE @@parent
MOVZX ECX,word ptr [EDI]
PUSH ECX
ADD EDI,2
REPNE SCASW
JE @@found
POP ECX
@@parent:
MOV ESI,[ESI].vmtParent
TEST ESI,ESI
JNE @@outerLoop
JMP @@exit
@@found:
POP EAX
ADD EAX,EAX
SUB EAX,ECX { this will always clear the Z-flag ! }
MOV ESI,[EDI+EAX*2-4]
@@exit:
POP EDI
end;
这里多说一句,看 System.GetDynaMethod 的代码简直就像在欣赏一件艺术品,代码被 Borland 的工程师优化得无可挑剔,其中用到了不少技巧,你需要慢慢体会,总之好棒!
至于象 TMyObject 中类似 WMCommand 的方法通常是由 TObject.Dispatch 来进行消息派发的,原理完全同于上面的过程,只是在 DMT 中检索的是消息 ID 而不是方法编号。具体看 TObject.Dispatch 源码及我的《Delphi 的消息机制学习笔记》。
参考文献
1. 李维.《深入核心 -- VCL架构剖析》第二章,2004.1
2. savetime."Delphi 的对象机制浅探", Jan 2004
3. 李战.《悟透delphi》之第二章 --- 《DELPHI的原子世界》
4. Ray Lischner.《Delphi in a Nutshell》,2000