第四章 接口
前不久,有位搞软件的朋友给我出了个谜语。谜面是"相亲",让我猜一软件术语。我大约想了一分钟,猜出谜底是"面向对象"。我觉得挺有趣,灵机一动想了一个谜语回敬他。谜面是"吻",也让他猜一软件术语。一分钟之后,他风趣地说:"你在面向你美丽的对象时,当然忍不住要和她接口!"。我们同时哈哈大笑起来。谈笑间,似乎我们与自己的程序之间的感情又深了一层。对我们来说,软件就是生活。
第一节 接口的概念
"接口"一词的含义太广泛,容易引起误解。我们在这里所说的接口,不是讨论程序模块化设计中的程序接口,更不是计算机硬件设备之间的接口。现在要说的接口,是一种类似于类的程序语言概念,也是实现分布式对象软件的基础技术。
在DELPHI中,接口有着象类一样的定义方式,但不是用保留字class,而是用interface。虽然接口和类有着相似的定义方式,但其概念的内涵却有很大的不同。
我们知道,类是对具有相同属性和行为的对象的抽象描述。类的描述是针对现实世界中的对象的。而接口不描述对象,只描述行为。接口是针对行为方法的描述,而不管他实现这种行为方法的是对象还是别的什么东西。因此,接口和类的出发点是不一样的,是在不同的角度看问题。
可以说,接口是在跨进程或分布式程序设计技术发展中,产生的一种纯技术的概念。类的概念是一种具有普遍性的思想方法,是面向对象思想的核心。但是,接口概念也的确是伴随面向对象的软件思想发展起来的。用接口的概念去理解和构造跨进程或分布式的软件结构,比起早期直接使用的远过程调用(RPC)等低级概念更直观和简单。因为可以象理解一个对象一样理解一个接口,而不再关心这个对象是本地的或远程的。
在DELPHI中,接口被声明为interface。其命名原则是:接口都以字母I开头命名,正如类都以字母T开头一样。在接口的声明中只能定义方法,而不能定义数据成员。因为,接口只是对方法和行为的描述,不存储对象的属性状态。尽管在DELPHI中可以为接口定义属性,但这些属性必须是基于方法来存取的。
所有的接口都是直接或间接地从IUnknown 继承的。IUnknown是所有接口类型的原始祖先,有着类概念中TObject的相同地位。"一个接口继承另一个接口"的说法其实是不对的,而因该说"一个接口扩充了另一个接口"。接口的扩充体现的是一种"兼容性",这种"兼容"是单一的,绝不会存在一个接口同时兼容两个父接口的情况。
由于接口只描述了一组方法和行为,而实现这些方法和行为必须靠类。接口是不能创建实例的,根本就不存在接口实例之说,只有类才能创建对象实例。但一个接口的背后一定会有一个对象实例,这个对象就是接口方法的实现者,而接口是该对象一组方法的引用。
从概念上讲,一个对象的类可以实现一个或多个接口。类对接口的责任只是实现接口,而不应该说类继承了一个或多个接口。"实现"一词和"继承"一词有不同的含义,应该从概念上区分开来。
一般情况下,声明接口时需要一个能唯一标识该接口类型的GUID标识符。接口类型是要被分布在不同进程空间或计算机上的程序使用的,不象类的类型只是在一个程序空间内标识和使用。为了保证一种接口类型在任何地方都能被唯一识别,就必须要一种有效标识不同接口的方法。用人工命名的方法是不行的,没有谁能保证你开发的接口不会与别人重名。于是,一种所谓"全球唯一标识符"GUID(Globally Unique Identifier)应运而生。它是通过一种复杂的算法随机产生的标识符,有16个字节长,可以保证全世界任和地方产生的标识是不同的。在DELPHI的编辑环境中,你可以用Ctrl+Shift+G轻松产生一个GUID标识符,来作为接口的唯一标识。
为接口指定GUID是必要的。虽然,不指定接口的GUID也可以常常可以编译通过,但在使用一些与接口识别和转换相关的功能时一定会有问题。特别是在基于COM的程序开发中,GUID一定不可少。
接口的概念其实很简单,但却在分布式软件开发中起了关键作用。有的朋友之所以认为接口比较复杂,主要是因为不了解接口的概念和原理。因为人们总是对自己未知的东西有一种神秘感。这种神秘感往往会使人对未知世界产生畏惧心理。要揭开接口的神秘面纱,就必须不断的去学习和理解接口的奥秘。其实,在探索的过程中还会有许多的乐趣,你说对吧。
第二节 IUnknown
因为IUnknown是所有接口的共同祖先,所以一定要首先了解它。知道事情的起因,可以有效地帮助我们理解事情的过程和结果。IUnknown的原始定义是在System.pas单元中。因为定义在System.pas单元,那么一定是与系统或编译器相关的原始东西。一看IUnknown的定义,很简单,一共才6行。
IUnknown = interface
['{00000000-0000-0000-C000-000000000046}']
function QueryInterface(const IID: TGUID; out Obj): HResult; stdcall;
function _AddRef: Integer; stdcall;
function _Release: Integer; stdcall;
end;
不过,这6行定义代码可是接口世界的基础。其中的三个接口方法蕴涵着简单而又博大精深的哲理,理解这些哲理将使我们在编写基于接口的程序时受益非浅。
IUnknown的这三个接口方法是每一个接口对象类必须实现的方法,是接口机制的基础方法。为什么说这三个方法是接口机制的基础?且听我漫漫道来。
首先来谈谈QueryInterface接口方法。我们知道一个对象类是可以实现多个接口的。任何接口对象都一定实现了IUnknown接口。因此,只要你获得了一个接口指针,那一定可以通过这个接口指针调用QueryInterface方法。而调用QueryInterface就可以知道这个接口指针还实现了一些什么接口。这对接口编程机制来说非常重要。判断一个接口指针是否实现了某接口功能,不同接口类型之间的接口匹配和转换,都与QueryInterface方法相关。
QueryInterface有两个参数和一个返回值。第一个参数是接口类型的标识,即一个16字节的GUID标识。由于DELPHI编译器知道每种接口对应什么GUID,所以你可以直接使用象ImyInterface之类的标识符作为第一个参数。如果,该接口支持第一个参数指定的接口类型,则将获得的接口指针通过第二个参数Obj送回给调用程序,同时返回值为S_OK。
从这里也可以看出,为什么为接口指定GUID标识是必要的。因为,QueryInterface方法需要这样的标识,而它又是接口和匹配和转换机制的基础。
接下来我们再谈谈_AddRef和_Release极口方法。_AddRef和_Release接口方法是每一种要接口对象类必须实现的方法。_AddRef是增加对该接口对象的引用计数,而_Release是减少对接口对象的引用。如果接口对象的引用计数为零,则要消灭该接口对象并释放空间。这本是接口机制要求的一个基本原则,就好象1+1=2这样简单的道理,不需要深奥的解释。数学家才会有兴趣去研究一加一为什么会等于二?但数学家对1+1=2的理解是透彻的。同样,对接口对象引用机制的深刻理解,会让我们明白许多道理,这些道理将为我们的开发工作带来益处。
有位大师曾说过:接口是被计数的引用!
要理解这句话,我们首先要理解"引用"的概念。"引用"有"借用"的意思,表明一种参考关系。引用的一方只存在找到被引用一方的联系,而被引用的一方才是真正的中心。由于,通过这种引用关系可以找到对象,因此,引用实际就是该对象的身份代表。在程序设计中,引用实际上是一种指针,是用对象的地址作为对象的身份代表。
在不是基于接口机制的程序中,本不需要就对象的引用关系进行管理。因为,非接口对象的实例都在同一个进程空间中,是可以用程序严格控制对象的建立、使用和释放过程的。可是,在基于接口机制的程序中,对象的建立、使用和释放可能出现在同一进程空间中,也可能出现在不同的进程空间,甚至是Internet上相隔千里的两台计算机中。在一个地方建立一个接口,可能实现这个接口的对象又存在于另一个地方;一个接口在一个地方建立后,又可能会在另一个地方被使用。在这种情况下,要想使用传统的程序来控制对象的建立和释放就显得非常困难。必须要又一种约定的机制来处理对象的建立和释放。因此,这一重任就落到了IUnknown的_AddRef和_Release的身上。
这种接口对象引用机制要求,接口对象的建立和释放由对象实例所在的程序负责,也就是由实现接口的对象类负责。任何地方引用该对象的接口时,必须调用接口的_AddRef方法。不再引用该对象时,也必须调用接口的_Release方法。对象实例一旦发现再也没有被任何地方引用时,就释放自己。
正是为了解决接口对象实例空间管理的问题,_AddRef和_Release方法才成为所有接口对象类必须实现的方法。
第三节 接口对象的生死
初看本节的标题似乎有点吓人。接口对象怎么会和生与死联系起来呢?接口对象的生死真的那么重要吗?一个好的统治者应该关心百姓的生死,同样,一个好的程序员也应该关心对象的生死。而接口对象又是流浪在分布式网络中的游子,我们更应该关心他们的生死!
由于,接口对象是伴随接口引用的产生而建立,又伴随接口引用的完结而消亡。在DELPHI 中使用接口,似乎没有人关心,实现接口的对象是怎样出身又怎样死亡的。这正是DELPHI中使用接口的简单性,也是其在解决接口机制的使用问题上所追求的目标。需要接口时总有一个对象会为她而生,一旦不再引用任何接口时这个对象又无怨无艾的死去,绝不拖累系统一个字节的资源。真有点"春蚕到死丝方尽,蜡炬成灰泪始干"的凄情。
因为接口对象的生死直接和引用该对象的接口数目有关,所以研究在什么情况下会增加一次接口引用,又在什么情况下会减少一次接口引用,是了解接口对象生死的关键。
现在我们来实现一个最简单的接口对象类TIntfObj,它只实现了IUnknown接口中定义的三个基本方法。有的朋友一看就知道,这个类实际抄袭了DELPHI中TInterfacedObject类的部分代码。只是我们分别在_AddRef和_Release方法中增加了一些信息输出语句,以便于我们探索接口对象的生死问题。请看下面的程序:
program ProgramA;
uses
SysUtils, Dialogs;
type
TIntfObj = class(TObject, IUnknown)
protected
FRefCount: Integer;
function QueryInterface(const IID: TGUID; out Obj): HResult; stdcall;
function _AddRef: Integer; stdcall;
function _Release: Integer; stdcall;
end;
function TIntfObj.QueryInterface(const IID: TGUID; out Obj): HResult; stdcall;
const
E_NOINTERFACE = HResult($80004002);
begin
if GetInterface(IID, Obj) then Result := 0 else Result := E_NOINTERFACE;
end;
function TIntfObj._AddRef: Integer; stdcall;
begin
INC(FRefCount);
ShowMessage(Format('Increase reference count to %d.', [FRefCount]));
result:=FRefCount;
end;
function TIntfObj._Release: Integer; stdcall;
begin
DEC(FRefCount);
if FRefCount <> 0 then
ShowMessage(Format('Decrease reference count to %d.', [FRefCount]))
else begin
Destroy;
ShowMessage('Decrease reference count to 0, and destroy the object.');
end;
result:=FRefCount;
end;
var
aObject:TIntfObj;
aInterface:IUnknown;
procedure IntfObjLife;
begin
aObject:=TIntfObj.Create;
aInterface:=aObject; //增加一次引用
aInterface:=nil; //减少一次引用
end;
begin
IntfObjLife;
end.
我们需要用单步调试功能来研究接口引用计数的增减与接口生死的关系。因此,建议你将Options选项中Complier页的Optimization项清除,以避免编译器会优化掉我们需要的指令。
当程序执行到IntfObjLife子程序的三行代码时,请一步一步的调试代码。你会发现,当发生一次对接口类型变量的赋值行为时,就会引发对接口引用计数的增减。
执行语句
aInterface:=aObject;
就会出现"Reference count increase to 1."的信息,表明增加了一次接口引用。
而执行语句
aInterface:=nil;
就会出现"Reference count decrease to 0, and destroy the object.",表明接口引用减少到零并且删除了接口对象。
所以,我们可以得出结论:当将引用值赋值给接口类型的变量时,会增加对接口对象的引用计数;而当清除接口类型变量的引用值时(赋nil),就会减少对接口对象的引用计数。
在看看下面的代码,加深一下对这一结论的理解。
var
aObject : TIntfObj;
InterfaceA, InterfaceB : IUnknown;
……
aObject := TIntfObj.Create;
InterfaceA := aObject; //引用增加至1
InterfaceA := InterfaceA; //引用增加至2,但立即又减少至1
InterfaceB := InterfaceA; //引用增加至2
InterfaceA := nil; //引用减少至1
InterfaceB := InterfaceA; //引用减少至0,释放对象
……
无论是将接口对象赋值给变量,还是将接口变量赋值给接口变量,以及将nil赋值给接口变量,都印证这一结论。有趣的是,其中的InterfaceA := InterfaceA一句执行时,接口对象的引用先增加然后立即减少。为什么会这样呢?留给你自己去思考吧!
接着,我们再来看看下面的代码:
procedure IntfObjLife;
var
aObject:TIntfObj;
aInterface:IUnknown;
begin
aObject:=TIntfObj.Create;
aInterface:=aObject;
end;
这个过程与前面那个过程不同的是,将变量定义为局部变量,并且最后没有给接口变量赋nil值。单步调试这段代码我们发现,在程序运行到子程序end语句之前,接口对象的引用还是减少为0并被释放。这是为什么呢?
我们知道,变量是有作用域的。全局变量的作用域是程序的任何地方,而局部变量的作用域只是在相应的子程序内。一旦变量离开它的作用域,变量本身已经不存在,它存储的值更是失去意义了。所以,当程序即将离开子程序时,局部变量aInterface将不在存在,而其所存储的接口对象引用值也将失去意义。聪明的DELPHI自动减少对接口对象的引用计数,以确保程序在层层调用和返回中都能正确地管理接口对象的内存空间。
因此,我们又可以得出新的结论:当任何接口变量超出其作用域范围的时候,都会减少相关接口对象的引用计数。
要注意的是,子程序的参数变量也是一种变量,它的作用域也是在该子程序范围内。调用一个含有接口类型参数的子程序时,由于参数的传递,相关接口对象的引用计数会被增加,子程序返回时又被减少。
同样,如果子程序的反回值是接口类型时,返回值的作用域是从主调程序的返回点开始,直到主调程序最后的end语句之前的范围。这种情况同样会引起对接口对象引用计数的增减。
该总结一下对象的生与死了。盖棺论定后我们可以得出以下原则:
1. 将接口对象的引用值赋值给全局变量、局部变量、参数变量和返回值等元素时,一定会增加接口对象的引用计数。
2. 变量原来存储的接口引用值被更改之前,将减少其关联对象的引用计数。将nil赋值给变量是一个赋值和修改接口引用的特例,它只减少原来接口对象引用计数,不涉及新接口引用。
3. 存储接口引用值的全局变量、局部变量、参数变量和返回值等元素,超出其作用域范围时,将自动减少接口对象的引用计数。
4. 接口对象的引用计数为零时,自动释放接口对象的内存空间。(在一些采用了对象缓存技术的中间件系统中,如MTS,可能并不遵循这一原则)
需要提醒你的是,一旦你将建立的接口对象交给接口,对象的生死就托付给接口了。就好象将宝贝女儿嫁给忠厚的男人一样,你应该完全信任他,相信他能照顾好她。从此,与对象的联系都要通过接口,而不要直接与对象打交道。要知道,绕过女婿直接干预女儿的事情有可能会出大问题的。不信,我们看看下面的代码:
program HusbandOfWife;
type
IHusband = interface
function GetSomething:string;
end;
TWife = class(TInterfacedObject, IHusband)
private
FSomething:string;
public
constructor Create(Something:string);
function GetSomething:string;
end;
constructor TWife.Create(Something:string);
begin
inherited Create;
FSomething:=Something;
end;
function TWife.GetSomething:string;
begin
result := FSomething;
end;
procedure HusbandDoing(aHusband:IHusband);
begin
end;
var
TheWife : TWife;
TheHusband : IHusband;
begin
TheWife := TWife.Create('万贯家财');
TheHusband := TheWife; //对象TheWife委托给一般接口变量TheHusband
TheHusband := nil; //清除接口引用,对象消失
TheWife.GetSomething; //直接访问对象,一定出错!
TheWife := TWife.Create('万贯家财');
HusbandDoing(TheWife); //对象委托给参数接口变量aHusband,返回时对象消失
TheWife.GetSomething; //直接访问对象,一定出错!
end.
请大家仔细看最后面begin至end之间的代码。我尽量将程序写的有趣和易于理解,希望你能读懂我的程序。其基本意思是,产生一个TheWife对象之后,一旦将该对象传递给一个IHusband类型的接口后,再使用TheWife直接操纵对象就可能发生想象不到的问题!那"万贯家财"给出去容易,拿回来可就难哟。
所以,在进行基于接口的程序设计中,请切记:接口对象一旦建立,请永远用接口来操纵对象!
现在,你该认为本节的题目并不吓人了吧!如果是这样,我写本节的目的就达到了。我也因为你能静静地倾听我诉说衷肠而感到心情舒畅。那么,谢谢你!让我们共同在愉快的心情中继续前进吧。
第四节 接口方法的背后
俗话说,每一个成功的男人背后,一定有一个了不起的女人。同样,每一个接口背后一定有一个了不起的对象。今天我们将了解一下接口是如何映射到对象而实现接口功能 的。
我们都知道,一个接口值实际上是一个指针。这个指针指向一张方法地址表,其中地址表的每一项存有真正要调用的对象方法的地址。通过接口值调用方法时,就可以通过其指向的方法地址表中映射的对象方法地址找到对象的方法,从而实现正确的方法调用。
这样理解接口到对象的方法映射关系本没有什么不正确,但却忽略了怎样通过接口只找到其引用的对象的问题。因为,一个接口值不仅代表了实现的一组方法,而且还表明这个接口值的方法是由其引用的那个对象实现的,而不是由另一个对象实现的(尽管是同一类的对象)。
所以,对接口值的调用首先是定位对象实例,然后才能调用对象的方法。
在我们思考问题的时候,常常会犯一些习惯性错误。又一次,我找一个朋友借车。我拿着车钥匙就去车库了。到了车库我才发现那里停有许多的车,于是不得不打在电话询问。因为,我急于开车而忘了问是什么车。同样,许多朋友在讨论接口到对象的映射关系时,也往往忽略了对象本身的映射问题。
其实,接口指针所对应的方法表中并不是直接存储的对象方法地址,而存储的是实现每一个接口方法到对象方法跳转的一小段代码的地址。这一小段代码首先将接口指针转换为对象指针,然后才跳转到对象的方法地址,调用其值向对象的方法。
在DELPHI中,一个接口对象的实例空间中存有该对象所实现的所有接口的方法表指针。如果将对象实例空间中的接口方法表指针看作一个对象的数据成员,则该数据的地址值就是一个接口引用值,即接口指针。实际上,接口方法表指针就是一个接口对象的数据成员,它在对象实例空间中的偏移是固定的。因此,DELPHI只需要将接口指针减去这个固定偏移就可得到对象的指针。接口方法到对象方法跳转的那小段代码,就是实现了这种映射。
总之,接口就是虚方法地址表的说法并不完全正确,接口的实现有更多要考虑的东西。但这并不影响我们使用DELPHI开发复杂的应用程序。这只是DELPHI内部实现接口机制的奥秘,应该由缔造DELPHI的大师们去考虑。我们还是来关心一下程序语言一级的接口方法映射问题吧。
在默认的情况下,我们都将接口类的方法与所实现接口类型的方法取成相同的名称,就好像重载类的虚函数一样。但接口类的方法与接口类型的方法之间不是重载关系,而是对应关系。没有任何人说过接口接口类的方法的名称一定要与接口类型的方法相同,只是这种相同的名称可以让DELPHI编译器自动地对应接口和类的方法。
假设我们定义了一个IMailBox的接口如下:
IMailBox = interface
procedure PutMail( aMail : string);
function GetMail : string;
end;
我们用下面的TMailBox类来实现这一接口:
TMailBox = class(TInterfacedObject, IMailBox)
procedure PutMail( aMail : string);
function GetMail : string;
end;
由于这个TMailBox的方法名称与IMailBox的方法名称完全相同,所以DELPHI编译器会自动将接口的方法映射到相关的类对象的方法上。
但如果我们改用下面的TMailBox类实现这一接口:
TMailBox = class(TInterfacedObject, IMailBox)
procedure SetMail( aMail : string);
function GetMail : string;
end;
这时,编译器无法在TMailBox的定义中找到一个与IMailBox的PutMail方法相匹配的定义,将提示"Undeclared identifier: 'PutMail'"。其实,我们的意思是要用TMailBox的SetMail方法实现IMailBox接口的PutMail方法,只是它们的名称不同。但编译器不知道,它还没有聪明到理解自然语言的地步。
这时候,我们需要用到接口方法映射语句。重新定义TMailBox如下:
TMailBox = class(TInterfacedObject, IMailBox)
procedure IMailBox.PutMail = SetMail; //接口方法映射定义
procedure SetMail( aMail : string);
function GetMail : string;
end;
其中的procedure IMailBox.PutMail = SetMail;一句,是告诉编译器:IMailBox的PutMail要映射到类的SetMail方法上。这样,程序就可以正常编译通过了。
这再次说明,对象类与接口不是继承关系,对象类方法与接口方法也不是虚函数的重载关系。而是对象类实现了接口,接口方法被映射到对象方法。
你可以将定义对象类想象为设计一块电路板。如果,你当初就是按照某总接口标准来设计电路板的输入和输出引脚,那么电路板设计好后肯定可以直接连接到标准接口上。否则,就要再焊接一些跳线,以实现接口引脚到电路板引脚的映射。
接口概念的好处就是,使用接口的人也许永远不知道接口功能是怎样实现的,但这种功能却应用得很好。而接口的使用者就可采用尽可能灵活的方式实现接口功能,不用担心外人会窃取你的实现技术机密。
在DELPHI中一个对象类实现接口时,它可以将这种实现要求委托给另外一个对象或接口来完成。这使得接口功能的提供者有了一种更灵活的接口实现方法。
我们来看看下面的程序:
program ServiceCenter;
type
IWasher = interface
procedure WashClothing;
end;
IRemover = interface
procedure MoveHouse;
end;
TWasher = class(TInterfacedObject, IWasher)
procedure WashClothing;
end;
TRemover = class(TInterfacedObject, IRemover)
procedure MoveHouse;
end;
TServiceCenter = class(TInterfacedObject, IWasher, IRemover)
private
FWasher : TWasher; //洗衣工对象
function FindRemover : IRemover; //找寻搬运工
public
constructor Create;
property Washer:TWasher read FWasher implements IWasher; //委托实现
property Remover:IRemover read FindRemover implements IRemover; //委托实现
end;
procedure TWasher.WashClothing;
begin
end;
procedure TRemover.MoveHouse;
begin
end;
constructor TServiceCenter.Create;
begin
inherited;
FWasher := TWasher.Create;
end;
function TServiceCenter.FindRemover:IRemover;
begin
result := TRemover.Create;
end;
begin
end.
在这个程序中我们分别定义了洗衣工和搬运工的接口IWasher和IRemover,以及它们的实现类TWasher和TRemover。我们又定义了一个服务中心的类TServiceCenter,它实现IWasher和IRemover的接口。服务中心是可以提供洗衣和搬家的服务的,但它并不自己去洗衣和搬家,而是委托给其他洗衣工和搬运工。其中的两条定义语句:
property Washer:TWasher read FWasher implements IWasher; //委托实现
property Remover:IRemover read FindRemover implements IRemover; //委托实现
完成这种服务的委托关系。
服务中心对象可以被任何需要IWasher和IRemover的接口引用,但它只是一个中间商,具体的工作是由TWasher和TRemover来完成的。
其中,TServiceCenter在创建的时候建立一个TWasher对象来实现IWasher接口,为客户提供洗衣服务。因为洗衣的服务是经常性的服务,有必要在服务中心开张的时候就雇佣洗衣工人。但是在需要提供IRemover接口的时候,它却动态建立一个TRemover对象并返回接口。因为搬家不是经常的,在需要的时候临时雇一个搬运工来干活就行了。
总之,接口功能委托实现是很灵活的,而且可以是动态的。了解这些奥秘,你就能编写出更好的基于接口的程序。不过程序应该贴近和反映生活中的实际事物,这才叫面向对象。
本节的最后卖两个关子留给大家思考:
1. 在TServiceCenter的Create构造函数中建立了FWasher对象,有必要在相应的析构函数Destroy中释放吗?
2. 是否有必要对TServiceCenter临时建立的TRemover的对象进行内存管理呢?
相信你能正确分析和解答这两个问题。