四 COM接口 接口的结构与描述 IUnknown 接口 C++, C, Delphi IDL 接口的的标识 方法与结果 数据类型 MIDL 编译器. IUnknown 接口 IUnkown接口定义 引用计数 接口查询
1 接口的结构与描述 1.1 C++, C, Delphi 前一章已经模拟了COM的设计过程中最关键的过程。本章通过演示一个真正的COM(提供字典功能和拼写检查功能) ,来讲述COM接口与对象的关系,为了实现COM所涉及到的其他细节。 假设我们要做一个字典,它要提供一些功能,这些功能用函数表示如下: BOOL __stdcall Initialize() ; BOOL __stdcall LoadLibrary(String); BOOL __stdcall InsertWord(String, String); void __stdcall DeleteWord(String); BOOL __stdcall LookupWord(String, String *); BOOL __stdcall RestoreLibrary(String); void __stdcall FreeLibrary(); 其具体意义略。
COM规范规定: 接口是包含了一组函数的数据结构,通过这组数据结构,客户可以调用组件对象的功能。接口的结构如下所示: 接口指针 pVtable 指针 函数指针1 函数指针2 函数指针3 …… 对象实现 vtable 客户程序使用一个指向接口数据结构的指针来调用接口成员函数。接口指针实际上指向第二个指针,这第二个指针指向一组函数指针(称为接口函数表,通常也成为虚函数表vtable,指向vtable的指针也成为虚表指针pvtable)虚表中每一项为一个4字节的函数指针,指向函数的实现。
使用C++的类来描述接口的结构: (见原理p29) class IDictionary { public : virtual BOOL __stdcall Initialize() = 0; virtual BOOL __stdcall LoadLibrary(String) = 0; virtual BOOL __stdcall InsertWord(String, String) = 0; virtual void __stdcall DeleteWord(String) = 0; virtual BOOL __stdcall LookupWord(String, String *) = 0; virtual BOOL __stdcall RestoreLibrary(String) = 0; virtual void __stdcall FreeLibrary() = 0; };
由上图,类IDictionary的内存结构与COM接口规范要求的完全一致。 this pVtable BOOL Initialize(this*) ; BOOL LoadLibrary(this* ,String) ; BOOL InsertWord(this* ,String, String) ; void DeleteWord(this* ,String) ; BOOL LookupWord(this* ,String, String *) ; BOOL RestoreLibrary(this* ,String) ; void FreeLibrary(this*) ; 由上图,类IDictionary的内存结构与COM接口规范要求的完全一致。
这是使用C语言描述接口的例子(简化自dictionary.h,见原理p27) struct Idictionary //接口包含一个虚表 {struct IDictionaryVtbl *pVtbl; }; struct IDictionaryVtbl //虚表由函数指针构成 { HRESULT ( STDMETHODCALLTYPE *Initialize )( IDictionary * This); //每个函数有一个指向接口本身的指针 HRESULT ( STDMETHODCALLTYPE *LoadLibrary )( IDictionary * This, /* [string][in] */ WCHAR *pFilename); ……//以下函数省略 }
实际上其他的语言,只要能描述虚表和虚表指针的结构就能够描述COM接口。比如Delphi语言对以上接口的描述如下: IDictionary = interface function Initialize: HResult; stdcall; function LoadLibrary(const fName: WideString): HResult; stdcall; ..//其他函数省略 end; Delphi对COM接口的描述非常地简练。
COM把接口与实现分离开的动机: 1。把对象内部的工作细节对客户隐藏起来。使得实现类内部的数据成员的数量、类型以及内部的方法都可以发生变化,而客户程序无需重新编译。(保密性,无缝升级) 2。在运行时询问对象,以便发现对象的扩展功能。(是否实现了其他的接口?) 3。类库与客户不必使用同一C++编译器。
1.2 IDL COM最终的目标是建立二进制级的组件模型。此前我们一直在C++上工作,无论是客户端,还是组件。我们需要语言的独立性。编译器独立性-》语言的独立性 尽管C++功能强大,但是这并不意味着其他的语言都无用武之地。相反,COM规范只定义了接口的特征,它不仅不能规定编译器,也不能约束语言的使用。类似于为了解决链接器兼容性问题,可以通过为每一种可能的链接器都提供一个模块定义文件的方式一样,我们也可以把C++定义的接口翻译到其他的编程语言中。因为COM接口的二进制本质上就是一组vptr/vtbl虚表指针和虚表,所以,很多语言都可以做到。 如果为所有已知的语言对所有的接口都产生映射版本,第一,工作量巨大。第二,由于C++语言非常复杂,并且很容易产生歧义,它所描述的接口不一定能映射到所有可能的语言上,如果客户端使用C++,应该没有问题,但是其他的语言没有与C++能相对应的特征。 前面我们把接口与它的实现分离开来,现在我们要把接口定义和描述接口定义所用的语言之间的联系分离开。 COM提供了这样一种语言,它只用到基本的C语法。同时加入了一些能消除歧义的特征,用来描述接口。称为接口定义语言IDL(Interface Definition Language)
COM IDL以OSF(OpenSoftware Foundation)开放软件基金会的DCE RPC(Distributed Computing Environment RPC)IDL为基础。DCE IDL使得我们可以以语言无关的方式来描述远程调用, IDL编译器也能够产生相应的网络代码。 COM IDL在DCE IDL的基础上加入了一些与COM相关的扩展,比如继承性多态性等。 下面是一个IDL文档的例子,它定义了我们将要完成的字典对象的接口。
import “unknwn.idl”; //类似于include,引入其他的idl文档 #define MaxWordLength 32 [ object, // 表明该接口是一个COM接口而不是一个RPC接口 uuid(54BF6568-1007-11D1-B0AA-444553540000), //全球唯一标志符 ] // [ ] 表示属性 interface IDictionary : Iunknown //Interface 关键字表明接口定义的开始 { HRESULT Initialize(); HRESULT LoadLibrary([in, string] WCHAR *pFilename); HRESULT InsertWord([in, string] WCHAR *pWord, [in, string] WCHAR *pWordUsingOtherLang); HRESULT DeleteWord([in, string] WCHAR *pWord); HRESULT LookupWord([in, string] WCHAR *pWord, [out] WCHAR pWordOut[MaxWordLength]); HRESULT RestoreLibrary([in, string] WCHAR *pFilename); HRESULT FreeLibrary(); }; //以上几个概念,如唯一标识符, 数据类型HRESULT等, IUnknown接口等见后.
它的意义在于以语言中性的方式准确地描述接口的类型. 并且在IDL与其他语言之间建立映射. 从而作为客户端与服务器端的接口描述标准 它的意义在于以语言中性的方式准确地描述接口的类型. 并且在IDL与其他语言之间建立映射. 从而作为客户端与服务器端的接口描述标准. 使得各方在遵循IDL标准的基础上,自由地选择编程语言. 关于COM IDL的完整的参考见msdn-》组件开发-》MIDL定义语言。这里只讲解几个涉及到的内容。
1.3 接口的标识 逻辑名称与实质名称。 两个开发人员可能选择同一个接口名字IDictionary ,然而有不同的成员函数,更有不同的实现方法。除了名字相同以外,两个接口是不兼容的。两个COM组件和各自的客户程序有可能在同一台机器上。冲突的可能性时刻存在。 为了消除名字冲突,所有的接口在设计时分配一个二进制的名字,也就是实质名字,它使用GUID(Global Unique Identifier)来标识接口。GUID是一个128位长的数。能在概率意义上保证不重复。 COM GUID以DCE RPC中用到的UUID为基础。当GUID用来命名接口时,它被称为接口ID(Interface Identifier,IID),COM的实现也使用GUID来标识,这时它被称为类ID(Class Identifier,CLSID) 用文本来表示,往往是以下的格式: {CEBB3FBA-17F5-44c4-987C-631FAE5B80AC} 32个16进制的数字。 GUID的产生方法: GUIDGEN.exe HRESULT CoCreateGuid (GUID * pguid)
1.因为很少编译器支持128位整数,COM定义了一个结构来表示GUID的128位值: typedef struct _GUID { DWORD Data1; WORD Data2; WORD Data3; BYTE Data4[8]; } GUID; 2 Typedef GUID IID;typedef GUID CLSID;//为接口和实现类ID提供了别称。 3 为GUID类型定义了常量引用别称,以使得传送GUID类型参数更高效: #define REFIID const IID& #define REFCLSID const CLSID& 4 COM提供了等价性的测试函数 IsEqualGUID 并为GUID引用类型重载了==和!=操作符。 由于实质接口的名字是GUID而不是字符串,因此上一章的Dynamic_Cast方法要修改,实际上整个IExtensibleObject接口都需要更新。
1.4 方法与结果 几乎每个COM方法的返回值都是HRESULT。它是一个32位的整数,可以向调用者的运行环境提供关于发生了什么类型的错误的信息。比如网络错误、服务器失败等等。 HRESULT分为3部分,严重程度位、操作码、信息码。 比如: S_OK 成功 S_FALSE 成功返回,但有逻辑错误 E_FAIL 失败 E_NOTIMPL 方法没有实现 COM提供了宏来进行操作 SUCCEEDED和FAIL.
1.5 数据类型 为了支持语言独立性和平台独立性,COM IDL提供了一组内置的数据类型,从这些数据类型到C、C++、Java、VisualBasic之间可以建立一个映射。 不使用int数据类型,而使用short或者long类型。因为int类型与平台相关。 COM使用双字节字符(UniCode编码)。使用OLECHA(wchar_t)来表示。 ASCII字符 单字节。0~255 ANSI字符 单字节或多字节 UNICODE 双字节或宽字节 WIN32API 提供了MultiByteToWideChar WideCharToMultiByte. COM 提供了AnsiToUnicode UnicodeToAnsi 为了编写在Visual Basic或ASP中使用的COM对象,应使用BSTR格式的字符串。COM提供了一组函数用于BSTR的操作。 SysAllocString SysFreeString等等。
IDL支持结构类型,并以结构类型作为参数。 COM与IDL支持联合类型(union)。COM定义了一个通用的联合的类型VARIANT.并且提供了一组函数操作VARIANT。 VariantInit VariantClear VariantCopy VariantChangeType 等等。 IDL支持指针类型,并使用C指针语法 函数的每一个参数必须指明是输入、输出或输入输出。以便编译器在生成的代码中进行列集和散集处理。
1.6 MIDL编译器 MIDL.exe是Win32SDK提供的工具。实现从IDL到C/C++的映射.它能编译idl文档以产生以下代码:(以刚才的Dictionary.idl为例) dictionary.h 接口说明的头文件 (C/C++) dictionary_p.c 实现了接口的代理和存根 dictionary_i.c 定义了IDL中的GUID、IID dlldata.c 代理存根的入口函数以及其他数据结构 (DllGetClassObject等函数) dictionary.tlb 类型库文件.可以供VB Java等编译器使用. 关于代理与存根及其它引出函数见后文。 开发工具提供了可视化编写IDL的工具,比如C++Builder的TypeLibEditor。
xxx.IDL文件 MIDL.exe xxx.h C++头文件 xxx_i.c GUID xxx_p.c P/S dlldata.c xxx.tlb 用于客户/服务器 proxy/stub 用于其他编程语 言,如Java、VB
2 IUnknown 接口 2.1 IUnkown接口定义 在仿真程序中我们定义了一个一般性接口: Class IExtensibleObject{ Public: virtual void * Dynamic_cast(const char*pszType)=0; virtual void DuplicatePointer(void)=0; virtual void DestroyPointer(void)=0; } 用来完成接口的通用的任务: Dynamic_cast用于RTTI,它与C++中的dynamic_cast操作符的功能类似。 DuplicatePointer用于通知对象一个接口指针已经被复制了, DestroyPointer用于通知对象一个接口指针被销毁了,它所拥有的资源可以被释放了。 其他的具体功能性的接口,如IFastString, IPersistObject等等都从此接口派生.
COM规范引入IUnkown接口来完成IExtensibleObject的功能。以下是其定义: class IUnknown { public: virtual HRESULT _stdcall QueryInterface( REFIID riid, void **ppv) = 0; virtual ULONG _stdcall AddRef( void) = 0; virtual ULONG _stdcall Release( void) = 0;} IUnkown接口包括三个成员函数,分别对应于IExtensible的三个成员函数,其中QueryInterface用于对查询COM对象的其他接口, AddRef, Release.用于引用计数。IUnkown提供了生存期控制和接口查询,它是唯一不从其他COM接口派生的接口。它是所有COM接口的根源,所有其他COM接口都必须直接或间接地从IUnkown接口派生。 class IDictionary : public IUnknown { public : …… } // IUnknown 接口的名字来源于Tony Williams. 1988. // DonBox 有一张个性车牌: IUNKNOWN
IUnknown接口的IDL定义:( 见SDK的include目录下的unknwn.idl) [ local, object, uuid(00000000-0000-0000-C000-000000000046)// UUID号 ] interface IUnknown {HRESULT QueryInterface( [in] REFIID riid, [out, iid_is(riid)] void **ppvObject); ULONG AddRef(); ULONG Release(); } 我们可以使用IDL语言表示一个接口从另一个接口派生,正如我们已经看到的例子:
import “unknwn.idl”; //类似于include,引入其他的idl文档 [ object, // 表明该接口是一个COM接口而不是一个RPC接口 uuid(54BF6568-1007-11D1-B0AA-444553540000)//全球唯一标志符 ] // [ ] 表示属性 interface IDictionary : Iunknown //Interface 关键字表明接口定义的开始 { ……HRESULT LookupWord([in, string] WCHAR *pWord, [out] WCHAR pWordOut[MaxWordLength]); ……} 使用MIDL编译器把以上IDL映射到C++: #include “unknwn.h” class IDictionary:public IUnknown { public : virtual BOOL __stdcall Initialize() = 0; virtual BOOL __stdcall LoadLibrary(String) = 0; virtual BOOL __stdcall InsertWord(String, String) = 0; virtual void __stdcall DeleteWord(String) = 0; virtual BOOL __stdcall LookupWord(String, String *) = 0; virtual BOOL __stdcall RestoreLibrary(String) = 0; virtual void __stdcall FreeLibrary() = 0; };
使用接口的继承,使得子类接口具有基类接口的功能.特别地,所有的接口都从IUnkown接口派生而来,它们都具有接口的转换,和引用计数的功能. 但是COM规定,接口不能使用多重继承.原因1.多重继承得到的C++抽象基类的表示不再是编译器无关的.(当然,C++总是得到更多的关注.这里我们又一次看到标准的影响)(又,接口当然是抽象基类,因为它的QueryInterface等函数是一直等到实现类才实现的) 原因2.由于COM与DCE RPC的紧密关系.单继承使得COM接口与DCE RPC的接口的映射可以直接进行. 但是,这并不妨碍一个对象支持多个接口,(例子见前章). class FastString : public IFastString ,public IPersistentObject …… 可以使用一个图来表示COM对象和它所支持的接口: FastStirng IUnknown IPersistObject IFastString
2.2 引用计数 COM对象可能实现了多个接口,对每个接口客户可能拥有多个指针。COM提供了一种机制管理资源,使得客户可以以直观的方式使用接口指针。 COM对象通过引用计数来决定是否继续生存下去。每个COM对象都记录了一个“引用计数”的数值,该数值的含义为有多少个有效指针在引用该COM对象。当客户得到了一个指向该对象的接口指针时,引用计数增1;当客户用完了该指针后,引用计数减1。当计数减到0时,COM对象就把自己从内存中清除掉。IUnkown的AddRef和Release成员函数分别进行引用计数的增1和减1操作。 当COM对象支持多接口时,客户对每个接口有多个指针时,以上引用计数的工作方式可以顺利进行。 往往使用COM对象类的成员变量就可以实现引用计数
客户控制规则: 客户创建组件对象并获得第一个接口指针,引用计数为1。 接口指针赋给其他变量,其他变量应调用AddRef。或者说,一个非空的接口指针从后一个内存位置被拷贝到另一个内存位置时,应该调用AddRef.以通知对象又有一个指针指向了它.(使用新指针或旧指针本质上是一样的,因为它们都指向同一个对象,但是为了理解的直观性,统一使用新指针,表示新指针的指向生效) 一个指针被使用完后,应该调用Release。或者说对于已经包含了非空指针的内存位置来说,在重写该位置之前,(包括释放掉和赋予新的内容两种情况),必须使用此指针调用Release.以通知对象“嗨,我不管你了!” 注意:接口指针变量指向的COM对象是在堆(heap)上的,指针使用完后,无需使用delete删除,只需按照引用计数规则使用即可。引用计数能保证内存的正确管理。 如果我们对多个内存之间的关系有特殊的理解.当然可能省略掉多余的AddRef和Release. 但是,这往往会带来理解上的和维护上的困难.弊大于利.
2.3 接口查询 按照COM规范,COM对象可以支持多接口,这是COM对象的升级、更新,体现COM生命力的地方。客户程序在运行时对COM对象的接口进行询问,如果它实现了该接口,则客户可以调用它的服务。对象的多个接口之间是如何联系起来的?在上章的模拟例子中,IExtensibleObjec的 Dynamic_Cast函数起到了RTTI的作用。在COM规范中,是使用IUnkown的另一个成员函数QueryInterface实现的。 HRESULT CDictionary::QueryInterface(const IID& iid, void **ppv); 和Dynamic_Cast不同, QueryInterface使用接口的GUID而不是字符串逻辑名称来区分接口。 当客户创建了COM对象之后,创建函数会给客户返回一个接口指针,由于所有的接口都派生自Iunkown, 它们都有QueryInterface成员函数,客户可以使用它来查询对象支持的其他接口。查询时,客户指定接口的IID号iid,查询函数把查询结果保存在接口指针*ppv中。
接口查询原则: 1。从同一对象的不同接口出发查询到的IUnknown接口完全相同。 也即得到相同的IUnknown子对象. 这并不是总是成立的. 因为比如多重继承的关系,可能有多个IUnknown子对象. 而保持唯一性的目的是调用唯一的接口查询和引用计数. 使得无论何时通过IUnknown接口开始的接口查询总是得到唯一的结果. 而从从同一对象的不同接口出发查询到的其他接口不必完全相同。这可以允许有的接口是动态地生成的,比如tearoff接口等. 2。自反性:查询自己总成功 3。对称性:A-》B成功,则B-》A成功 4。传递性: A-》B,B-》C,则A-》C 这意味者所有的接口处于平等的地位(IUnknown除外).