早在Delphi 3的时候就引入了接口的概念,当时完全是因为COM的出现而诞生的,但经过这么多版本的进化,Delphi的接口已经成为Object Pascal语言的一部分,我们完全可以用接口来完成我们的设计,而不用考虑与COM相关的东西。
那么接口在Delphi中是如何实现的呢,很多人想得很复杂,其实它的本质不过也是一些简单的数据结构和调用规则。笔者假设读者已经有接口的使用经验,本文试图向你展示接口在Delphi中的实现过程,使你在使用接口的时候,知其然而知其所以然。
接口在内存中的分布
接口在概念上并不是一个实体,它需要与实现接口的类关联,如果脱离了这些类,接口就变得没有意义了。但接口在内存中仍然有其布局,它依附在对象的内存空间中。
Delphi对象本质上是一个指向特定内存空间的指针,这块内存的前四个字节是一个指针指向类的VMT表,接下来排布对象的数据成员,如果对象实现了接口,则在后面又排着一系列指针,我们可以认为这些指针就是对应的接口,每个指针就指向一个接口方法表。我们来看一下简单的例子:
type
ITest1 = interface
['{5347BB0D-89B7-4674-A991-5C527BE6F8A8}']
procedure SayHello1;
end;
ITest2 = interface
['{567B86BB-711D-40C2-8E5E-364B742C2FF1}']
procedure SayHello2;
end;
TTest = class(TInterfacedObject, ITest1, ITest2)
public
procedure SayHello1;
procedure SayHello2;
end;
... ...
implementation
{ TTest }
procedure TTest.SayHello1;
begin
showMessage(IntToStr(FRefCount));
ShowMessage('Itest1 say hello');
end;
procedure TTest.SayHello2;
begin
ShowMessage(IntToStr(FRefCount));
ShowMessage('Itest2 say hello');
end;
end.
上面是两个接口的声明以及一个实现接口的类,TTest类在内存中的分布可以用下图来表示:
其中FRefCount为父类TInterfacedObject的一个成员,接下来存放的是TInterfacedObject实现的接口IInterface,再下来分别是TTest类实现的ITest2和ITest1指针。各个接口指针分别指向各自的方法表,注意ITest2和ITest1是从IInterface继承下来的,所以自然就有了IInterface的所有方法。方法表中每个指针指向方法真正实现的地方,其实这个说法只是暂时的,稍后会解释方法表中的指针真正指向的地方,并说明其原因。
上面的内存分布并非笔者随意想出来的,而是经过多次测试证实的,下面我们用一些代码来证实上面分布图:
var
test: Itest2;
begin
test := TTest.Create;
test.SayHello2;
end;
在证明接口的内存布局之前,需要了解接口的变量是个什么东西,比如上面的test是什么,它的本质上是一个指针,在没有被赋值之前,它指向空;而得到对象的赋值之后,它指向上面分布图中的Itest2处,对于同一个对象的多个接口变量来说,它们的“值”不一定是相等的,比如有下面的代码:
Var
Test1: ITest1;
Test2: ITest2;
Test: TTest;
Begin
Test := Ttest.Create;
Test1 := Test;
Test2 := Test;
If Integer(Test1) <> Integer(Test2) then
ShowMessage('it is not eqeual');
End;
最后,会弹出一个对话框,说明Test1和Test2是不相等的;只有属性同一种接口类型,这两个变量才会相等,比如Test1和Test2都是Iinterface,则他们的“值”是相等的。
好了,回过头来看看之前的代码片段吧,在第4行设置断点,运行程序并使上面代码执行,程序执行到断点处中止,按下Ctrl+Alt+C调用CPU窗口,可以看到下面的反汇编代码:
Unit1.pas.49: test := TTest.Create;
mov dl,$01
mov eax,[$00458e0c]; eax指向VMT的地址
call TObject.Create; 创建TTest对象,eax指向TTest对象的首地址
mov edx,eax; edx指向eax指向的地方,edx也指向TTest对象的首地址
test edx,edx; 测试TTest对象是否有效
jz +$03
sub edx,-$0c; 对象首地址偏移12个字节,到ITest2指针处
lea eax,[ebp-$04]; test变量的地址是ebp-04的值,eax指向这个地址
call @IntfCopy; 调用IntfCopy,将edx的值拷贝给eax,引用计数管理
Unit1.pas.50: test.SayHello2;
mov eax,[ebp-$04]; 将test指向的地址赋给eax,此时eax指向Itest2的地址
mov edx,[eax]; 将eax的内容赋给edx,此时edx指向ITest2指向的方法表
call dword ptr [edx+$0c]; 调用ITest2指向的方法表偏移12个字节处。
... ...
ret
sub edx,-$0c这一句,edx原来指向对象的内存空间,偏移12个字节刚好到哪里呢?刚好到ITest2接口指针处。接下来eax指向Test变量在栈中的地址,此时如果直接将edx赋值给eax在逻辑上也没有错,但这样就不能对接口进行引用计数的管理了。因此要调用IntfCopy,进行接口地址的赋值,再加上一个引用计数。
IntfCopy其实是调用System单元中的_IntfCopy,它的实现如下:
procedure _IntfCopy(var Dest: IInterface; const Source: IInterface);
{$IFDEF PUREPASCAL}
var
P: Pointer;
begin
P := Pointer(Dest); //保存Dest,无引用计数
if Source <> nil then
Source._AddRef; //增加Source的引用计数,即增加ITest2的引用计数
Pointer(Dest) := Pointer(Source); //将Source的值赋给Dest,无引用计数
if P <> nil then
IInterface(P)._Release; //减少目标接口的引用计数,但这里的P为空指针,所以不会调用这句
end;
此时的Dest参数是eax,亦即Test变量的地址,Source参数是edx,正好是对象内容空间中的ITest2的地址。我们看到其中只是对接口地址的拷贝,及增加接口的引用计数。如果Dest有内容,则减少它的引用计数,不过这里Dest为空,所以不会调用减少引用计数的代码。
接下来到call dword ptr [edx+$0c],edx指向ITest2指向的方法表首地址,而edx+$0c偏移到哪里呢,看看上面的内存图,正好到ISayHello2处。此时调用ISayHello2指向地址的代码,我们可以简单地认为就是调用TTest.SayHello2。但事实上却不是这样的,为什么?因为在调用SayHello2之前,要先指定eax的值为TTest对象的Self指针,以此作为隐含参数传进SayHello2。
我们可以到[edx+$0c]的地址看看,按F8将执行点执行到call dword ptr [edx+$0c]这一句,再按F7,跳到[edx+$0c]的地址,可以看到下面的反汇编代码:
add eax,-$0c; eax向上偏移12个字节正好是对象内存首地址。
jmp TTest.SayHello2; 跳到TTest.SayHello2处。
仔细看前面的汇编码,可以知道eax正好指向ITest2指针,向上偏移12个字节则好就到了对象内存的首地址。接着调用TTest.SayHello2完成。
通过上面的例子,不仅证明了接口在对象内存空间中的布局,还可以得出以下结论:
1. 一个实现特定接口的对象创建完之后赋给该接口,编译器作了一些工作,使得接口变量指向了对象内存中的某个特定地址。
2. 调用接口的方法时,实际上调用的是接口方法表中特定的地址,在该地址处编译器计算出实现该接口的对象内存首地址,再调用对象相应的方法。
联系客服