Delphi接口的底層實現

Delphi接口的底層實現

引言

      接口是面向對象程序語言中一個很重要的元素,它被描述爲一組服務的集合,對於客戶端來講,咱們關心的只是提供的服務,而沒必要關心服務是如何實現的;對於服務端的類來講,若是它想實現某種服務,實現與該服務相關的接口便可,它也沒必要與使用服務的客戶端進行過多的交互。這種良好的設計方式已經受到很普遍的應用。html

   早在Delphi3的時候就引入了接口的概念,當時徹底是由於COM的出現而誕生的,但通過這麼多版本的進化,Delphi的接口已經成爲ObjectPascal語言的一部分,咱們徹底能夠用接口來完成咱們的設計,而不用考慮與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的全部方法。方法表中每一個指針指向方法真正實現的地方,其實這個說法只是暫時的,稍後會解釋方法表中的指針真正指向的地方,並說明其緣由。spa

   上面的內存分佈並不是筆者隨意想出來的,而是通過屢次測試證明的,下面咱們用一些代碼來證明上面分佈圖:

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對象的首地址

movedx,eax;               edx指向eax指向的地方,edx也指向TTest對象的首地址

testedx,edx;             測試TTest對象是否有效

jz +$03

subedx,-$0c;             對象首地址偏移12個字節,到ITest2指針處

leaeax,[ebp-$04];        test變量的地址是ebp-04的值,eax指向這個地址

call@IntfCopy;           調用IntfCopy,將edx的值拷貝給eax,引用計數管理

Unit1.pas.50: test.SayHello2;

moveax,[ebp-$04];        將test指向的地址賦給eax,此時eax指向Itest2的地址

movedx,[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爲空,因此不會調用減小引用計數的代碼。

   接下來到calldword ptr [edx+$0c],edx指向ITest2指向的方法表首地址,而edx+$0c偏移到哪裏呢,看看上面的內存圖,正好到ISayHello2處。此時調用ISayHello2指向地址的代碼,咱們能夠簡單地認爲就是調用TTest.SayHello2。但事實上卻不是這樣的,爲何?由於在調用SayHello2以前,要先指定eax的值爲TTest對象的Self指針,以此做爲隱含參數傳進SayHello2。

   咱們能夠到[edx+$0c]的地址看看,按F8將執行點執行到calldword 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的地址,而IOffset是IntfTable.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被調用時只是將FRefCount增1:

Result:= InterlockedIncrement(FRefCount);

_Release被調用時,減小FRefCount,若是FRefCount爲0時,即調用Destroy消毀本身:

   Result := InterlockedDecrement(FRefCount);

   if Result = 0 then

     Destroy;

   若是即想實現接口,而不想經過引用計數管理生命週期的,能夠在AddRef和Release中簡單地將結果返回爲-1便可,TComponent類便是如此。

   那麼Delphi是如何實現接口引用計數的管理的呢,有下面的規律:

1.     當一個非空的接口變量要賦值給另外一個接口變量時,非空的接口變量應該要調用AddRef。

2.     當一個非空的接口變量要被另外一個接口變量賦值時,非空的接口變量應該要調用Release。

3.     若是你對於接口的引用計數有足夠了解的話,有些AddRef和Release能夠被優化掉。

 

對於第一種狀況,在上節中已經有描述,看_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; outObj): HResult;

   對於這個特性的實現,我不想在這裏羅嗦,實際上只要理解了第一部分和第二部分,這個特性是很容易推斷出怎樣實現的,更況且源代碼就在那兒,何不給本身一個練習的機會呢?

 

自:http://blog.sina.com.cn/s/blog_4bc47d230101gdk9.html

--------------------- 本文來自 蒼穹帝 的CSDN 博客 ,全文地址請點擊:https://blog.csdn.net/gjtao1130/article/details/24536853?utm_source=copy 

相關文章
相關標籤/搜索