Delphi 的接口机制浅探

目 录
 
===============================================================================
 
 ⊙ 接口的引用计数管理
 
    接口指针总是被初始化为 nil
 
    接口指针赋值为对象
 
    接口指针赋值为接口指针
 
    接口引用计数使用规则小结
 
 ⊙ 接口对象的编译器实现
 
    接口对象的内存空间
 
    接口跳转表
 
    对象内存空间中接口跳转指针的初始化
 
 ⊙ implements 的实现
 
    以接口成员变量实现 implements
 
    以对象成员变量实现 implements
 
===============================================================================
 
  
 
本文排版格式为:
 
    正文由窗口自动换行;所有代码以 80 字符为边界;中英文字符以空格符分隔。
 
  
 
(作者保留对本文的所有权利,未经作者同意请勿在在任何公共媒体转载。)
 
  
 
  
 
正 文
 
===============================================================================
 
⊙ 接口的引用计数管理
 
===============================================================================
 
  
 
---------------------------
 
接口指针总是被初始化为 nil
 
---------------------------
 
接口是生存期自管理对象,即使是局部接口指针,也总是被初始化为 nil。接口指针被初始化为 nil 是很重要的,从下文中 Delphi 生成维护接口引用计数的代码时可以看到这一点。
 
  
 
当接口与一个对象连接时,编译器会执行一些特殊的代码维护接口对象的引用计数。例如以下代码:
 
  
 
  var
 
    MyObject: TMyObject;
 
    MyIntf, MyIntf2: IInterface;
 
  begin
 
    MyObject := TMyObject.Create;  // 创建 TMyObject 对象
 
    MyIntf  := MyObject;           // 将接口指向 MyObject 对象
 
    MyIntf2 := MyIntf;             // 接口指针的赋值
 
  end;
 
  
 
-------------------
 
接口指针赋值为对象
 
-------------------
 
当执行到 MyIntf := MyObject 语句时,编译器的实现是:
 
  1. 如果 MyObject <> nil,则设置一临时接口指针 P 指向 MyObject 对象内存空间中
 
     的“接口跳转表”指针(后面会分析“接口跳转表”);否则 P := nil
 
  2. 执行 System.pas 中的 _IntfCopy(MyIntf, P) 操作,进行引用计数管理;
 
  
 
  { System.pas }
 
  procedure _IntfCopy(var Dest: IInterface; const Source: IInterface);
 
  var
 
    P: Pointer;
 
  begin
 
    P := Pointer(Dest);           // 保存目的接口指针,用于后面的 Release 调用
 
    if Source <> nil then         // 源接口指针增加引用计数
 
      Source._AddRef;
 
    Pointer(Dest) := Pointer(Source); // 目的接口指针赋值为源接口指针
 
    if P <> nil then                  // 原目的接口指针减少引用计数
 
      IInterface(P)._Release;
 
  end;
 
  
 
_IntfCopy 的代码比较简单,就是增加 Source 接口对象的引用计数,减少被赋值的接口对象的引用计数,最后把源接口指针赋值至目标接口指针。(其中还有源接口指针为 nil 的情况,看源代码比我说得还要清楚)
 
  
 
-----------------------
 
接口指针赋值为接口指针
 
-----------------------
 
对于两个接口指针的赋值的情况,如MyIntf2 := MyIntf,这时比 MyIntf := MyObject 的情况要简单一些,编译器不需要进行对象到接针的转换工作,这时真正执行的代码是:_IntfCopy(MyIntf2, MyIntf)。
 
  
 
-----------------
 
接口对象清除工作
 
-----------------
 
在一个过程(procedure/function)执行结束时,编译器会生成代码减少接口对象的引用计数。编译器使用接口指针为参数调用 _IntfClear 函数,_IntfClear 函数的作用是减少接口对象的引用计数并设置接口指针为 nil
 
  
 
  { System.pas }
 
  function _IntfClear(var Dest: IInterface): Pointer;
 
  var
 
    P: Pointer;
 
  begin
 
    Result := @Dest;
 
    if Dest <> nil then
 
    begin
 
      P := Pointer(Dest);
 
      Pointer(Dest) := nil;
 
      IInterface(P)._Release;
 
    end;
 
  end;
 
  
 
-------------------------
 
接口引用计数使用规则小结
 
-------------------------
 
根据以上代码及分析,我们可以小结过程(procedure/function)中的接口引用计数使用规则:
 
  
 
  1. 一般不需要使用 _AddRef/_Release 函数设置接口引用计数;
 
  2. 可以将接口赋值为接口或对象,Delphi 自动处理源/目标接口对象的引用计数;
 
  3. 如果要提前释放接口对象,可以设置接口指针为 nil,但不要调用 _Release。
 
     因为 _Release 不会把接口指针设置为 nil,最后 Delphi 自动调用 _IntfClear
 
     时会出错;
 
  
 
下面我们看看将接口指针作为参数传送时的情况:
 
  
 
  1. 以 var const 方式传递接口指针时,像普通的参数传递一样。
 
  2. 以 out 方式传递接口指针时,编译器会先调用 _IntfClear 函数减少引用计数,
 
     清除接口指针为 nil 。(out 也是以引用方式传送参数)。
 
  3. 以传值方式传递接口指针时,编译器会在参数被使用之前调用 _IntfAddRef 函数
 
     增加引用计数,在过程结束之前调用 _IntfClear 函数减少引用计数。
 
  
 
     (* 为什么以传值方式要特别处理引用计数呢?因为复制了接口指针?)
 
  
 
  { System.pas }
 
  procedure _IntfAddRef(const Dest: IInterface);
 
  begin
 
    if Dest <> nil then Dest._AddRef;
 
  end;
 
  
 
对于全局接口指针变量,在接口变量被赋值时增加对象的引用计数,在程序退出之前编译器自动调用 _IntfClear 函数减少引用计数以清除对象。
 
  
 
===============================================================================
 
⊙ 接口对象的编译器实现
 
===============================================================================
 
  
 
-------------------
 
接口对象的内存空间
 
-------------------
 
假设我们定义了如下两个接口 IIntfA 和 IIntfB,其中 ProcA 和 ProcB 将实现为静态方法,而 VirtA 和 VirtB 将以虚方法实现:
 
  
 
  IIntfA = interface
 
    procedure ProcA;
 
    procedure VirtA;
 
  end;
 
  
 
  IIntfB = interface
 
    procedure ProcB;
 
    procedure VirtB;
 
  end;
 
  
 
然后我们定义一个 TMyObject 类,它继承自 TInterfacedObject,并实现 IIntfA 和 IIntfB 两个接口:
 
  
 
  TMyObject = class(TInterfacedObject, IIntfA, IIntfB)
 
    FFieldA: Integer;
 
    FFieldB: Integer;
 
    procedure ProcA;
 
    procedure VirtA; virtual;
 
    procedure ProcB;
 
    procedure VirtB; virtual;
 
  end;
 
  
 
然后我们执行以下代码:
 
  
 
  var
 
    MyObject: TMyObject;
 
    MyIntf:  IInterface;
 
    MyIntfA: IIntfA;
 
    MyIntfB: IIntfB;
 
  begin
 
    MyObject := TMyObject.Create;  // 创建 TMyObject 对象
 
    MyIntf  := MyObject;           // 将接口指向 MyObject 对象
 
    MyIntfA := MyObject;
 
    MyIntfB := MyObject;
 
  end;
 
  
 
以上代码的执行过程中,编译器实现的内存空间情况如下:( 后文简称“图一” )
 
 ----------------|-----------------|----------|--------------|-----------------
 
 对象/接口指针   | 对象内存空间    |          | 虚方法表     |
 
 ----------------|-----------------|----------|--------------|-----------------
 
 MyObject    ->  | VMTptr        00|--------->| VirtA      00|
 
                 | FRefCount     04|          | VirtB      04|
 
 MyIntf      ->  | IInterface    08|----|          
 
                 | FFieldA       0C|    |           | IInterface    跳转表   |
 
                 | FFieldB       10|    |---------> | addr of QueryInterface |
 
 MyIntfB     ->  | IIntfB        14|---------|      | addr of _AddRef        |
 
 MyIntfA     ->  | IIntfA        18|--|      |      | addr of _Release       |
 
                                      |      |
 
                                      |      |      | IIntfB        跳转表   |
 
                                      |      |----> | addr of ProcB          |
 
                                      |             | addr of VirtB          |
 
                                      |
 
                                      |             | IIntfA        跳转表   |
 
                                      |-----------> | addr of ProcA          |
 
                                                    | addr of VirtA          |
 
 ------------------------------------------------------------------------------
 
先看最左边一列。MyObject 是对象指针,指向对象数据空间中的 0 偏移处(虚方法表指针)。可以看到 MyIntf/MyIntfA/MyIntfB 三个接口都实现为指针,这三个指针分别指向 MyObject 对象数据空间中一个 4 bytes 的区域。
 
  
 
中间一列是对象内存空间。可以看到,与不支持接口的对象相比,TMyObject 的对象内存空间中增加了三个字段:IInterface/IIntfB/IIntfA这些字段也是指针,指向(我暂命名为)“接口跳转表”的内存地址。(* 注意 MyIntfA/MyIntfB 的存放顺序与 TMyObject 类声明的顺序相反,为什么?)
 
  
 
第三列是类的虚方法表,与一般的类(不支持接口的类)一致。
 
  
 
-----------
 
接口跳转表
 
-----------
 
“接口跳转表”就是一排函数指针,指向实现当前接口的函数地址,这些函数按接口中声明的顺序排列。现在让我们来看一看所谓的“接口跳转表”有什么用处。
 
  
 
我们知道,一个对象在调用类的成员函数的时候,比如执行 MyObject.ProcA,会隐含传递一个 Self 指针给这个成员函数:MyObject.ProcA(Self)。Self 就是对象数据空间的地址。那么编译器如何知道 Self 指针?原来对象指针 MyObject 指向的地址就是 Self,编译器直接取出 MyObject^ 就可以作为 Self。
 
  
 
在以接口的方式调用成员函数的时候,比如 MyIntfA.ProcA,这时编译器不知道 MyIntfA 到底指向哪种类型(class)的对象,无法知道 MyIntfA 与 Self 之间的距离,因此编译器直接把 MyIntfA 指向的地址设置为 Self。从上图可以看到,MyIntfA 指向 MyObject 对象空间中 $18 偏移地址。这时的 Self 指针当然是错误的,编译器不能直接调用 TMyObject.ProcA,而是调用 IIntfA 的“接口跳转表”中的 ProcA。“接口跳转表”中的 ProcA 的内容就是对 Self 指针进行修正(Self - $18),然后再调用 TMyObject.ProcA,这时就是正确调用对象的成员函数了。由于每个类实现接口的顺序不一定相同,因此对于相同的接口在不同的类中实现,就有不同的接口跳转表(当然,可能编辑器能够聪明地检查到一些类的“接口跳转表”偏移量相同,也可以共享使用)。
 
  
 
上面说的是编译器的实现过程,使用“接口跳转表”真正的原因是 interface 必须支持 COM 的二进制格式标准。下图是我从《〈COM 原理与应用〉学习笔记》中摘录的 COM 二进制规格:
 
  
 
    接口指针 ----> pVtable ----> 指针函数1 ->  |----------|
 
                                 指针函数2 ->  | 对象实现 |
 
                                 指针函数3 ->  |----------|
 
  
 
-----------------------------------
 
对象内存空间中接口跳转指针的初始化
 
-----------------------------------
 
还有一个问题,那就是对象内存空间中的接口跳转指针是如何初始化的。原来,在 TObject.InitInstance 中,用 FillChar 清零对象内存空间后,进行的工作就是初始化对象的接口跳转指针:
 
  
 
  (* 我还没有细看)
 
  class function TObject.InitInstance(Instance: Pointer): TObject;
 
  var
 
    IntfTable: PInterfaceTable;
 
    ClassPtr: TClass;
 
    I: Integer;
 
  begin
 
    FillChar(Instance^, InstanceSize, 0);
 
    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
 
        PInteger(@PChar(Instance)[IOffset])^ := Integer(VTable);
 
    end;
 
      ClassPtr := ClassPtr.ClassParent;
 
    end;
 
    Result := Instance;
 
  end;
 
  
 
===============================================================================
 
⊙ implements 的实现
 
===============================================================================
 
Delphi 中可以使用 implements 关键字将接口方法委托给另一个接口或对象来实现。
 
  
 
下面以 TMyObject 为基类,考查 implements 的实现方法。
 
  
 
  TMyObject = class(TInterfacedObject, IIntfA, IIntfB)
 
    FFieldA: Integer;
 
    FFieldB: Integer;
 
    procedure ProcA;
 
    procedure VirtA; virtual;
 
    procedure ProcB;
 
    procedure VirtB; virtual;
 
    destructor Destroy; override;
 
  end;
 
  
 
------------------------------
 
以接口成员变量实现 implements
 
------------------------------
 
  TMyObject2 = class(TInterfacedObject, IIntfA)
 
    FIntfA: IIntfA;
 
    property IntfA: IIntfA read FIntfA implements IIntfA;
 
  end;
 
  
 
这时编译器的实现是非常简单的,因为 FIntfA 就是接口指针,这时如果使用接口赋值 MyIntfA := MyObject2 这样的语句调用时,MyIntfA 就直接指向 MyObject2.FIntfA。
 
  
 
------------------------------
 
以对象成员变量实现 implements
 
------------------------------
 
如下例,如果一个接口类 TMyObject2 以对象的方式实现 implements (通常应该是这样),其对象内存空间的排列与 TMyObject(见“图一”)几乎是一样的:
 
  
 
  TMyObject2 = class(TInterfacedObject, IIntfA, IIntfB)
 
    FMyObject: TMyObject;
 
    function GetMyObject: TMyObject;
 
    property MyObject: TMyObject read GetMyObject implements IIntfA, IIntfB;
 
  end;
 
  
 
不同的地方在于 TMyObject2 的“接口跳转表”的内容发生了变化。由于 TMyObject2 并没有自己实现 IIntfA 和 IIntfB,而是由 FMyObject 对象来实现这两个接口。这时,“接口跳转表”中调用的方法就必须改变为调用 FMyObject 对象的方法。比如下面的代码:
 
  
 
  var
 
    MyObject2: TMyObject2;
 
    MyIntfA: IIntfA;
 
  begin
 
    MyObject2 := TMyObject2.Create;
 
    MyObject2.FMyObject := TMyObject.Create;
 
    MyIntfA := MyObject2;
 
    MyIntfA._AddRef;
 
    MyIntfA.ProcA;
 
    MyIntfA._Release;
 
  end;
 
  
 
当执行 MyIntfA._AddRef 语句时,编译器生成的“接口跳转”代码为:
 
  
 
MyIntfA._AddRef;
 
  mov eax,[ebp-$0c]              // eax = MyIntfA^
 
  push eax                       // MyIntfA^ 设置为 Self
 
  mov eax,[eax]                  // eax = 接口跳转表地址指针
 
  call dword ptr [eax+$04]       // 转到接口跳转表
 
  
 
  { “接口跳转段”中的代码 }
 
  mov eax,[esp+$04]              // [esp+$04] 是接口指针内容 (MyIntfA^)
 
  add eax,-$14                   // 修正 eax = Self (MyObject2)
 
  call TMyObject2.GetMyObject
 
  mov [esp+$04],eax              // 获得 FMyObject 对象,注意 [esp+$04]
 
  jmp TInterfacedObject._AddRef  // 调用 FMyObject._AddRef
 
  
 
[esp+$04] 是值得注意的地方。“接口跳转表”中只修正一个参数 Self,其它的调用参数(如果有的话)在执行过程进入“接口跳转表”之前就由编译器设置好了。在这里 _AddRef 是采用 stdcall 调用约定,因此 esp+$04 就是 Self。前面说过,编译器直接把接口指针的内容作为 Self 参数,然后转到“接口跳转表”中对 Self 进行修正,然后才能调用对象方法。上面的汇编代码就是修正 Self 为 FMyObject 并调用 FMyObject 的方法。
 
  
 
可以看到 FMyObject._AddRef 方法增加的是 FMyObject 对象的引用计数,看来 implements 的实现只是简单地把接口传送给对象执行,而要实现 COM 组件聚合,必须使用其它方法。
 
  
 
===============================================================================
 
⊙ 结 束
 
===============================================================================
posted @ 2021-07-01 16:41  风潇潇xi  阅读(205)  评论(0)    收藏  举报