先行知識:Delphi/接口/Dll/OOP
難度:★★★☆☆
引子:
接口的概念由來已久,早在COM出現之前(COM應該是95年左右)接口的概念就已經在面向對象的開發中根深蒂固了,著名的《設計模式》(94年出版)中也指出“針對接口編程而不是針對實現編程”。使用接口可以降低軟件系統中不同模塊的偶合性,利於軟件系統的更新與維護。接口的優點絕對不只是出現在COM中,事實上在大多數的編程任務中接口都是一個不錯的選擇。(用Delphi開發過Web Service的朋友知道,Delphi也是使用接口來描述Web Methord的,所以接口的概念在面向對象領域永遠不會過時)本文不是一篇討論COM的文章,而是想通過一個例子來說明在Delphi中接口的實際作用,以及在開發中可能碰到的問題和所需的技巧。
例子:
※第一印象:
熟悉Windows程序設計的人應該早已經在他們開發的系統中使用到了DLL,如果我們要把對象放入DLL中維護(而不僅僅是一些函數和過程)怎麼辦呢?最容易想到答案是使用COM。除此之外還有什麼辦法呢?使用Delphi中的動態包bpl或則一些其他的一些辦法(如內存拷貝)也許可以解決問題。不過現在我們要創建一個標准的DLL文件,我們可以象使用COM一樣直接通過接口來操作維護在其中的對象,但又不用象COM組件一樣需要注冊,它應該是如同普通的DLL文件樣只要加載就可以正常工作。這樣的優點是明顯的,也許我們正在需要一個如同大多數繪圖軟件一樣允許有插件擴充的程序,那麼除了標准的COM技術外我們可以將實現約定接口(也就是插件的契約)的對象放在一個標准的DLL庫中,在主應用程序中根據一份可由用戶配置的文件中的不同插件名稱和所在路徑來依次加載這些DLL,這樣我們的插件下載到客戶的計算機中後根本不用任何注冊安裝過程,而僅僅只是在主程序中配置它就可以正常工作了。這個過程看起來象這樣:
for I:=0 to PluginCount-1 do
//PluginCount是從配置文件中得到的已經“安裝”的插件數目
begin
…
Dllhnd[i]:=loadlibrary(PlugPath);
//PlugPath為每一個dll的路徑,以由前面程序從培植文件中得到
@GetPlugIntf:=GetProcAddress(Dllhnd[i],’GetPlugIntf’);
PlugIntf[i]:= GetPlugIntf; //GetPlugInth可以返回一個IunKnown的接口
…
end;
現在我們就得到所加載的每一個插件的接口並可進行操作了。從上面的代碼中可以大概的看出一些我們需要管理對象的DLL的樣子:這個DLL只有一個唯一的導出函數以獲得其中維護的對象的接口(GetPlugIntf,也有可能有其它的導出函數,但這個是必須的),這個函數可以返回一個對象實現的接口也可以直接返回Iunknown接口(這樣便於用一個數組管理所有的插件接口,也利於用循環結構實現程序,就象上面看到的那樣),主程序在需要的時候進行轉換。另外我們的主程序需要和Dll共用一個描述接口的文件(契約)。返回接口導出的函數看起來象這樣:
var
OurObject:TintfObject;
…
function GetFooObjectIntf:IUnKnown;stdcall;
begin
if not assigned(OurObject) then
begin
OurObject:= TintfObject.Create;
…
end;
result:= OurObject as IUnKnown;
end;
有了上面的描述後可以看到要在一個普通的DLL中維護對象並象COM一樣發布對象的接口也是一件很簡單的事情,沒什麼特別的,不過上面的討論有一個很大的問題:如果我們的DLL只有一個導出函數,這意味這它只能導出一個對象的接口,就象上面那樣,但如果我們要在這個DLL中維護多個對象怎麼辦呢(特別是一些按照繼承關系連接起來的對象家族,或者具有共同特點的對象)?
※使用工廠模式:
解決上面問題的最好辦法是在DLL的設計中使用工廠模式來管理其中維護的多個對象,這樣做不僅可以維護不同的對象,還可以維護一個類的多個實例。然後我們只用在那個唯一的導出函數中導出這個工廠對象的接口,其它的對象接口都可以通過這個接口獲得。比如象下面的樣子:
function TFooManager.CreateAFoo: IFoo;
begin
inc(FooNum);
if length(FList)<FooNum then
setlength(FList,FooNum*2);
FList[FooNum-1]:=TFoo.Create;
…
result:=FList[FooNum-1] as IFoo;
end;
例如上面的TfooManager就是一個工廠類,它負責管理DLL中具體對象的生命周期,CreateAFoo創建一個DLL中名為Foo的對象,並把它保存到自己的私有字段,一個動態數組中:
private
FList:array of TFoo;
注意,上面的代碼中用到了一個小小的技巧,每當flist的空間不夠大時,我們采用了雙倍分配策略,既在這個時候我們給flist它所需要的空間的兩倍的空間。在創建對象請求頻繁的時候,這無疑是一條有效的提高執行效率的策略,因為setlength函數在重新分配空間並依次移動已經存在的元素時是一個耗費時間的過程。這個策略也是一個典型的空間換時間的算法。
現在有了工廠模式後,我們要在dll中管理多個對象或則不同的對象就十分方便了,我們甚至可以讓CreateAFoo接受一個參數以確定創建何種類型的對象,並且每新增加一種對象我們就在TfooManager工廠類中添加一個對象的私有字段(動態數組,當然也可以根據你的需要使用其它的數據結構如鏈表TList)。關於這個工廠類的其它方法的代碼請參看文後的代碼清單。
問題和解決技巧:
※引用計數問題(當我們需要手工管理對象的生命周期時):
既然我們在工廠類中定義了私有字段以存貯dll中的諸多對象實例,那從這裡得到的一個很顯然的觀點是我們需要手工管理其中對象的生命周期,我們也許還需要在工廠類中添加一個用於釋放所管理對象的方法:
procedure TFooManager.DelAFoo(id:integer);
var
i:integer;
begin
if FooNum>0 then
begin
FList[id].Free;
for i:=id to FooNum-2 do
begin
FList[i]:=FList[i+1]; //移動剩余元素,使對象在flist中保持連續存貯。
end;
FList[FooNum-1]:=nil;
Dec(FooNum);
end;
end;
這個方法根據傳入的一個ID值釋放指定的對象(在管理多種不同的對象時,還需要接收一個代表所需要釋放對象類型的參數以決定釋放哪種類型的對象)。好,一切看上去都很正常,但當我們從DLL外在調用工廠方法創建對象的時候問題出現了:
procedure CallCreateFoo;
var
tempfoo:IFoo;//要創建的Foo對象的接口IFoo
begin
tempfoo:=FooMan.CreateAFoo; //FooMan是工廠類的對象的接口
end;
當我們需要在這個過程外使用剛才創建的對象時(例如我們可以使用工廠類的一個方法TFooManager.GetFooByID(id: integer)根據傳入的id找到flist中指定的對象,如剛才我們通過工廠方法創建的那對象),會出現一個內存訪問錯誤,仔細觀察後會發現我們剛才創建的對象根本不在內存中!為什麼?由於Delphi中的接口都繼承自Iinterface,這是一個與Iunknown一樣的接口,這意味著我們的對象都必須實現Iunknown中的那3個方法,更進一步的我們為了不用手工書寫這些代碼我們的類都繼承自默認實現了Iinterface的類TinterfacedObject。然而錯誤的根源就在這個地方,繼承自TinterfacedObject的對象根本不允許我們手工管理它的生命周期,因為Iunknown的實現類會根據對象中的引用計數來維護對象的生命周期,而上面的tempfoo是一個過程的局部變量,當它離開作用域時會被Delphi編譯器自動調用tempfoo._ Release(這是Iunknown的方法),這個方法在引用計數為0的時候將自動釋放對象!而我們在調用TFooManager.CreateAFoo方法時其中僅做了一次as操作result:=FList[FooNum-1] as IFoo;(Delphi會在這時自動增加一個對象的引用計數),所以引用計數為1在_ Release後變為0,於是在我們想訪問我們創建的對象之前這個對象就已經不存在了。好了,問題的起因弄的很清楚了,要解決也不難,我們只用在返回請求接口之前進行一次_AddRef操作增加引用計數值,這樣除非我們手工釋放對象,否則引用計數都不會為0,如下的改進:
function TFooManager.CreateAFoo: IFoo;
begin
…
FList[FooNum-1]:=TFoo.Create;
(FList[FooNum-1] as IUnKnown)._AddRef;
result:=FList[FooNum-1] as IFoo;
end;
好了,似乎我們已經克服了所有的困難,是這樣嗎?不是,麻煩馬上又出現了!當我們這個時候調用工廠類的DelAFoo方法時會拋出更多的異常!。當我們釋放對象的時候會調用到TinterfaceObject的free方法,在調用這個方法前編譯器會自動調用TInterfacedObject.BeforeDestruction方法(事實上這是一個從Tobject繼承下來的方法,但在Tobject中它沒有任何的實現),這個方法代碼如下:
procedure TInterfacedObject.BeforeDestruction;
begin
if RefCount <> 0 then
Error(reInvalidPtr);
end;
看到這裡問題明白了吧,由於我們手動增加了引用計數值,使那個值在釋放對象前也不會為0,而上面的代碼在引用計數值為0的時候會拋出一個異常。解決這個問題的辦法很簡單,我們只需要再自己定義一個我們的TinterfacedObject類TourInterfacedObject,在這個類中復寫(override,因為這是一個虛函數)BeforeDestruction,讓它的代碼部分空白:
procedure TInterfacedObject.BeforeDestruction;
begin
end;
然後我們DLL中所有的類只用從TourInterfacedObject繼承就可以了。
※改進和手工管理
這次我們再進行測試時就沒有任何的問題了嗎?如果只是上面的代碼的確沒有問題了,但問題是我們可能需要在任何地方使用接口操作我們所管理的對象,而Delphi編譯器會在接口變量離開作用域或者被手工設置為nil時自動調用_IntfClear以決定是否釋放實現接口的對象,如果在這之前我們已經調用諸如DelAFoo這樣的方法手工釋放了我們的對象,那麼在調用_IntfClear時,一個訪問異常出現了,因為這時對象已經根本不存在了!!現在是應該徹底把對象生命周期交給我們管理而不是交給接口管理的時候了(上面的做法是片面的,因為事實上對象內部仍然存在著一個引用計數值,我們只是用了一點技巧混淆了它對對象生命周期的管理),看來我們又要回到最開始了,我們不得不考慮不要TinterfacedObject而是自己寫一個實現Iinterface的類,徹底的拋棄引用計數,並省略到諸如BeforeDestruction之類的不必要的方法:
TMyInterfacedObject = class(TObject, IInterface)
protected
function QueryInterface(const IID: TGUID; out Obj): HResult; stdcall;
function _AddRef: Integer; stdcall;
function _Release: Integer; stdcall;
end;
function TMyInterfacedObject._AddRef: Integer;
begin
result:=1; //我們已經不需要引用計數了
end;
function TMyInterfacedObject._Release: Integer;
begin
result:=1;
end;
function TMyInterfacedObject.QueryInterface(const IID: TGUID;
out Obj): HResult;
begin
if GetInterface(IID, Obj) then
Result := 0
else
Result := E_NOINTERFACE;
End;
很高興的告訴大家,到此我們已經解決了對象生命周期管理中的所有問題,我們所管理的對象可以正常工作了!而且我們還可以去掉上面的技巧性代碼,從這個TmyInterfacedObject繼承就已經足以解決所有的問題了。也許有人可以看到上面的代碼已經極大的破壞了COM規范,然而這正是本文的目的(J),通過我們的改進,我們手工管理的對象工作的很好,而且這也正是我們所需要的不是嗎?