`
美丽的小岛
  • 浏览: 298870 次
  • 性别: Icon_minigender_1
  • 来自: 大连
社区版块
存档分类
最新评论

Delphi接口的底层实现<转>

 
阅读更多

声明:转自http://blog.csdn.net/linzhengqun/article/details/1520455

引言

       接口是面向对象程序语言中一个很重要的元素,它被描述为一组服务的集合,对于客户端来说,我们关心的只是提供的服务,而不必关心服务是如何实现的;对于服务端的类来说,如果它想实现某种服务,实现与该服务相关的接口即可,它也不必与使用服务的客户端进行过多的交互。这种良好的设计方式已经受到很广泛的应用。

    早在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类实现的ITest2ITest1指针。各个接口指针分别指向各自的方法表,注意ITest2ITest1是从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;

最后,会弹出一个对话框,说明Test1Test2是不相等的;只有属性同一种接口类型,这两个变量才会相等,比如Test1Test2都是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.      调用接口的方法时,实际上调用的是接口方法表中特定的地址,在该地址处编译器计算出实现该接口的对象内存首地址,再调用对象相应的方法。

 

接口内存空间的形成

       上节说明了接口在对象内存空间中的分布,但对象内存空间是在运行时生成的,那么接口的内存空间是如何生成的呢,这一节将阐述之。

    在此之前,让我们再回到上面的对象内存图,对象内存的首地址是一个指针,指向一张VMT表,而Delphi的类其实也是一个指针,这个指针正好也指向VMT表。类是在编译时就确定下来的,VMT表当然也是编译器生成的。

    VMT表在负偏移vmtIntfTable-72)字节处是一个指针,它指向下面的数据结构:PInterfaceTable = ^TInterfaceTable;

TInterfaceTable = packed record

  EntryCount: Integer;

  Entries: array[0..9999] of TInterfaceEntry;

end;

EntryCount表示对象实现的接口数。

Entries是一个指向TInterfaceEntry结构的数组,TInterfaceEntry表示了一个接口的进入点,它的声明如下:

PInterfaceEntry = ^TInterfaceEntry;

TInterfaceEntry = packed record

  IID: TGUID;

  VTable: Pointer;

  IOffset: Integer;

  ImplGetter: Integer;

end;

IID表示接口的GUID,如果接口没有指定GUID,则它里面的值全为0

VTable指向接口的方法表。

IOffset指明接口与对象首地址的偏移。

ImplGetter是一个方法指针,当IOffset不可用时指向接口的地址,一般不用,初始化为0

    上面的数据结构在编译期就生成了,那么当一个对象创建时,相应的接口内存是如何生成的呢。在对象创建完毕之后,会调用TObejct.InitInstance(Instance: Pointer)类方法初始化对象的数据。看其代码:

class function TObject.InitInstance(Instance: Pointer): TObject;
{$IFDEF PUREPASCAL}
var
  IntfTable: PInterfaceTable;
  ClassPtr: TClass;
  I: Integer;
begin
//将对象全部清0
  FillChar(Instance^, InstanceSize, 0);
//指定首地址为Self,即指向VMT的指针
  PInteger(Instance)^ := Integer(Self);
  ClassPtr := Self;
  //建立对象的接口内存分布
  while ClassPtr <> nil do
  begin
    //取得接口表
    IntfTable := ClassPtr.GetInterfaceTable;
    if IntfTable <> nil then
      for I := 0 to IntfTable.EntryCount-1 do
      with IntfTable.Entries[I] do
      begin
        if VTable <> nil then
        //对象偏移IOffset处,设定为指向VTable的指针  
        PInteger(@PChar(Instance)[IOffset])^ := Integer(VTable);
      end;
    //继续建立其父类的接口内存内存
    ClassPtr := ClassPtr.ClassParent;
  end;
  Result := Instance;
end;

我们看PInteger(@PChar(Instance)[IOffset])^ := Integer(VTable)这一句,@PChar(Instance)[IOffset]是对象偏移IOffset的地址,而IOffsetIntfTable.Entries[I]IOffset,这个值在编译期就指定了,是接口到对象的偏移值。所以,经过上面方法调用之后,对象的内存空间就如同前面所画一样了。

    现在我们对接口在内存的来龙去脉已经了如指掌,可以利用这些知识来实现一些非常的功能了。在我们的经验中,对象生成之后可以直接赋给一个接口,编译器会自动将指针偏移到接口处。但如果反过来,将一个接口赋给一个对象却是不允许的,因为信息不足啊,任何类都可以实现这个接口,编译器并不知道这个接口是由那个类实现的,所以就无从转换了。如果我们提供一个现实该接口的类,再根据该类的VMT中的接口信息,就可以得到IOffset了,如此一来不就可以偏移到对象的首地址了吗,下面的例程可以从一个接口得到实现该接口的对象,前提是必须提供实现这个接口的类:

function GetObjFromIntf(AClass: TClass; const Intf: IInterface): TObject;
var
  PIntfTable: PInterfaceTable;
  IntfEntry: TInterfaceEntry;
  i: Integer;
begin
  Result := nil;
  //取得接口表结构
  PIntfTable := AClass.GetInterfaceTable;
  if PIntfTable = nil then Exit;
  while AClass <> nil do
  begin
    for i := 0 to PIntfTable^.EntryCount - 1 do
    begin
      IntfEntry := PIntfTable^.Entries[i];
      //判断接口表指向的地址是否和传入接口指向的地址相同
      if PPointer(Intf)^ = IntfEntry.VTable then
      begin

//偏移到对象首地址
        Result := TObject(Integer(Intf) - IntfEntry.IOffset);
        Exit;
      end;
    end;
    //继续在父类中找
    AClass := AClass.ClassParent;
  end;
end;

看下面例子:

var
  Intf: Itest2;
  Obj: TTest;
begin
  Intf := TTest.Create;
  Intf.SayHello2;
  Obj := TTest(GetObjFromIntf(TTest, Intf));
  Obj.SayHello1;
end;

执行上面代码,先弹出Hello2的对话框,再弹出Hello1的对象,说明GetObjFromIntf函数执行成功,我们实现了从接口到对象的转换过程。

 

接口的引用计数

       上面接口的内存空间与COM的接口在二进制上是兼容的,即接口就是一个指向VTable的指针,与COM兼容的还有另一个特性,就是通过引用计数自动管理COM对象的生命周期。C++程序员必须手工去管理引用计数的增减,而Delphi编译器帮我们做了这些事情,因为引用计数是有规律,只要遵循这些规律,便能自动管理引用计数的增减。IInterface的声明如下:

IInterface = interface

    ['{00000000-0000-0000-C000-000000000046}']

function QueryInterface(const IID: TGUID; out Obj): HResult; stdcall;

function _AddRef: Integer; stdcall;

function _Release: Integer; stdcall;

end;

任何实现IInterface的类都必须实现上面三个方法,其中的_AddRef_Release就是实现引用计数管理的。Delphi提供了IInterfaceObject类默认实现Interface,它声明一个成员FRefCount: Integer指定引用计数,_AddRef被调用时只是将FRefCount1

Result := InterlockedIncrement(FRefCount);

_Release被调用时,减少FRefCount,如果FRefCount0时,即调用Destroy消毁自己:

    Result := InterlockedDecrement(FRefCount);

    if Result = 0 then

      Destroy;

    如果即想实现接口,而不想通过引用计数管理生命周期的,可以在AddRefRelease中简单地将结果返回为-1即可,TComponent类即是如此。

    那么Delphi是如何实现接口引用计数的管理的呢,有下面的规律:

1.      当一个非空的接口变量要赋值给另一个接口变量时,非空的接口变量应该要调用AddRef

2.      当一个非空的接口变量要被另一个接口变量赋值时,非空的接口变量应该要调用Release

3.      如果你对于接口的引用计数有足够了解的话,有些AddRefRelease可以被优化掉。

 

对于第一种情况,在上节中已经有描述,看_CopyIntf的代码。对于第二种情况,在有接口变量声明及应用的例程中,编译器会在例程结束处调用_IntfClear,代码如下:

function _IntfClear(var Dest: IInterface): Pointer;
{$IFDEF PUREPASCAL}
var
  P: Pointer;
begin
  Result := @Dest;  
  if Dest <> nil then
  begin
    P := Pointer(Dest);//先保存接口
    Pointer(Dest) := nil;//将接口清空
    IInterface(P)._Release;//调用原接口方法,减少引用计数
  end;
end;

由上可见,我们不能随意调用_AddRef_Release,不然将会打乱接口的引用计数,像上面的代码,只是调用了一下_Release,如果对象的引用计数不为0,则它是不会被释放的。  

关于接口的引用计数,交给编译器去管理就行了,我们只要遵循一些规则,就可以灵活地使用接口进行程序的设计了。

 

接口的转换

    接口的另一个特性是:被一个类所实现的多个接口应该是可以互相转换的。方法是调用QueryInterface(const IID: TGUID; out Obj): HResult;

    对于这个特性的实现,我不想在这里罗嗦,实际上只要理解了第一部分和第二部分,这个特性是很容易推断出怎样实现的,更何况源代码就在那儿,何不给自己一个练习的机会呢?

分享到:
评论

相关推荐

    Delphi接口的底层实现 .mht

    Delphi接口的底层实现 .mht

    Boost ASIO for delphi

    不过这些库由于底层实现的方式都不能很好的支持大规模应用,由于后续支持的问题也不能支持DELPHI的新unicode版本。 C++由于有类似ACE、ASIO等等工业级的高性能稳定牛库的支持,c++的使用者们可以轻轻松松搞定很多...

    Delphi并口操作实例

    Delphi并口操作实例 Delphi调用inpout32.dll控制...由于安全原因在windows对计算机硬件的操作都得使用系统接口,比较繁琐。从网上找到这个inpout32.dll把这些繁琐的底层操作都封装好了,可以很方便的访问计算机并口。

    USB2.0接口感应式IC卡读写器二次开发的例程

    1. 真正USB2.0接口,而不是一般的USB转串口的模式,通讯稳定快速。 2. 由电脑USB接口提供稳定的5V电话,无需外接电源,并内置电源保护。 3. 独立自主的底层程序,可控性强,读写可靠。 4. 强大的功能,全面的程序接口...

    SkinSE3.1 For Delphi不完全破解版

    可实现360安全卫士界面的好工具,支持png格式、透明、窗体阴影等功能。 由于技术有限,该组件偶尔还是会跳出提示窗口,但提示内容已改为温馨提示,不影响一般使用。如果用于开发大型软件,请购买正版。 SkinSE是一...

    CDS 工控接口控件OPC 西门子驱动与注册应用

    如今,数字化转型,机器学习,人工智能等领域正在广泛应用,但L1L2底层数据目前还是与外部数据隔开的,需要编写接口程序对接L3L4L5层,才能实现这些信息打通,数据互通等等,DELPHI编写CDS接口通过OPC读数生产业务...

    一个多线程文件拷贝工具的实现(使用内存映射文件)

    一个多线程的文件拷贝工具的实现,文件操作接口使用了内存映射模型的方式实现,可以指定线程数量,可以在拷贝过程中查看整体的进度信息(进度、速度、剩余时间、已用时间),同时可以查看每个子线程对应的进度信息。...

    windows网络通信引擎c/c++

    主要提供了网络与通信引擎和网络中间件...此引擎不光封装了高性能API函数,还封装了底层网络IO和网络应用与协议相关接口,你可以使用此SDK快速部署与开发大型或者中小型应用服务器以及其他与网络和通信相关的应用程序。

    命名管道类

    命名管道是通过网络来完成进程之间的通信的,命名管道依赖于底层网络接口, 其中包括有 DNS 服务,TCP/IP 协议等等机制,但是其屏蔽了底层的网络协议细节, 对于匿名管道而言,其只能实现在父进程和子进程之间进行...

    C#微软培训资料

    &lt;&lt;page 1&gt;&gt; page begin==================== 目 目目 目 录 录录 录 第一部分 C#语言概述.4 第一章 第一章第一章 第一章 .NET 编 编 编程语言 程语言编程语言 程语言 C#.4 1.1 Microsoft...

    VC 结合Java编程的实例.rar

    一个软件为了快速开发,可能是使用Delphi或VB作为界面开发首选语言,底层的指令或核心算法,会使用C/C 处理,涉及数据处理的时候,为了安全和快速开发,会使用Javascript或Python等脚本语言实现数据分析处理。...

    通用报表引擎

    Demo展示的报表设计器由于时间比较仓促部分底层已实现的基本功能还没能集成进去, 所以此Demo程序仅能展示部分功能。同时由于方便打包及展示,我特意将报表底层部件静态编译到了设计器中。这样演示的 时候作为一个...

    高等学校教材管理系统的设计与实现_

    它不要求用户指定对数据的存放方法,也不需要用户了解具体的数据存放方式,所以具有完全不同底层结构的不同数据库系统可以使用相同的SQL语言作为数据输入与管理的 接口。它以记录集合作为操作对象,所有SQL语句接受...

    地图符号设计软件及动态库调用源码2.0

    图形符号的显示在底层设计时采用了图形硬件加速和大量优化算法,大大提升了图形显示速度和显示质量,能够实现无闪烁的连续放大。2.0版点符号编辑软件增加了字符图形导入的功能,动态库接口部分并且增加了多窗口显示...

    人性化自助式计算机实验室管理系统

    系统底层采用汇编语言,在WINDOWS平台下采用优秀的前端开发工具Delphi7.0。服务层采用SQL Server数据库及XML和OPENXML技术。对数据采用集中式和分布式处理相结合的策略,充分利用分布式数据库的优点,结合本地暂存、...

    意天原始磁盘数据操作开发包(磁盘扇区读写组件)

    Delphi开发人员可以很方便的进行 磁盘 扇区读写 操作,以完成读写(按扇区模式或字节模式)底层 磁盘数据 工作,该组件完全解决VISTA下Win32API无法直接写 磁盘数据 的问题,用该开发包在VISTA下也可以进行 磁盘 扇区读写...

    ADO.Net完全攻略(PDF中文版)

     开发人员在使用ADO时,其实就是在使用OLE DB,不过OLE DB更加接近底层。ADO的一项属性远程数据服务,支持“数据仓库”ActiveX 组件以及高效的客户端缓存。作为ActiveX的一部分,ADO也是COM组件的一部分。ADO是由...

    winner30setup4mock.rar

    盈佳期货交易终端是基于综合交易平台接口(CTP-API)开发的标准期货交易终端...9. 盈佳期货交易终端还提供可供非c++语言调用的COM接口,熟悉VB、delphi甚至excel编程的用户也可以基于该COM接口实现期货交易策略的开发。

    vc++ 应用源码包_1

    利用Delphi的代码在VC中显示JPG图片,不使用动态连接库。 Mail_Report.zip 一个邮件报告程序。 SrcFirstProg.zip 解释了最基本的MFC程序流程。 tabcontrol_demo.zip tabcontrol_src.zip 自定义的标签控件对话框...

Global site tag (gtag.js) - Google Analytics