用ATL建立轻量级的COM对象
第一部分
作者:赵湘宁
本文假设你熟悉C++和COM。
摘要:
ATL——活动模板库(The Active Template Library),其设计旨在让人们用C++方便灵活地开发COM对象。ATL本身相当小巧灵活,这是它最大的优点。用它可以创建轻量级的,自包含的,可复用的二进制代码,不用任何附加的运行时DLLs支持。
由于COM技术良好的口碑,越来越多的程序员已经走进或正在走进COM的编程世界。它就像盛夏里的冰镇啤酒,从来不会让你失望。可惜作为一个C++程序员来说,C++从不与我分享COM的极致以及我对COM的情有独钟。
C++与COM之间若即若离,和平共处,一次又一次在每个对象中用同样简洁的几行代码实现IUnknown。我敢肯定将来C++编译器和链接器会实现C++对象和COM对象之间自然 的无意识的对应和映射,目前这个环境只存在于实验室中,因此它肯定不是一个你我今天可以购买的产品。眼下可得到的最接近这个环境的东西就是活动模板库——ATL。
为什么使用ATL?
ATL是在单层(single-tier)应用逐渐过时,分布式应用逐渐成为主流这样一个环境中诞生的, 它最初的版本是在四个C++头文件中,其中有一个还是空的。它所形成的出色的构架专门用于开发现代分布式应用所需的轻量级COM组件。作为一个模块化的标准组件,ATL不像MFC有厚重的基础结构,省时好用的库使得成百上千的程序员一次又一次轻松实现IUnknown 和IClassFactory。
ATL的构架并不打算包罗万象,无所不能。其第一个版本对实现IUnknown,IClassFactory,IDispatch,IconnectionPointContainer及COM枚举提供非常 到位的支持。第二个版本除了可以编写ActiveX控件外,还对最初的第一个版本中ATL类进行了增强。ATL不提供集合(collections)和串(strings)的处理 ,它假设你用标准的C++库进行这些处理;不支持ODBC——这个世界正在转移到基于COM的不需要包装的数据存取方式;不支持WinSock打包类--sockets本身也是新的东西;ATL也不支持完整的Win32 API打包类——ATL2.0的实现机制提供了对话框和WndProcs支持。此外ATL中没有MFC中的文档/视图模型。取而代之的是ATL那更具伸缩性和灵活 性的通过COM接口(如ActiveX控件)与基于UI的对象之间的沟通模式。
使用正确的工具非常关键。如果你正在编写一个不可见的COM组件,那么ATL与MFC比起来,从开发效率,可伸缩性,运行时性能以及可执行文件大小各方面来看,ATL可能 都是最好的选择。对于现代基于ActiveX控件的用户界面,ATL所产生的代码也比MFC更小更快。另一方面,与MFC的类向导相比,ATL需要更多的COM知识。ATL与STL一样,对于单层应用没什么帮助,而MFC在这方面保持着它的优势。
ATL的设计在很大程度上来自STL的灵感,STL与所有ANSI/ISO兼容的C++编译器一起已经被纳入成为标准C++库的一部分。像STL一样,ATL大胆使用C++模板。模板是C++中众多具有争议的特性之一。每每使用不当都会导致执行混乱,降低性能 和难以理解的代码。明智地使用模板所产生的通用性效果和类型安全特性则是其它方法所望尘莫及的。ATL与STL一样陷入了两个极端。幸运的是 在L大胆使用C++模板的同时,编译器和链接器技术也在以同样的步伐向前发展。为当前和将来的开发进行STL和ATL的合理选择。
尽管模板在内部得到广泛的使用,但是在用ATL技术时,你不用去敲入或关心那些模板中的尖括弧。因为ATL本身带有ATL对象向导(参见图一):
图一 ATL 对象向导
对象向导产生大量基于ATL模板类缺省的对象实现代码(即框架代码)。这些缺省的对象类型如附表一所列。ATL对象向导允许任何人 快速建立COM对象并且在分分钟之内让它运行起来,不用去考虑COM或ATL的细节问题。当然,为了能充分驾驭ATL,你必须掌握C++,模板和COM编程技术。对于大型的对象类,只要在ATL对象向导所产生的缺省实现(框架代码)中加入方法实现来输出定制接口,这也是大多数开发人员开始实现COM对象时的重点所在。
初次接触ATL时,其体系结构给人的感觉是神秘和不可思议。
HelloATL是一个最简单的基于ATL的进程内服务器源代码 以及用SDK(纯粹用C++编写)实现的同样一个进程内服务器源代码。在真正构建出一个COM组件之前,代码需要经过反反复复多次斟酌和修改。对于想加速开发COM组件速度的主流组件开发人员来说,ATL体系结构并不是什么大问题,因为对象向导产生了所需要的全部框架代码,只 要你加入方法定义即可。对于认真的COM开发人员和系统编程人员来说,ATL提供了一个用C++建立COM组件的高级的,可扩展的体系结构。一旦你理解和掌握了这个体系结构并能驾驭对象向导,你就会看到ATL的表现能力和强大的功能 ,它完全可以和原始的COM编程技术媲美。
另外一个使用ATL开发COM组件的理由是Visual C++ 5.0+集成开发环境(IDE)对ATL的高度支持。 微软在Visual C++ 5.0+中将ATL所要用到的接口定义语言(IDL)集成到了C++编辑器中。(待续)
用ATL建立轻量级的COM对象
第二部分
作者:赵湘宁
起步篇
在本文的第一部分,我们简要介绍了ATL的一些背景知识以及ATL所面向的开发技术和环境。在这一部分 将开始走进ATL,讲述ATL编程的基本方法、原则和必须要注意的问题。
理解ATL最容易的方法是考察它对客户端编程的支持。对于COM编程新手而言,一个棘手的主要问题之一是正确管理接口指针的引用计数。COM的引用计数法则是没有运行时强制 性的,也就是说每一个客户端必须保证对对象的承诺。
有经验的COM编程者常常习惯于使用文档中(如《Inside OLE》)提出的标准模式。调用某个函数或方法,返回接口指针,在某个时间范围内使用这个接口指针,然后释放它。下面是使用这种模式的代码例子:
void f(void) { IUnknown *pUnk = 0; // 调用 HRESULT hr = GetSomeObject(&pUnk); if (SUCCEEDED(hr)) { // 使用 UseSomeObject(pUnk); // 释放 pUnk->Release(); } }
这个模式在COM程序员心中是如此根深蒂固,以至于他们常常不写实际使用指针的语句,而是先在代码块末尾敲入Release语句。这很像C程序员使用switch语句时的条件反射一样,先敲入break再说。
其实调用Release实在不是什么可怕的负担,但是,客户端程序员面临两个相当严重的问题。第一个问题与获得多接口指针有关。如果某个函数需要在做任何实际工作之前获得三个接口指针,也就是说在第一个使用指针的语句之前必须要由三个调用语句。在书写代码时,这常常意味着程序员需要写许多嵌套条件语句,如:
void f(void) { IUnknown *rgpUnk[3]; HRESULT hr = GetObject(rgpUnk); if (SUCCEEDED(hr)) { hr = GetObject(rgpUnk + 1); if (SUCCEEDED(hr)) { hr = GetObject(rgpUnk + 2); if (SUCCEEDED(hr)) { UseObjects(rgpUnk[0], rgpUnk[1], rgpUnk[2]); rgpUnk[2]->Release(); } rgpUnk[1]->Release(); } rgpUnk[0]->Release(); } }
像这样的语句常常促使程序员将TAB键设置成一个或两个空格,甚至情愿使用大一点的显示器。但事情并不总是按你想象的那样,由于种种原因项目团队中的COM组件编程人员往往得不到 所想的硬件支持,而且在公司确定关于TAB键的使用标准之前,程序员常常求助于使用有很大争议但仍然有效的“GOTO”语句:
void f(void) { IUnknown *rgpUnk[3]; ZeroMemory(rgpUnk, sizeof(rgpUnk)); if (FAILED(GetObject(rgpUnk))) goto cleanup; if (FAILED(GetObject(rgpUnk+1))) goto cleanup; if (FAILED(GetObject(rgpUnk+2))) goto cleanup; UseObjects(rgpUnk[0], rgpUnk[1], rgpUnk[2]); cleanup: if (rgpUnk[0]) rgpUnk[0]->Release(); if (rgpUnk[1]) rgpUnk[1]->Release(); if (rgpUnk[2]) rgpUnk[2]->Release(); }
这样的代码虽然不那么专业,但至少减少了屏幕的水平滚动。
使用以上这些代码段潜在着更加棘手的问题,那就是在碰到C++异常时。如果函数UseObjects丢出异常,则释放指针的代码被完全屏蔽掉了。 解决这个问题的一个方法是使用Win32的结构化异常处理(SEH)进行终结操作:
void f(void) { IUnknown *rgpUnk[3]; ZeroMemory(rgpUnk, sizeof(rgpUnk)); __try { if (FAILED(GetObject(rgpUnk))) leave; if (FAILED(GetObject(rgpUnk+1))) leave; if (FAILED(GetObject(rgpUnk+2))) leave; UseObjects(rgpUnk[0], rgpUnk[1], rgpUnk[2]); } __finally { if (rgpUnk[0]) rgpUnk[0]->Release(); if (rgpUnk[1]) rgpUnk[1]->Release(); if (rgpUnk[2]) rgpUnk[2]->Release(); }
可惜Win32 SHE在C++中的表现并不如想象得那么好。较好的方法是使用内建的C++异常处理模型,同时停止使用没有加工过的指针。标准C++库有一个类:auto_ptr,在其析构函数中定 死了一个操作指针的delete调用(即使在出现异常时也能保证调用)。与之类似,ATL有一个COM智能指针,CComPtr,它的析构函数会正确调用Release。
CComPtr类实现客户端基本的COM引用计数模型。CComPtr有一个数据成员,它是一个未经过任何加工的COM接口指针。其类型被作为模板参数传递:
CComPtr<IUnknown> unk; CComPtr<IClassFactory> cf;
缺省的构造函数将这个原始指针数据成员初始化为NULL。智能指针也有构造函数,它的参数要么是原始指针,要么是相同类型的智能参数。不论哪种情况,智能指针都调用AddRef控制引用。CComPtr的赋值操作符 既可以处理原始指针,也可以处理智能指针,并且在调用新分配指针的AddRef之前自动释放保存的指针。最重要的是,CComPtr的析构函数释放保存的接口(如果非空)。请看下列代码:
void f(IUnknown *pUnk1, IUnknown *pUnk2) { // 如果非空,构造函数调用pUnk1的AddRef CComPtr unk1(pUnk1); // 如果非空,构造函数调用unk1.p的AddRef CComPtr unk2 = unk1; // 如果非空,operator= 调用unk1.p的Release并且 //如果非空,调用unk2.p的AddRef unk1 = unk2; //如果非空,析构函数释放unk1 和 unk2 }
除了正确实现COM的AddRef 和 Release规则之外,CComPtr还允许实现原始和智能指针的透明操作,参见附表二所示。也就是说下面的代码按照你所想象的方式运行:
void f(IUnknown *pUnkCO) { CComPtr cf; HRESULT hr; // 使用操作符 & 获得对 &cf.p 的存取 hr = pUnkCO->QueryInterface(IID_IClassFactory,(void**)&cf); if (FAILED(hr)) throw hr; CComPtr unk; // 操作符 -> 获得对cf.p的存取 // 操作符 & 获得对 &unk.p的存取 hr = cf->CreateInstance(0, IID_IUnknown, (void**)&unk); if (FAILED(hr)) throw hr; // 操作符 IUnknown * 返回 unk.p UseObject(unk); // 析构函数释放unk.p 和 cf.p }
除了缺乏对Release的显式调用外,这段代码像是纯粹的COM代码。有了CComPtr类的武装,前面所遇到的麻烦问题顿时变得简单了:
void f(void) {
CComPtr<IUnknown> rgpUnk[3];
if (FAILED(GetObject(&rgpUnk[0]))) return;
if (FAILED(GetObject(&rgpUnk[1]))) return;
if (FAILED(GetObject(&rgpUnk[2]))) return;
UseObjects(rgpUnk[0], rgpUnk[1], rgpUnk[2]);
}
由于CComPtr对操作符重载用法的扩展,使得代码的编译和运行无懈可击。
假定模板类知道它所操纵的指针类型,你可能会问:那为什么智能指针不能在它的功能操作符或构造函数中自动调用QueryInterface,从而更有效地包装IUnknown呢?在Visual C++ 5.0出来以前,没有办法将某个接口的GUID与它的本身的C++类型关联起来——Visual C++ 5.0用私有的declspec将某个IID与一个接口定义绑定在一起。因为ATL的设计 考虑到了它要与大量不同的C++编译器一起工作,它需要用与编译器无关的手段提供GUID。下面我们来探讨另一个类——CComQIPtr类。
CComQIPtr与CComPtr关系很密切(实际上,它只增加了两个成员函数)。CComQIPtr必须要两个模板参数:一个是被操纵的指针类型 ,另一个是对应于这个指针类型的GUID。例如,下列代码声明了操纵IDataObject 和IPersist接口的智能指针:
CComQIPtr<IDataObject, &IID_IDataObject> do; CComQIPtr<IPersist, &IID_IPersist> p;
CComQIPtr的优点是它有重载的构造函数和赋值操作符。同类版本(例如,接受相同类型的接口)仅仅AddRef右边的赋值/初始化操作。这实际上就是CComPtr的功能。异类版本(接受类型不一致的接口)正确调用QueryInterface来决定是否这个对象确实支持所请求的接口:
void f(IPersist *pPersist) { CComQIPtr<IPersist, &IID_IPersist> p; // 同类赋值 - AddRef''s p = pPersist; CComQIPtr<IDataObject, &IID_IDataObject> do; // 异类赋值 - QueryInterface''s do = pPersist; }
在第二种赋值语句中,因为pPersist是非IDataObject *类型,但它是派生于IUnknown的接口指针,CComQIPtr通过pPersist调用QueryInterface来试图获得这个对象的IDataObject接口指针。如果QueryInterface调用成功,则此智能指针将含有作为结果的原始IDataObject指针。如果QueryInterface调用失败,则do.p将被置为null。如果QueryInterface返回的HRESULT值很重要,但又没有办法从赋值操作获得其值时,则必须显式调用QueryInterface。
既然有了CComQIPtr,那为什么还要CComPtr呢?由几个理由:首先,ATL最初的发布版本只支持CComPtr,所以它就一直合法地保留下来了。其二(也是最重要的理由),由于重载的构造函数和赋值操作,对IUnknown使用CComQIPtr是非法的。因为所有COM接口的类型定义都必须与IUnknown兼容。
CComPtr<IUnknown> unk;
从功能上将它等同于
CComQIPtr<IUnknown, &IID_IUnknown> unk;
前者正确。后者是错误的用法。如果你这样写了,C++编译器将提醒你改正。
将CComPtr作为首选的另外一个理由可能是一些开发人员相信静悄悄地调用QueryInterface,没有警告,削弱了C++系统的类型。毕竟,C++在没有进行强制类型转换的情况下不允许对类型不一致的原始指针 进行赋值操作,所以为什么要用智能指针的道理也在这,幸运的是开发人员可以选择最能满足需要的指针类型。
许多开发人员将智能指针看成是对过于的复杂编程任务的简化。我最初也是这么认为的。但只要留意它们使用COM智能指针的方法。就会逐渐认识到它们引入的潜在危险与它们解决的问题一样多。
关于这一点,我用一个现成的使用原始指针的函数为例:
void f(void) { IFoo *pFoo = 0; HRESULT hr = GetSomeObject(&pFoo); if (SUCCEEDED(hr)) { UseSomeObject(pFoo); pFoo->Release(); } }
将它自然而然转换到使用CComPtr。
void f(void) { CComPtr<IFoo> pFoo = 0; HRESULT hr = GetSomeObject(&pFoo); if (SUCCEEDED(hr)) { UseSomeObject(pFoo); pFoo->Release(); } }
注意CComPtr 和 CComQIPtr输出所有受控接口成员,包括AddRef和Release。可惜当客户端通过操作符->的结果调用Release时,智能指针很健忘 ,会二次调用构造函数中的Release。显然这是错误的,编译器和链接器也欣然接受了这个代码。如果你运气好的话,调试器会很快捕获到这个错误。
使用ATL智能指针的另一个要引起注意的风险是类型强制转换操作符对原始指针提供的访问。如果隐式强制转换操作符的使用存在争议。当 ANSI/ISO C++ 委员会在决定采用某个C++串类时,他们明确禁止隐式类型转换。而是要求必须显式使用c_str函数在需要常量char *(const char *)的地方传递标准C++串。ATL提供了一种隐含式的类型转换操作符顺利地解决了这个问题。通常,这个转换操作符可以根据你的喜好来使用,允许你将智能指针传递到需要用原始指针的函数。
void f(IUnknown *pUnk) { CComPtr unk = pUnk; // 隐式调用操作符IUnknown *() CoLockObjectExternal(unk, TRUE, TRUE); }
这段代码能正确运行,但是下面的代码也不会产生警告信息,编译正常通过:
HRESULT CFoo::Clone(IUnknown **ppUnk) { CComPtr unk; CoCreateInstance(CLSID_Foo, 0, CLSCTX_ALL, IID_IUnknown, (void **) &unk); // 隐式调用操作符IUnknown *() *ppUnk = unk; return S_OK; }
在这种情况下,智能指针(unk)对原始值针**ppUnk的赋值触发了与前面代码段相同的强制类型转换。在第一个例子中,不需要用AddRef。在第二个例子中,必须要用AddRef。
有关使用智能指针的更详细一般信息,请参见Scott Meyer的《More Effective C++》(Addison-Wesley, 1995年出版)。国内目前还没有这本书的中译本或影印本。有关COM智能指针的更多特定信息,请参见Don Box的一篇关于智能指针的专题文章http://www.develop.com/dbox/cxx/SmartPointer.htm。 (待续)