从普通函数到对象方法
------Windows窗口过程的面向对象封装
开始,由VirtualAlloc想起
我在查看VirtualAlloc这个API的时候,思绪竟然跳到另一个地方去了。那是以前阅读VCL源码时遗留下来的问题,Classes单元的MakeObjectInstance函数调用了VirtualAlloc,我甚是不解,为什么Delphi提供了那么多内存分配函数,而MakeObjectInstance偏偏要用系统提供的API,更令我不解的是,之后再也不见有VirtualFree的调用,也就是说,VCL其实存在内存泄漏?这个问题我在网上也看到相关的讨论,有人认为这的确是VCL的Bug,有人甚至修改了Classes单元,在单元的结束节处调用VirtualFree以释放以前分配的内存。
不过我对这个问题始终持保留态度,MakeObjectInstance是一个非常重要的函数,担负着窗口过程到对象方法的转换,Borland没有理由留着这个“Bug”不理。
于是我重新阅读了MakeObjectInstance这个倍受李维赞誉的函数,我想我这次是读懂了,为什么不调用VirtualFree,因为没有必要,进程在结束的时候会毫无保留的回收所有的内存,而经由VirtualAlloc分配到的内存就保留在由TInstanceBlock记录所组成的链表中,这个链表组成的内存并不是使用一次即弃掉的,它是可重用的,调用一次FreeObjectInstance,那张链表便空余出TObjectInstance大小的内存,以供下一次使用。所以,这其实是一个内存池,提供了更上一层的内存分配机制。而在结束的时候调用VirtualFree就显得没有任何必要了。
回到上面提出的第一个问题,为什么要调用VirtualAlloc,而不用Delphi提供的内存分配函数,如果没有看到System单元的这两个变量,我想永远也不可能找到答案:
var
AllocMemCount: Integer; { Number of allocated memory blocks }
AllocMemSize: Integer; { Total size of allocated memory blocks }
这两个变量的确是记录内存使用的总量,前提是你调用Delphi提供的内存管理函数,如果调用Windows原生的API,则VCL是没有办法感应到的。写到这里,再看看上面的描述,也许一切都了然了。
然而,这只是我写这篇文章的导火线,真正原因是我读懂了MakeObjectInstance,以前的许多疑惑已经拨云见日,窗口过程到对象方法的脉络在我的脑中从未有过这么清晰,因此欲罢不能,作此文记之。
使用,将窗口过程转成对象方法的步骤
从SDK的角度来讲,设置窗口过程有两种方法(我所能想到的),一是调用RegisterClass,另一个是调用SetWindowLong,第一种用在创建窗口的时候,另一种用在改变窗口过程的时候。在Delphi中,假设你写了一个自定义窗口类,那么你可以重载WndProc,这个方法就相当于窗口过程。可以确定,VCL在开始时肯定也是用上面所说的方法,设置窗口过程,只是后来经过一些转换,最终使窗口过程调用到对象实例的WndProc,所以WndProc可以当成窗口过程来使用。
这个转换的步骤从表面上看很简单,现在我们不必去深究其原理,只要知道通过下面的做法,就可以将一个窗口过程转成对象的方法。
首先,到Controls单元的TWinControl类,这是所有窗口的父类,转换过程就在这里面完成。TWinControl的构造函数中写了这一句:
constructor TWinControl.Create(AOwner: TComponent);
begin
... ...
FObjectInstance := Classes.MakeObjectInstance(MainWndProc);
... ...
end;
其中的MainWndProc就是代替窗口过程的对象方法。
接着,在InitWndProc有如下代码:
function InitWndProc(HWindow: HWnd; Message, WParam,
LParam: Longint): Longint;
Begin
... ...
SetWindowLong(HWindow, GWL_WNDPROC,
Longint(CreationControl.FObjectInstance));
... ...
end;
InitWndProc就是刚开始的窗口过程,而调用了SetWindowLong之后,窗口过程就转成了FobjectInstance了。而实际上最终得到调用是却是MainWndProc。
最后,在TWinControl的析构函数中还写了如下语句:
destructor TWinControl.Destroy;
begin
... ...
if FObjectInstance <> nil then
Classes.FreeObjectInstance(FObjectInstance);
... ...
end;
这是为了回收由MakeObjectInstance使用的内存,让这块内存可在下一次重用。
上面就是TWinControl的窗口过程到对象方法的转换步骤,这的确是很神奇的事情,它们在某些情况下是很有用的,比如TComboBox,在这个控件里面有一个用于编辑的Edit和一个用于下拉选择的ListBox,这两个控件是在ComboBox创建的时候一起创建的,VCL没有办法对它们进行封装,但有时候需要处理他们的消息,这时,上面的方法就派上用场了,事实上TComboBox就是运用上面的方法,将Edit和ListBox的窗口过程转换成TcomboBox内部的方法的,有兴趣者请查阅一下VCL。
对上面进行一次总结:
1、 假设你通过原生的API创建了一个窗口,如果你想让这个窗口的窗口过程被指定为一个类的方法,那么可以在类的内部调用MakeInstanceObject,传进类的一个方法(如上面的MainWndProc,当然这个方法必须是TwndMethod类型的),并保留函数返回的指针。
2、 调用SetWindowLong,用类保留的指针替换原来的窗口过程。到这里,窗口过程就被传进MakeInstanceObject的对象方法所代替了。
3、 在消毁这个类的实例时,别忘了调用FreeObjectInstance,并传回保留的指针。如果这时窗口还未消毁,还得用SetWindowLong恢复原来的窗口过程。
知道如何使用并不是我们的最终目的,我们要更进一步,为什么会是这样,请看下一节。
实现,窗口过程到对象方法的转换技术
窗口过程实际上是一个回调函数,向API传递函数的地址,Windows保留着这个函数地址,在适当的时候调用这个函数。那么对象方法与普通函数有什么不同呢,对于同一种调用规则来说,不同之处就是对象方法在第一个参数之前有一个隐藏的参数,这个参数就是对象的实例(如果是C++应该叫实例指针,而Delphi的对象实例就是一个指针,只已经为大多数人所共知的事实)。
另一方面,Windows的API使用的是Stdcall的调用规则,从机器指令的角度看,就是在Call某个函数之前,先将函数的参数从右向左地压栈。而Delphi为了提高效率,默认使用了Register调用规则,粗略的讲就是从左向右传递参数,且前三个参数分别放在EAX,EDX,ECX寄存器中,其后则依次入栈。若要知道详细的规则,请查看Delphi的帮助主题:Calling conventions。
现在,如果我们想让窗口过程流入某个对象的方法,要解决两个问题:
1、 在进入对象方法的入口时,先将对象实例作为第一个参数传入,其次再将窗口过程的参数依次传入。对于Register调用规则来说,就是将对象实例赋值给EAX,再将其他参数按照规则赋给相应的寄存器或者压栈。
2、 Stdcall规则到Register规则的转换,这个不是必须的,因为Delphi也支持StdCall规则,但对Register规则来说效率更高,另一方面Delphi对Register规则作了更多的支持,比如Published的属性就只能指定Register规则的方法。
现在让我们围线着这两个问题开始探索VCL是如何做的。
VCL在开始的时候同样要遵守Win32的做法,首先填充一个窗口类结构然后注册窗口类,注意TWinControl.CreateWnd中的这一句:
WindowClass.lpfnWndProc := @InitWndProc;
它将窗口过程指定为InitWndProc函数。
接下来就创建窗口类,在TWinControl.CreateWindowHandle中:
FHandle := CreateWindowEx(ExStyle, WinClassName, Caption, Style,
X, Y, Width, Height, WndParent, 0, WindowClass.hInstance, Param);
现在来看,一切都似乎正常,但其实在调用CreateWindowEx的时候,事情正在稍稍发生变化。CreateWindowEx的时候系统将发送(请注意是发送而不是投递)一个WM_CREATE消息给窗口,处理这个消息的是谁呢,正是上面看到的InitWndProc。
有必要看一下这个函数的代码,我顺便作了详细的注释:
01functionInitWndProc(HWindow:HWnd;Message,WParam,
02LParam:Longint):Longint;
03Begin
04//CreationControl就是窗口类,TWinControl在CreateWnd的时候将Self赋给它
05//由此可以看到VCL的窗口类是非线程安全的。
06CreationControl.FHandle:=HWindow;
07//重设窗口过程,从此之后,这个函数再也不会得到调用了
08SetWindowLong(HWindow,GWL_WNDPROC,
09Longint(CreationControl.FObjectInstance));
10if(GetWindowLong(HWindow,GWL_STYLE)andWS_CHILD<>0)and
11(GetWindowLong(HWindow,GWL_ID)=0)then
12 SetWindowLong(HWindow,GWL_ID,HWindow);
13//设置该窗的一些属性,与我们讨论的无关,可不去理会它们
14SetProp(HWindow,MakeIntAtom(ControlAtom),THandle(CreationControl));
15SetProp(HWindow,MakeIntAtom(WindowAtom),THandle(CreationControl));
16//主动调用一次FobjectInstance
17asm
18PUSHLParam
19PUSHWParam
20PUSHMessage
21PUSHHWindow
22MOVEAX,CreationControl
23MOVCreationControl,0
24CALL[EAX].TWinControl.FObjectInstance
25MOVResult,EAX
26end;
27end;
第6行对窗口类的Fhandle进行赋值,这么做是必要的,因为正常情况下Fhandle只有到CreateWindowsEx返回之后才能得到赋值,在这个函数调用的过程中,系统发送WM_CREATE消息给窗口,在外部,我们可以得到WM_CREATE的处理器进行处理,如果没有第6行的赋值,则那时我们将没有办法得到窗口句柄。我想这也是InitWndProc存在的原因之一。
第8行重新设置窗口过程,设置为窗口类的FobjectInstance,从此以后,窗口消息只会流到FobjectStance指向的地方,这个函数也就作废了。
而接下来是一段汇编代码,主要的意思是调用FobjectInstance,18到21行传递参数(还记得STDCALL规则吗),然后24行调用FobjectInstance。这段汇编就相当于这样的语句:
WinControl := CreationControl;
CreationControl := nil;
Result := TThunkProc(WinControl.FObjectInstance)(HWindow, Message, WParam, LParam);
其实这正是Linux版下面的做法。
在这里我想说一下CALL指令,理解它的行为,对下文是很有帮助的,CALL指令可以分解为两个动作:先将下一条指令的地址(EIP)压栈,然后跳转到操作数指定的地址去。与CALL对应的是RET指令,这个指令其实就是从栈顶弹出一个值,然后跳转到这个值指明的地址去。这就是函数的原理,在函数内部,维持堆栈的平衡是非常重要的,你必须保证在RET的时候弹出来的值正是CALL的时候压入的值,这样才能正确返回到CALL指令的下一条指令的地址,要不然执行点就不知跳到哪里去了?当然使用高级语言不用去关心这些东西,但理解堆栈的知识仍然是非常有用的。
在InitWndProc完成它的历史命令之后,我们可以把目光关注到FobjectInstance这个指针去,现在它就是新的窗口过程,但是它到底指向了什么东西呢,答案就在前面看到的MakeObjectInstance中,我们要去详细的分解这个函数的代码,不过之前我要从总体上说一下这个过程:
FobjectInstance指向一块由MakeObjectInstance分配好的内存,这块内存存放的是一段机器指令,这段机器指令其实也是在MakeObjectInstance写入的,当FobjectInstance得到调用时,就执行了那段指令,这段指令的任务是将对象方法(这个方法就是传入MakeObjectInstance的那个参数,即MainWndProc)存放在ECX,然后跳转到StdWndProc去,StdWndProc从ECX取出MainWndProc,并从这个方法中得到对象实例(对象方法其实是一个地址和一个对象实例的组合,详情请看TMethod帮助),然后构造出一个Tmessage的结构,最后调用MainWndProc,流程完毕。
为了让读者有一个总体的认知,我画了下面的流程图:
从上面的分析看,至少有这么几个元素对转换过程起着至关重要的作用:
MakeObjectInstance函数
FObjectInstance以及其指向的内存
StdWndProc函数
现在我们就来详细解析它们。
在TWinControl的构造函数中调用了MakeObjectInstance,并传入TWinControl的一个方法:MainWndProc。MakeObjectInstance的代码是这样的:
01functionMakeObjectInstance(Method:TWndMethod):Pointer;
02const
03//机器指令
04BlockCode:array[1..2]ofByte=(
05$59,{POPECX}
06$E9);{JMPStdWndProc}
07PageSize=4096;
08var
09Block:PInstanceBlock;
10Instance:PObjectInstance;
11Begin
12//InstFreeList指向一个TObjectInstance记录,这个记录是当前可用的
13ifInstFreeList=nilthen
14begin
15//如果InstFreeList为空,就再创建4K的内存,这个内存格式化为一个
16//TinstanceBlock结构。
17Block:=VirtualAlloc(nil,PageSize,MEM_COMMIT,PAGE_EXECUTE_READWRITE);
18Block^.Next:=InstBlockList;
19//对新创建的4K内存进行初始化
20Move(BlockCode,Block^.Code,SizeOf(BlockCode));
21Block^.WndProcPtr:=Pointer(CalcJmpOffset(@Block^.Code[2],@StdWndProc));
22//TinstanceBlock里面含有313个TobjectInstance记录,对这些记录进行初始化
23Instance:=@Block^.Instances;
24repeat
25Instance^.Code:=$E8;{CALLNEARPTROffset}
26Instance^.Offset:=CalcJmpOffset(Instance,@Block^.Code);
27Instance^.Next:=InstFreeList;
28InstFreeList:=Instance;
29Inc(Longint(Instance),SizeOf(TObjectInstance));
30untilLongint(Instance)-Longint(Block)>=SizeOf(TInstanceBlock);
31InstBlockList:=Block;
32end;
33//将可用的TobjectInstance块返回,并让InstFreeList指向下一个可用的块
34Result:=InstFreeList;
35Instance:=InstFreeList;
36InstFreeList:=Instance^.Next;
37//将MainWndProc保存在这里
38Instance^.Method:=Method;
39end;
这个函数一个非常重要的任务就是管理一个链表,这个链表的每一项有4096字节大小,每一项可以认为是一个TinstanceBlock结构(实际上TinStanceBlock只有4092字节,即最后4个字节是没有用的)。这个链表会随着MakeObjectInstance 的调用而增加链表项,但是不会被释放,到进程结束时由操作系统回收。InstBlockList变量指向这个链表头,可以用下图来表示:
每一个TinstanceBlock的结构是这样的:
PInstanceBlock = ^TInstanceBlock;
TInstanceBlock = packed record
Next: PInstanceBlock; //下一个块
Code: array[1..2] of Byte; //机器码
WndProcPtr: Pointer; //指针,相当于操作数
Instances: array[0..InstanceCount] of TObjectInstance;//314个记录数组
end;
Code和WndProcPtr一起组成了一段机器指令,请回头看看第20和21行,最后Code和WndProcPtr成员一起组成了类似下面这样的指令:
POP ECX
JMP Offset
上面的Offset是另有用意的,它等于WndProcPtr,而Jmp的结果是跳到StdWndProc的入口点去,为什么能够这样呢,请看第21行:
Block^.WndProcPtr:=Pointer(CalcJmpOffset(@Block^.Code[2],@StdWndProc));
CalcJmpOffset函数如下
function CalcJmpOffset(Src, Dest: Pointer): Longint;
begin
Result := Longint(Dest) - (Longint(Src) + 5);
end;
StdWndProc的地址减去Code[2]的地址与5的和,为什么Code[2]还要加上5,才能被StdWndProc的地址减呢,原因是Code[2]等到$E9,后面跟一个地址(可以是绝对地址也可以是相对地址,这里是使用相对地址)就形成了一条JMP指令,$E9占一个字节,地址是一个指针占了4个字节,所以这条指令占用了5个字节,所以Code[2]要和5加后,被StdWndProc的地址减去后才能得到一个正确的相对地址(其实也就是StdWndProc的地址到JMP指令的距离)。
接下来的Instances是一个数组,共有314个,数组的每一项是一个TobjectInstance记录:
PObjectInstance = ^TObjectInstance;
TObjectInstance = packed record
Code: Byte; //机器码
Offset: Integer; //偏移,操作数
case Integer of
0: (Next: PObjectInstance); //可能是指向下一个记录
1: (Method: TWndMethod); //也可能存放一个方法类型
end;
Code和Offset也组成了一条机器指令,请看第25,26行,这条指令相当于:
CALLNEARPTROffset
Offset也是通过CalcJmpOffset计算得到的,它指定当前地址到Block的Code处的偏移,也就是调用ObjectInstance所在的InstanceBlock的Code处的代码。另外,请注意这里是使用CALL而不是JMP,这是有特殊的含意的,你不妨可以思考一下,稍后我会作解释。
接下来是一个变体,有可能是Next指向下一个记录,也有可能是一个TwndMethod的变量。看MakeObjectInstance的代码,在初始化Block块的时候,是将数组中所有项都设成Next的。这样看来,当一个InstanceBlock新生成时,这个Instances数组也可以当成一个链表了,从第28行可以看出,有一个变量InstFreeList,就指向了这个表头。
但在34行下面的几句代码,返回了InstFreeList,并将这个记录指针的Next变成了Method,将传进来的参数Method赋给它,最后InistFreeList指向下一个ObjectInstance。这样看来,一个InstanceBlock是否已经用完取决于它里面的Instances数组,如果所有ObjectInstance的最后一个成员是Method,那么表示这个块已经用完了,相反如果是Next则表示还有ObjectInstance可用。
<p class
分享到:
相关推荐
使用说明如下:函数声明和调用方法与DLL命令一致;“库文件名”以.lib或.obj为后缀的将被视为静态库,可使用绝对路径或相对路径(相对当前源代码所在目录),如依赖多个静态库请分别列出并以逗号分隔;“在库中的...
� 暂不具备 Push Mail 和 Office(DataViz 、 QuickOffice 计划近期推出 ) 功能,目前主要面向的是普通消费 者 用户,对商业用户支持尚弱。 Android Android Android Android 带来的影响 ANDROID 的推出后可能影响的...
类的集合,是一套面向对象的函数库,以类的方式提供给用户使用 2、MFC AppWizard是一个辅助我们生成源代码的向导工具,它可以帮助我们自动生成基于MFC框架的源代码 二、基于MFC的程序框架剖析 1、MFC程序的ClassView...
NEAT 程序一般执行过程 ..................................................................................................................... 20 第四章 窗口 ................................................
2.13 在PowerBuilder 9.0中实现面向对象编程 58 2.13.1 创建对象 58 2.13.2 设置对象属性 59 2.13.3 编写事件代码 59 2.13.4 实现继承对象 60 2.14 用户对象和用户事件 61 2.14.1 用户对象分类 61 2.14.2 ...
②在普通窗口中,直接创建一个CXPButton类对象,然后在OnCreate()中调用CXPButton的Create方法; 以下的章节将综合地使用以上的方法,请读者朋友留心观察。 3.2 使用MFC类的既有函数 在界面美化的专题中,MFC也...
第6章 面向对象编程 217 引言 217 6.1 温标的转换 223 6.2 定义常量 225 6.3 限制属性的设置 227 6.4 链式字典查询 229 6.5 继承的替代方案-自动托管 231 6.6 在代理中托管特殊方法 234 6.7 有命名子项的...
面向对象的思想方法已经非常流行了,在编程语言(例如java,js)中,都运用面向对象的编程思想。在XML中,就是要将网页也作为一个对象来操作和控制,我们可以建立自己的对象和模板。与对象进行交流,如何命令对象,...
6.1.2 面向过程与面向对象思想的对比 78 6.1.3 面向对象技术的背景和特点 79 6.2 类的定义与对象的创建 80 6.3 成员变量 81 6.3.1 成员变量的开发与使用 81 6.3.2 成员变量的初始值 82 6.3.3 对象引用...
第九章 面向对象的程序设计 .101 9.1 面向对象的基本概念.101 9.2 对象的模型技术 .103 9.3 面向对象的分析 .105 9.4 面向对象的设计 .107 9.5 小 结 .110 第十章 类 .112 10.1 类 的 声 明 .112 ...
│ │ └[北京思库教育]第57集 面向对象编程(OOP).avi │ ├ │ │ ├[北京思库教育]第58集 构造方法.avi │ │ ├[北京思库教育]第59集 clone及静态方法.avi │ │ ├[北京思库教育]第60集 常量继承.avi │ │ └...
11.5.3 DNS将名字映射到IP地址及反 序操作 207 11.5.4 前区和反区必须保持同步 207 11.5.5 HUP信号和重启 207 11.5.6 IN-ADDR.ARPA域 207 11.5.7 主机命名方案 208 11.5.8 配置DNS客户:/etc/resolv.conf 208 ...