GUI 医生点评:组件、COM 和 ATL (关于COM的由来和一些基本概念,摘自MSDN)


GUI 医生点评:组件、COM 和 ATL

GUI 医生 Online
第一部分:1998 年 2 月 2 日
第二部分:1998 年 2 月 9 日
第三部分:1998 年 2 月 23 日
第四部分:1998 年 3 月 2 日

编辑评注 在 Visual Studio 6.0 发布时,只能提供本栏目的第一到第四部分。在 1998 年 7 月发布 MSDN Library 时,本栏目的全部内容将再版。您也可以参阅 MSDN Online (http://www.microsoft.com/msdn/) 上每周的“GUI 医生 Online”,并且查看本栏目和以前栏目的归档部分。

目录

第一部分:您打算解释 COM 吗?以前不是已经有人解释过了吗?
第二部分:COM 基本概念
第三部分:获取对象和接口
第四部分:对象类和对象库

第一部分:您打算解释 COM 吗?以前不是已经有人解释过了吗?

医生听到您说:“您要干什么?解释 COM?已经有人写了这方面的书!”

确实有书了,而且 GUI 医生还可以推荐很多好书。他很喜欢 Dale Rogerson 的 Inside COM (Microsoft Press, 1997) 和 Don Box 的 Essential COM (Addison-Wesley, 1998)。Adam Denning 的 ActiveX Controls Inside Out(Microsoft Press, 1996),Sing Li 和 Panos Economopoulos 的 Professional Visual C++ 5 ActiveX/COM Control Programming (WROX Press, 1997) 也是好书。当然,您也应该看 Kraig Brockschmidt 的 Inside OLE, 2nd Edition (Microsoft Press, 1995),这是 OLE 参考的最好印刷品。(当然,MSDN 也是最好的参考,不只是医生这样认为。)另外,如果您想要看客观地解释组件软件的书,请参考 Clemens Szyperski 的新书 Component Software: Beyond Object-Oriented Programming (Addison-Wesley, 1998)。

“那又怎么样,”您问到,“GUI 医生还能比这些书说得更好吗?”

“根本不会。”医生这样回答。这些作者(和其他作者)再加上文档确实已经全说到了。如果您有时间看这些书,就是这样。

“那么您要做的就是,”您叫道,“为我们轻描淡写地介绍一番 COM 和 ATL,而且每周只有一小段!”

GUI 医生回敬道:“您很聪明。这样就比我预料的要容易多了。”

组件?我是在编写一个立体声系统吗?

如果您很熟悉 Visual Basic,您就会很熟悉使用组件编写程序:您既使用 visual ActiveX 控件(例如微调按钮),也使用非可视的 ActiveX 组件(例如数据库访问对象)。很难找到一个没有频繁使用预制的可重用组件的重要 Visual Basic 程序。但是,虽然您重用了大量的组件,大多数人并没有为自己编写过完整的可重用组件。

如果您使用 C++ 进行编程,那么您对重用会有不同的感受。人们说 C++ 和面向对象编程可以很容易地实现对象重用,但是您的经验怎么样?您编写过可重用对象库吗?无疑少数人确实编写过,但是大部分人并非如此。并且,就算有了这样的一个库,我们能很好地利用它吗?并不只是由于缺乏规则从而使我们难以重用代码;事实是,代码很难重用(代码好象从不按我们需要的方式执行),而编写可重用代码则更难(很难既足够通用又足够实用)。

结果是,C++ 并没有使创建可重用二进制组件变得容易,而是使重用源代码变得相对容易。要知道,大多数主要的 C++ 库是以源代码形式发布的,而不是编译后的形式。为了正确地继承一个对象,很有必要查看这些源代码,并且依赖于原库的实现细节来进行重用是很容易的(也是有必要的)。更糟的是,修改源代码并自己连编原库是很诱人的(或是必要的)。(到底有多少自连编的 MFC?谁也不知道 . . . .)

那么让我们重用二进制对象,而不是源代码

那么又怎能重用二进制对象呢?Windows 程序员首先想到的答案很简单:使用动态链接库 (DLL)。使用 DLL 确实管用,毕竟 Windows 本身就主要是一组 DLL。但是,还有一些问题。

首先,DLL 不是独立于编程语言的。即使是使用 C 语言编写的 DLL,也很容易更改调用约定(按什么顺序将什么参数入栈),从而使该 DLL 只能用在 C 程序中。就算 Windows 使用的调用约定已作为 Windows 系统的标准规定得很完备,但是,医生仍遇到过由于调用约定不匹配而导致的 DLL 失败。

为 DLL 编写一个 C 语言的接口有一些重要限制。首先,这限制了您进行面向对象编程,因为,C++ 的面向对象特性需要对函数名进行修饰,名称修饰没有统一的标准。有些情况下,甚至同一编译器的不同版本对名称的修饰也会不同。第二,实现多态性将很困难。通过创建包装类,您可以解决这些问题,但是这样做是痛苦的。GUI 医生没有痛苦过。(起码没有太痛苦过。)

即使您解决了名称修饰问题,并进而成功地链接到了 DLL,当更新对象时也会出现其他问题。

首先,如果当您更新对象时要向其中添加任何虚函数,那么您就会像浑身插满软管的病人一样动弹不得。您可能会认为在对象的末尾添加新函数不会出问题,但是实际上并非如此:这样会将所有从您的对象派生的对象的虚函数表入口平移。并且,因为调用虚函数需要使用虚函数表中的固定偏移,以便调用正确的函数,所以您不能对虚函数表进行任何更改——至少不重新编译每个有关的程序(这些程序使用您的对象或任何从您的对象派生的对象),就不能进行更改。很明显,每次更新您的对象时都重新编译全世界,不是个好主意。

其次,如果您在客户程序中使用 new 来分配对象,那么您要更改该对象的大小(即添加任何数据),就必须重新编译全世界。

最后(也是最重要的),更新 DLL 简直就是一场恶梦,因为您处于两难境地,两种选择都很令人倒胃口:要么通过覆盖 DLL 来“就地”更新该 DLL,要么重新命名一个新的版本。就地更新 DLL 很糟糕:即使您保持接口的统一,DLL 的某些用户程序也会被破坏,这样的几率很高。GUI 医生就不必一一告诉您业界(包括 Microsoft 在内)因此问题而遇到的所有麻烦了。

另一种方法,即使用一个新的 DLL 名称,至少能让原来正常运转的系统继续正常运转。但是,代价是需要占用硬盘空间(也许当普通的硬盘有 3GB 左右大小时,这不是个大问题),而第二个代价是:增加了内存的使用。如果用户使用了两种版本的 DLL,那么在用户的工作集内就会存在两个代码极其相似的复本。例如,通常,当您检查用户内存的使用情况时,就会发现两三个版本的 Visual Basic 运行时模块或 Microsoft Foundation Class (MFC) DLL。既然几乎所有的 Windows 系统通常都使用比物理内存更多的虚拟内存,增加工作集的大小就意味着严重的性能问题,表现在增加了交换到硬盘上的虚拟内存的大小。(这就为 Brook 定律提供了反例:向一个慢的系统添加更多的内存反而会使它更快。)

在理想情况下,您希望能让用户(或应用程序)来选择使用哪个版本。这对于静态链接的 DLL 是极其困难的,但是对动态加载的 DLL 就很容易了。

公平地讲,应该指出,C++ 从来也没打算解决这类问题。C++ 的原本用途是在只有一个文件的程序中重用代码,这样所有的对象是同时编译的。C++ 并不打算提供一种建立可重用二进制组件的方法,以便可以混合使用不同版本和时间的组件。顺便说一句,Java 的缔造者注意到了这些问题,这些不足是开发 Oak 的主要原因,Oak 后来变成了 Java。

Java 的情况又如何?

Java 确实解决了这些问题中的一部分,但是它也引入了一些自己的问题。最大的问题是 Java 组件(通常是 JavaBeans)只能用于使用 Java 编写的程序。现在,Microsoft 虚拟机 (VM) 确实允许您将 JavaBeans 用作 COM 对象,从而可以在任何语言中使用它们。而且,Sun 确实有一个 Java/ActiveX bridge。但是,总的说来,除非在 Windows 中运行程序,否则 Java 仍是一个单语言的系统:Java 组件只能用于 Java 程序。并且,大多数情况下,为了使用 Java,您必须从头开始重新编写系统。(是的,您可以进行本地调用,但是,使用 Java Native Interface(JNI) 非常麻烦,而且程序将再也无法移植了。)GUI 医生认为这很不可取,所以他很高兴 Microsoft 虚拟机 (VM) 更为灵活,至少对 Windows 而言是这样。没有哪种语言,甚至包括 C++、Visual Basic 或 Java,能适合于每位程序员和解决每个问题。

当您用 Java 编写程序时,还必须确定要使用的组件是本地的(在您的计算机上)还是远程的(在另一台计算机上),而且使用本地和远程组件的方法很不相同。

Java 还有一些其他问题,使它还不能成为满足所有组件需要的理想工具。首先,它还没有真正可靠的方法解决版本问题。(Microsoft VM 中的打包管理程序对此会有很大帮助。)其次,Java 多少要比 C++ 慢一些。GUI 医生注意到,在一种 Java 联机杂志上出版的“象 C++ 一样快”的性能测试中,遗漏了 Java 会表现不好的测试。能想到的两个例子是字符串和数组操作(Java 必须在每次访问时进行越界检查),以及初始的方法程序调用(在第一次调用时,Java 必须在类中的一个表内按签名查找该方法程序。当然在后续的调用会很快,那个 Java 杂志所测试的正是后续的调用。)。最后,Java 的“一次一个类”的加载机制会比一次加载所有的代码慢得多(即使代码很少!),因为它需要更多的文件或 HTTP 事务,这些都需要极高的开销。

即使您是按能够获得良好性能的方法使用 Java,当您从另一种语言中使用 Java 组件时,性能也会很糟,因为需要存在翻译层,以连接不相似的语言和对象模型。

Java 的闪光之处在于存在这样一种可能,即您可以在不同的计算机上使用编译好的组件,而不必为每种计算机的处理器和操作系统重新编译。但是,这经常并不是那么回事,在要支持的每种平台上,都需要测试和调试您的组件。

那么,还有别的选择吗?

正如所证实的,可以使用 C++ 连编 DLL 和其他可重用的二进制组件。在 Dale Rogerson 的书 Inside COM 和 Don Box 的书 Essential COM 中,他们都以一个要重用的 C++ 类开始,然后使用一些聪明的技巧解决了我上面列出的每个问题(还有其他一些问题)。毫不奇怪地,他们最后都得出了同样的结果,即 COM。也就是说,二进制代码重用的每个问题的解决方法都是 COM 的一个重要特性。(如果您想要现在就查看这个过程,请查阅 Markus Horstmann 的文章“从 CPP 到 COM”。)

虽然 COM 的“母语”是 C++,从 C 程序中也可以很方便地使用 COM ——甚至头文件就支持这样做。而且,通过一些技巧,在任意一种语言中都可能实现双向 COM 支持,例如 Visual Basic、Java、Delphi 等等。(“双向 COM 支持”的意思是指,有可能既在一种语言中使用 COM 对象,又使用这种语言编写 COM 对象。)在语言的运行时模块中实现 COM 兼容性的工作并不简单,但是好处是巨大的:一旦这样做了,您就拥有了大量已经编写和调试好的 COM 对象,以供您使用。而且,COM 组件会有广阔的市场——Giga Information Group 估计当前的市场是每年 4 亿美元,在三年后预计为 30亿美元。(COM 组件市场比 Microsoft 的增长还要快!)要注意,这些市场预测是针对第三方 COM 对象的,不包括由 Microsoft 提供的 COM 组件。

COM 的另一个关键特性是支持三种类型的对象:进程内 (DLL)、本地(同一计算机上不同进程中的 EXE)以及远程(不同计算机中的 DLL 或 EXE,通过分布式 COM 或称 DCOM 来通讯)。您在编写使用 COM 组件的代码时,不必考虑(甚至知道)最后要使用哪种 COM 对象,因此可使用完全相同的代码来连接进程内、本地或远程对象。COM 是怎样连接到正确的对象上的呢?是这样,它在注册表中查找对象的 Class ID——注册表项告诉 COM 哪种对象可用。COM 做其余的工作,包括启动进程和通过网络通讯。(注意:不同种类的 COM 对象存在性能差异,对此您需要心中有数——但是,不管您最后使用哪种对象,至少用于连接和使用对象的代码是完全相同的。)

但是,COM 并不能解决世界上的所有问题。例如,当您更新一个组件时,仍有可能破坏使用该组件的程序。(可能由于 COM 强制地为组件赋予“黑盒子”视图,从而不可能了解组件的实现细节,也就使得这种破坏并不普遍,但是仍会发生。)所以,您仍需要选择是就地更新组件而承担破坏的风险,还是为新组件使用新的 Class ID。但是,有了 COM,确实可以较为容易地编写一些代码,以使用户(或应用程序)能够选择使用哪种版本的组件,而不必重新编译。

回忆一下,几乎可以使用任何语言来编写和使用 COM 组件,而且它们可以存放在任何计算机上。这很好。但是,跨平台支持又怎样呢?

跨平台的情况是喜忧参半的。忧的是,现在除了 Win32,还没有太多其他平台上的 COM。有一些移植到非 Windows 平台的 COM,但是不多。不过,这只是忧的一面。

好消息是,很快会进行很多的移植,包括对最常见的 UNIX 版本和对 MVS 的移植。而且,Microsoft 在亲自进行一些移植工作。COM 和 DCOM 在您最喜欢的主机和 UNIX 计算机中可用的日子不会太远了,按计划,用于 UNIX 的 COM 在二月份发布。想想看,在一些快速的主机上运行使用任意的语言编写的远程 COM 对象,而您可以通过您的计算机上的任何语言(Visual Basic、Java、Delphi、C++)访问该主机,这是多么酷啊!请查阅 Microsoft COM Web 站点 (http://www.microsoft.com/com/)上的最新信息。

所以,如果您正在为 Windows 编程,您一定会考虑编写 COM 对象,而不管您是使用 Visual Basic、Java、C++、Delphi 还是其他 COM 兼容语言进行开发的。您编写的对象可以用在本地计算机上或远程使用,而不用重新连编您的组件或组件的客户程序,这多亏了 COM 和 DCOM 的魔力。而且如果您想要让您的方案在非 Windows 平台上运行,COM 正越来越成为一种合适的手段,所以很值得认真地探究和考虑一下。

下一步:您应该知道的 COM 基本概念

下周我们将探讨 COM 的基本概念:对象、接口和模块。而且,如果有时间,我们将深入研究一个简单 COM 对象的 C++ 代码(如果没有时间,就下一周再说。)

第二部分:COM 基本概念

回顾与前瞻

第一部分,医生探讨了为什么 C++ 这样的语言不能解决用二进制组件构造软件的问题。这个问题的关键是,C++ 原本不打算解决这个问题;相反,它本来的用途是在单个可执行程序中,较为容易地重用源代码。C++ 很好地做到了这一点。但是我们希望能够混合和匹配不同供应商的组件,而在组件每次改变时不必重新连编系统的一部分(或全部)。我们已经讨论了很多理由,说明为什么 C++ 模型对此无能为力。

那又怎样?您必须抛弃 C++ 吗?不,但是您必须以与以前所熟悉的方法稍有不同的方法使用它。这就是我们下一步将要讨论的,即如何从 C++ 中使用 COM。

这是否意味着,如果您不是一位 C++ 程序员,就不要阅读下去了?不,因为不管您使用的是哪种 COM 兼容的语言(Visual Basic、Visual J++、Delphi 等等),这些语言都会做我们讨论的事情(或许不只是机房中的事情?)。所以,如果您继续读下去,您会对这些 ActiveX 控件和 COM 组件的工作机制获得有益的了解。

好吧,那么什么是 COM 呢?

Components Object Model (COM) 是软件组件互相通讯的一种方式。它是一种二进制和网络标准,允许任意两个组件互相通讯,而不管它们是在什么计算机上运行(只要计算机是相连的),不管各计算机运行的是什么操作系统(只要该操作系统支持 COM),也不管该组件是用什么语言编写的。COM 还提供了位置透明性:当您编写组件时,其他组件是进程内 DLL、本地 EXE 还是位于其他计算机上的组件,对您而言都无所谓。(当然会有性能区别,但是,即使改变了其他组件的位置,您也不必重新编写什么,这是关键所在。)

对象

COM 是基于对象的——但是这种对象概念与您熟悉的 C++ 或 Visual Basic 中的对象不太一样。(顺便说一下,“对象”和“组件”几乎是同一个东西。GUI 医生在谈论应用程序的结构时愿意说成“组件”,而在谈论实现时愿意说成是“对象”)

首先,COM 对象被很好地封装起来。您无法访问对象的内部实现细节;您无法知道对象使用了什么数据结构。实际上,对象的封装是如此的严密,以致于 COM 对象通常被描绘为盒子。图 1 描绘了一个完全封装的对象。请注意,实现细节是如何向您隐藏的。


图 1 一个完全封装的非 COM 对象。

封装是不错,但是通讯又怎么样呢?在这种状况下,我们无法与这个盒子中的组件通讯。很明显,这个方法不行。

接口:与对象的通讯

这时就需要接口了。访问 COM 对象的唯一途径是通过接口。我们可以象图 2 所示,在对象上描绘一个名为IFoo 的接口。


图 2 带有接口的对象—也不是 COM。

在对象旁边支出来的象棒棒糖的东西就是接口——这里是 IFoo 接口。该接口是与该对象通讯的唯一途径。医生认为,将接口看成一个插件连接器,比看成棒棒糖会更有用。正是通过它您才能为对象添加功能,可将它看成是录相机或电视机的天线输入。

接口有两个含义。首先,它是一组可以调用的函数,由此您可以让该对象做某些事情。在 C++ 中,接口是用抽象基类代表的。例如,IFoo 的定义可能是:

class IFoo {
   virtual void Func1(void) = 0;
   virtual void Func2(int nCount) = 0;
};

我们现在暂时忽略返回值和继承性,但是要注意在接口中可以有多个函数,而且这些函数都是纯虚函数:它们没有在 Ifoo 类中实现。我们在这里并非要定义行为,而只是要定义在接口中有什么函数(当然,真正的对象必须要有实现部分,有关的详细内容稍后讲解。)

其次,也是更重要的,接口是组件及其客户程序之间的协议。也就是说,接口不但定义了可用什么函数,也定义了当调用这些函数时对象要做什么。这种语义定义不是以对象的特定实现来表达的,所以无法用 C++ 代码来表达该定义(虽然我们可以用 C++ 提供一种特定实现)。相反,该定义是以对象的行为来定义的,所以对该对象和(或)也实现该接口(协议)的新对象进行修订是可能的。实际上,对象可以按自己选择的任何方式实现该协议(只要该对象遵守该协议)。也就是说,该协议必须(医生恶狠狠地说)书写在源代码之外的文档中因为客户程序无法(也不必)得到源代码,所以这一点尤其重要。

这种特殊协议的观念对 COM 和组件软件都是很至关重要的。没有“坚不可摧”的协议,就不可能交换组件。

接口协议象钻石一样永久

在 COM 中,一旦您通过发布一个组件来“公布”了一个接口协议,该协议就不能变更了——不能以任何方式变更。您不能添加、不能删除、不能修改。为什么?因为其他组件依赖于该协议。如果更改了该协议,您将会破坏那些软件。只要遵守该协议,您可以改进内部的实现。

如果您忘记了什么怎么办?如果需求发生了变化怎么办?难道整个世界都要永远停滞不前吗?

答案很简单:编写一个新协议。标准的 OLE 接口列表有很多这样的协议:IClassFactory 和IClassFactory2IViewObject 和 IViewObject2,等等。当然,您也可以提供一个 IFoo2。(我敢肯定,您已注意到接口名称按约定是以大写字母 I 开头的。)

如果我编写了一个新协议,那么那些只知道旧协议的软件如何继续使用我的组件呢?这是否会把新旧组件搞得一团糟?

COM 对象可以支持多接口—它们可以实现多个协议

答案也是不,原因很简单:在 COM 中,一个对象可以支持多个接口。实际上,所有有用的 COM 对象都至少支持两个接口。(至少包含标准 IUnknown 接口(有关的详细内容稍后讲解)和一个实现所需功能的接口。)Visual ActiveX 控件都支持十几个接口,大多是标准接口。要使组件支持一个接口,必须实现该接口中的每个方法程序,所以要进行大量的工作。这就是 Active Template Library (ATL) 等工具流行的原因:它们提供了所有接口的实现。

所以为了支持新的 IFoo2 功能,我们将 IFoo2 也添加到该对象。


图 3 2.0 版,它支持 IFoo 和 IFoo2——但仍不是一个 COM 对象。

如果您仍想到插件,可以将 IFoo 想象为电视机的天线输入,将 IFoo2 想象为复合视频输入。注意,您不能将天线电缆插入到复合视频输入的插孔,反过来也不行。也就是说,每个接口在逻辑上都是唯一的。

另一方面,这些接口也有共同的地方。为了添加一个与旧接口几乎一样的新接口,是否需要重新编写全部的实现代码?不,因为 COM 支持接口的继承。只要我们不更改 IFoo 中已有的函数,我们可以如下定义 IFoo2

class IFoo2 : public IFoo {
   // 继承了 Func1, Func2
   virtual void Func2Ex(double nCount) = 0;
};
接口回顾

现在,让我们回顾一下我们阅读过的内容。首先,COM 是一个有关软件对象交互的二进制标准。由于是一个二进制标准,对象不会也不能知道所使用对象的实现细节。所以,对象就是黑盒子。

我们只能通过对象提供的接口来操作这些黑盒子对象。最后,一个对象可以提供任意多的接口。

很简单,是不是?

其实,我们忽略了很多细节。如何创建这些对象?如何访问接口?如何调用接口的方法程序?不管怎么说,这些对象的实现代码在哪里?这个恼人的对象何时最后被破坏?

这些问题很好,但遗憾的是 GUI 医生的手术要迟到了,所以我们将推迟到第三部分回答。不过医生现在可以处理一个问题:如何调用接口的方法程序?

调用接口的方法程序

您可能认为会很复杂,但其实很简单:COM 方法程序调用就是 C++ 虚函数的调用。我们将以某种方法(有关的详细内容在第三部分讲解)获得实现接口的对象的指针,然后我们就调用该接口的方法程序。

首先,假设我们有一个名为 CFoo 的 C++ 类,它实现了 IFoo 接口。注意,我们从 IFoo 继承,以保证我们按正确的顺序实现了正确的接口。

class CFoo : public IFoo {
   void Func1() { /* ... */ }
   void Func2(int nCount) { /* ... */ }
};

我们使用的指针称为接口指针。假设我们可以得到一个接口指针,我们的代码将如下所示:

#include <IFOO.H // 不需要 CFoo,只需接口
void DoFoo() {
  IFoo *pFoo = Fn_That_Gets_An_IFoo_Pointer_To_A_CFoo_Object();

  // 调用方法程序。
  pFoo -> Func1();
  pFoo -> Func2(5);
};

就这么简单。

但是,在这些代码的背后到底发生了什么呢?正如您以后要看到的,COM 二进制标准也应用于方法程序的调用——所以 COM 定义了调用函数时将发生什么。具体地说,所发生的事情与虚函数调用时的情形相同:

  1. 由 pFoo 获得对象的虚函数表指针。
  2. 由虚函数表指针和索引获得要调用函数的地址。
  3. 调用函数。

有关这几个步骤的情况,请参阅图 4:


图 4 通过接口指针进行的 C++ 虚函数调用

记住,在 C++ 中,每当声明虚函数时,就会生成一个虚函数表,它指向这些函数,并且,对这些函数的调用都是通过虚函数表和索引进行的。

“哈哈!”您说,“我知道了。实际上,COM 是离不开 C++ 的!它根本算不上一个二进制标准!”

GUI 医生回答说:“不对。”毕竟,您可以在任何支持函数指针数组的语言中实现这种调用。例如,在 C 语言中就很容易,通过指针 p 对 Func2 的调用可能会象这样:

(*((*p)+1))(p, 5); //  5 传递到数组中第二个函数

注意,我们必须将 p 作为第一个参数来传递——这模拟了 C++ 的 this 指针。(*p) 是第一次寻址(步骤1),*((*p) + 1) 是用索引进入虚函数表的正确入口(步骤 2),然后我们用 p 和 5 作为参数调用了函数(步骤 3)。很容易,但是很不雅观——GUI 医生做这个示范只是为了表明 C 语言是可以做到的(并且使您欣赏 C++)。在 x86 汇编语言中,调用可能会象这样:

MOV EAX, [pFoo]      ; 步骤 1
MOV EAX, [EAX + 4]   ; 步骤 2,用索引获得第二个指针
CALL [EAX]          ; 步骤 3

GUI 医生知道第二和第三个指令可以合并为 CALL [EAX + 4],如果您不想在 EAX 中保留函数的地址。

为什么医生演示了所有这些细节?是的,如果能用汇编语言或 C 语言做到,就能用任何语言做到!其他语言(Visual Basic、Visual J++、Delphi)将对这些调用的支持置入了它们的运行时模块或虚拟机中——通常是使用与上面相似的汇编语言代码或 C 语言代码。

要点是任何 COM 方法程序的调用都必须使用上面所示的数据结构,而不管其原始语言是什么,也不管 COM 对象位于何处。在将来的栏目中我们将讨论 COM 是如何做到位置透明的。

回顾与前瞻

好了,我们已经讨论了接口、对象,以及如何调用接口的方法程序。

对象是 COM 的基本单元——它是 COM 所创建的东西。对象要实现一些接口。接口是一组方法程序和规定这些方法程序做什么的协议。调用接口的方法程序的方式与调用 C++ 虚函数的方式相同。

在第三部分中,我们将讨论如何创建这些对象,如何获得接口指针,以及对象是如何被破坏的。

第三部分:获取对象和接口

第二部分,我们讨论了 COM 中两个非常基本的概念:对象和接口。我们还演示了一个对象如何实现多个接口。最后,我们讨论了 COM 方法程序调用的本质,并且认识到它们与 C++ 虚函数的调用方式相同。(但是,我们也注意到,您可以在任何支持函数指针数组的指针的语言中(或者通过调用汇编语言来支持)调用 COM 的方法程序。)

有关 COM 的详细讨论:对象的创建和破坏;获得接口指针

但是,您可能会发现这些讨论有些不能令人满意:我们从来没有讨论如何创建对象,也没有讨论如何获得接口指针,以调用对象接口的方法程序。而且,我们从来没有讨论如何删除您不再需要的对象,或者如何切换接口。

医生将在本周的讨论中涉及这些主题。但是,首先,我们需要稍微整理一下思路。您是否有过这样的经历:想要解释什么,但是突然想起来您忘记了一些重要的东西。现在就是这种情况,医生忘记的事情是,为了创建对象,需要有一种能引用它们的方法,还要有一种能明确定义接口的方法。所以,我们先讨论被遗忘的东西,然后再涉及我们真正感兴趣的内容。(而且,这也是为什么这次的栏目这么长,并且这么晚与大家见面。)

COM 中的标识符

如果您考虑得比较超前,您会知道,我们需要一些标识符来表示 COM 世界中的各种实体。首先,对象类型(或称“类”)需要一个标识符。其次,各接口也需要标识符。但是,我们应该用什么作为标识符呢?32 位整数?64 位整数?我们可以用它们作标识符。但是有一个问题:在所有的计算机上,标识符都必须是独一无二的,因为无法知道会在什么计算机上安装组件。对象和接口需要在所有的计算机上都使用相同的标识符,这样才能使任何客户程序都可以使用该组件。另外,不能有其他对象或接口使用这个标识符,不管其他对象或接口来自何处。也就是说,这些标识符必须是全球唯一的。

幸运的是,创建这种标识符的算法和数据格式是存在的。通过使用计算机的唯一网络卡 ID、当前时间和其他数据,一个称为 GUIDGEN.EXE 的程序就可以创建这种标识符,称为 GUID(全球唯一标识符)。GUID 是按 16 字节(128 位)结构存储的,这样就可以有 2128 个可能的 GUID。但是,不要担心会用完所有的 GUID:虽然医生不知道整个宇宙中的原子的确切数目,即使搜索了 Web 也未能找到答案,但是他相信那个数目一定比 2128 少得多。所以,GUID 不会缺乏,也没必要保留 GUID,不用理会 COM 从业者的玩笑。

在 C++ 中,COM 头文件为 GUID 定义了数据类型,即 CLSID(类标识符 GUID)和 IID(接口标识符 GUID)。由于这些 16 字节结构有些大,因而不能按值传递,所以当传递 GUID 时,需要使用 REFCLSID 和 REFIID 作为参数的数据类型。您需要为每个对象类型创建一个 CLSID,为每个自定义接口创建一个 IID。

标准接口

COM 定义了大量的标准接口及其相关的 IID。例如,所有接口的母辈 IUnknown 的 IID 是 "00000000-0000-0000-c000-000000000046"(连字符是书写 GUID 的标准方式)。这个 IID 是由 COM 定义的,您永远也不必直接引用它;而应使用 IID_IUnknown 常量,这是在头文件中定义的。

IUnknown 接口有三个函数:

HRESULT QueryInterface(REFIID riid, void **ppvObject);
ULONG AddRef();
ULONG Release();

稍后我们将详细讨论这些函数的功用。

使用标准接口可以完成大量 COM 编程工作——其中的大部分工作就是提供标准接口的实现细节,这样,其他的 COM 客户程序和对象就可以使用您的对象了。

顺便说一句,在 COM 中用到了一些宏,以说明函数的返回值和调用约定。几乎所有的 COM 方法程序都返回一个 HRESULT 类型的量,所以 STDMETHODIMP 宏就采用这种类型。STDMETHODIMP_() 宏带有一个参数——方法程序的返回类型。(只有当您使用纯虚函数定义接口时,才会用到 STDMETHOD 宏——并且将由 IDL 编译器为您编写代码,有关的详细内容稍后讲解。)

使用这些宏,上面的声明就会是这样:

STDMETHODIMP QueryInterface(REFIID riid, void **ppvObject);
STDMETHODIMP_(ULONG) AddRef();
STDMETHODIMP_(ULONG) Release();

以后我们将一直使用这些宏。这样做,就可以容易地将代码移植到其他不同的 COM 平台(例如 Macintosh 和 Solaris)上。

自定义接口

自定义接口是您创建的接口。您要为这些接口创建您自己的 IID,并且定义您自己的函数。我们的 IFoo 接口就是一个自定义接口。通过运行我计算机上的 GUID 生成器,我已经定义了一个 IID,称为 IID_Ifoo(它的值是 "13C0205C-A753-11d1-A52D-0000F8751BA7")。

回忆一下,原来的类声明是:

class IFoo {
   virtual void Func1(void) = 0;
   virtual void Func2(int nCount) = 0;
};

我们将略作修改,就将它变成 COM 兼容的:

Interface IFoo : IUnknown {
virtual HRESULT STDMETHODCALLTYPE Func1(void) = 0;
virtual HRESULT STDMETHODCALLTYPE Func2(int nCount) = 0;
};

使用上面所说的宏,就变成:

Interface IFoo : IUnknown {
STDMETHOD Func1(void) PURE;
STDMETHOD Func2(int nCount) PURE;
};

"Interface" 并不是 C++ 中的关键字,而是在相应的 COM 头文件中用 #define 定义为 "struct" 的。(回忆一下,在 C++ 中,类和结构是相同的,只是在默认情况下,结构使用公共继承和访问,而不是私有的。)STDMETHOD 使用 STDMETHODCALLTYPE,它定义为 __stdcall,这表明编译器要为这些函数生成标准的函数调用序列。记住,我们使用这些宏是因为将我们的代码移植到不同的平台上时,它们的定义会改变。

所有的 COM 函数(几乎毫无例外)都返回一个 HRESULT 类型的错误代码。这个 HRESULT 是一个 32 位的数值,它使用符号位代表成功或失败,其余 31 位中的域表明“功能”和与功能对应的错误代码,还有一些保留位。通常情况下要返回成功代码 S_OK,但是如果在方法程序中遇到了问题,也可以返回错误代码——或者是标准的,或者是您自己构造的。

最后,注意我是从标准 COM 接口 IUnknown 派生出 IFoo 的。这意味着,任何实现 IFoo 的类也需要同时实现 AddRefRelease 和 QueryInterface 这三个函数。另外,在 IFoo 的虚函数表中,这三个函数的指针将位于 Func1 和 Func2 函数的指针之前。在虚函数表中有五个函数,而这五个函数都需要实现。所有的 COM 接口都是从 IUnknown 派生的,所以所有的 COM 接口除包含其他函数外,都包含这三个函数。

MIDL 又怎样?

您不用自己动手编写上面的声明 — 它是由 MIDL 编译器为您生成的。为什么?正如事实表明的,C++ 不能表达需要在一个接口中表达的所有东西。回忆一下,COM 对象可以是进程内使用的 DLL,意味着位于相同的地址空间。所以,如果您将某些数据的一个指针传递到一个进程内服务程序,该服务程序会直接废弃该指针。

但是,也要记住,您的 COM 对象也可以是一个本地(线外)服务程序,位于单独的 EXE 地址空间,甚至可以远程访问。每当您向这样一个对象的 COM 方法程序中传递一个指针时,都会遇到问题:在任何其他地址空间,该指针都是毫无意义的,有意义的是该指针所指向的数据。该数据必须复制到其他地址空间—甚至复制回去。这个复制正确数据的过程称为排队 (marshalling)。谢天谢地,在大多数情况下 COM 为您进行排队。但是,为此您不仅需要告诉 COM 指针所指数据的类型,您还需要告诉 COM 该指针是如何使用的。例如,该指针是否指向一个数组?指向一个字符串?该参数只是一个输入参数?是一个输出参数?还是两者都是?您会看到,无法在 C++ 中表达这些。

所以,我们需要另一种语言,称为 IDL (Interface Definition Language),以便定义接口。IDL 很象 C++,但是将方括号中的“属性”添加到与 C++ 类似的代码中。MIDL.EXE 编译您(或 Visual Studio)编写的 IDL 文件,以便生成各种输出。到目前为止,我们关心的唯一输出是我们的接口的头文件,我们将在我们的代码中包含这个头文件。

在我们的示例中,并没有多少区别,因为我们只是按值传递参数,所以 IDL 代码很眼熟—主要区别是没有 "virtual" 一词。但是,如果我们创建一个新的接口 IFoo2,除了其他两个方法程序,还添加了一个方法程序Func3(int *),IDL 会是这样:

[ uuid(E312522F-A7B7-11D1-A52E-0000F8751BA7) ]
Interface IFoo2 : IUnknown
{
   HRESULT Func1();
   HRESULT Func2(int in_only);
   HRESULT Func3([in, out] int *inout);
};

要注意一些事情。首先,在 IDL 中有各种属性包含在方括号中。属性总是应用于后面紧跟的内容,所以上面的UUID 属性应用于接口—它是接口的 IID。(UUID 或通用唯一标识符,是 GUID 的同义词。)[in, out] 属性应用于指针,并且告诉 COM 当调用 Func3 时,必须让单个的 int 在函数内外排队(如果需要排队)。如果 int 指针引用一个数组,它会有一个附加属性(size_is 带一个参数)。也有 IDL 代码定义对象,定义我们的对象的代码段可能会象这样:

[ uuid(E312522E-A7B7-11D1-A52E-0000F8751BA7) ]
coclass Foo
{
   [default] Interface IFoo;
};

这就是 CLSID 如何与类相关的,也是类实现的接口如何定义的。注意,虽然这些代码很象 C++,并且带有一些附加属性,但是这些代码并不象接口定义那样严格地对应于 C++ 代码。

对象的创建

一旦我们的 CLSID 与一个对象类型相关(将在稍后详细探讨),就可以创建一个对象。正如所证明的,这很简单—只是一个函数的调用:

IFoo *pFoo = NULL;
HRESULT hr = CoCreateInstance(CLSID_Foo, NULL, CLSCTX_ALL,
               IID_IFoo, (void **)&pFoo);

如果 CoCreateInstance 成功了,它会创建 CLSID GUID CLSID_Foo 标识的对象的一个实例。注意,没有“对象的指针”这回事;相反,我们总是通过一个接口指针引用对象。所以我们必须指定我们需要哪个接口 (IID_IFoo),并将一个指针传递到某处,以便让 CoCreateInstance 存储该接口指针。

我们还没有讨论的两个参数目前还不重要。

一旦我们调用了一个函数,需要进行检查,以确保调用成功,并且接下来使用该对象:

if (SUCCEEDED(hr)) {
   pFoo->Func1();   //调用方法程序。
   pFoo->Func2(5);
   pFoo->Release();   // 当处理完之后,必须释放接口。
}
else // 创建失败...

CoCreateInstance 返回一个 HRESULT,以表明它是否成功。因为非负数表示成功,我们总是使用 SUCCEEDED 宏来检查结果。实际上,最普通的成功代码 S_OK 是零,所以象 "if (hr) // Success" 这样的检查根本不起作用。一旦成功地创建了该对象,您可以如上所示,使用接口指针来调用接口的方法程序。

当您处理完接口指针后,需要通过调用 Release 来释放该接口指针,这是至关重要的。注意,由于所有接口都是从 IUnknown 导出的,所以所有接口都支持 Release。当您告诉 COM 对象您已经处理完它时,由它自己释放自己,但是它需要您告诉它您何时处理完。如果您忘记了调用 Release,该对象会被泄漏(并且被锁在内存中,至少要等到关闭应用程序,甚至要等到系统重新启动)。弄乱对象生命期是非常普遍的 COM 编程问题,而且难于发现。所以,要从现在开始小心谨慎。注意,如果我们真正创建了某接口,就只能释放该接口。

图 5 是我们新创建对象的图例。按约定,IUnknown 没有标识;它总是画在对象的右上角。所有其他接口画在左边。


图 5 第一个简单的 COM 对象,带有无标识的 IUnknown。

由于我们实现了 IUnknown,所以就有了一个 COM 对象。(就象画一个连接器一样简单!)

如果将一个 IFoo2 接口添加到该对象,总共就有了三个接口,如图 6 所示。


图 6 理论版 2.0,支持 IFoo 和 IFoo2。

GUID 和注册表

那么 COM 是如何找到对象的代码,以便创建对象的呢?很简单:它在注册表中找。当安装了一个 COM 组件时,它必须在注册表中建立注册项。对于我们的 Foo 类,注册项可能会是这样:

HKEY_CLASSES_ROOT
  CLSID
    {E312522E-A7B7-11D1-A52E-0000F8751BA7}="Foo Class"
      InprocServer32="D://ATL Examples/Foo//Debug//Foo.dll"

大多数对象会有一些附加项,但是我们现在暂时忽略这些项。

在 HKEY_CLASSES_ROOT/CLSID,有一个我们的类的 CLSID 的注册项。这就是 CoCreateInstance 如何查找组件的 DLL 名称的。当您为 CoCreateInstance 提供了 CLSID,它会找到 DLL 名称,加载这个 DLL,并且创建该组件(稍后将详细讨论)。

如果服务程序是线外的或远程的,该注册项会有所不同,但是重要的是这些信息存在,所以 COM 可以启动服务程序并且创建该对象。

如果您知道对象的名称 (ProgID),但不知道它的 CLSID,就可以在注册表中查找 CLSID。对于我们的对象,有这样一个注册项:

HKEY_CLASSES_ROOT
  Foo.Foo="Foo Class"
    CURVER="Foo.Foo.1"
    CLSID="{E312522E-A7B7-11D1-A52E-0000F8751BA7}"
  Foo.Foo.1="Foo Class"
    CLSID="{E312522E-A7B7-11D1-A52E-0000F8751BA7}"

"Foo.Foo" 是独立版本的 ProgID,Foo.Foo.1 是 ProgID。如果您从 Visual Basic 中创建一个 Foo 对象,可使用其中一个 ProgIDs 查找 CLSID。(注意,在当前版本,ATL 向导还不能完全正确地创建注册项:它会漏掉上面所示的前两个 CLSID 关键字。不要忘记为独立版本的 ProgID 复制 CLSID。)

模块、组件类和接口

注意,一个模块(DLL 或 EXE)有可能(实际上是通常)实现多个 COM 组件类。如果是这样,会有多个 CLSID 注册项引用相同的模块。

这样我们现在可以定义模块、类和接口之间的关系。一个模块(您连编和安装的基本单元)可以实现一个或多个组件,每个组件在注册表中都有自己的 CLSID 和指向模块的文件名的注册项,并且每个组件至少实现两个接口:IUnknown 和一个提供组件功能的接口。图 7 表明了这点。


图 7 模块 Oo.DLL 包含三个对象(Foo、Goo 和 Hoo)的实现细节。每个对象实现了 IUnknown 和一个或多个附加接口。

使用 QueryInterface 获得其他接口

可以说,我们有了一个新的改进了的 Foo2 对象,它实现了两个自定义接口:IFoo 和 IFoo2。我们已经知道了如何使用 CoCreateInstance 创建这样一个对象,也知道了如何获得一个指向三个接口(不要忘记 IUnknown)之一的指针。

当我们得到这样的接口指针之后,怎样才能得到该对象的其他接口的接口指针呢?不能再调用CoCreateInstance—这会创建一个新对象,这并不是我们希望的,我们只需要现有对象的另一个接口。

这就需要 IUnknown::QueryInterface 来解决了。记住,由于所有的接口都从 IUnknown 继承,所以它们都执行 QueryInterface。这样,我们只需使用第一个接口指针来调用 QueryInterface,以便得到第二个接口指针:

IFoo *pFoo = NULL;
HRESULT hr = CoCreateInstance(CLSID_Foo2, NULL, CLSCTX_ALL,
               IID_IFoo, (void **)&pFoo);
if (SUCCEEDED(hr)) {
   pFoo->Func1();   //调用 IFoo::Func1
   IFoo2 *pFoo2 = NULL;
   hr = pFoo->QueryInterface(IID_IFoo2, (void **)&pFoo2);
   if (SUCCEEDED(hr)) {
      int inoutval = 5;
      pFoo2->Func3(&inoutval);   // IFoo2::Func3
      pFoo2->Release();
   }
   pFoo->Release();
}

我们向 QueryInterface 传递了所需接口的 IID,还有一个指向 QueryInterface 保存新接口指针的位置的指针。一旦 QueryInterface 返回成功,我们就可以使用该接口指针来调用该接口的函数。

一定要注意,当我们处理完两个接口指针之后必须释放这两个指针。如果释放其中的一个指针失败,就会泄漏该对象。由于我们只能通过接口指针引用该对象,所以必须释放每个得到的接口指针,这样才能作为一个整体释放该对象。

IUnknown 的其他函数

IUnknown 有其他两个函数:AddRef 和 Release。我们已经看到,您使用 Release 告诉一个对象,您已经处理完一个接口指针。那么您什么时候使用 AddRef 呢?

引用计数,并且当一个对象可以释放的时候

大多数 COM 对象都保留一个引用计数 ( reference count)—也就是说,它们需要跟踪有多少个该对象的接口指针正在使用。如果所有对象接口的引用计数变成零时,该对象就可以释放。我们不必明确地释放该对象;只需释放对象的所有接口指针,对象会在合适的时候释放自己。

AddRef 增加引用计数,而 Release 减少引用计数。所以,如果没有调用 AddRef,为什么必须调用 Release呢?

每当 QueryInterface 为一个对象分配一个新指针时,QueryInterface 有责任在返回该指针前调用AddRef。这就是为什么不必为得到的指针调用 AddRefQueryInterface 为我们做了。(注意,CoCreateInstance 调用 QueryInterface,而 QueryInterface 调用 AddRef,所以对象的第一个接口指针也是这样。)

对于调用了 AddRef 的相同接口指针,也需要调用 Release。如果对象需要,它们可以一个接口一个接口地跟踪引用。上面的代码小心地做到了这一点,对应 Release 调用的正确配对的隐含 AddRef 调用—每个接口指针一个 Release 调用。

如果您复制了一个接口指针,则需要调用 AddRef,这样该接口的引用计数才准确。至于何时需要何时不需要有点复杂,但是各种 COM 参考书都有详细的介绍。有关细节请查看这些参考书。

各种巧妙的指针类使得处理 IUnknown 变得容易多了(实际上是自动的)。在 ATL 和 Visual C++ 5.0 中有几个这样的类。如果您使用另一种语言,例如 Visual Basic 或 Java,该语言对 COM 的实现会正确地处理 IUnknown方法程序。

回顾与前瞻

我们已经讨论了如何创建对象,以及如何破坏它们(不是真的破坏,只是释放它们的所有接口指针),还有如何调用接口的方法程序和切换接口。同时,也介绍了用于标识对象和接口的各种 GUID 的概念,还有所需的注册项,这样 COM 才能知道如何创建您的对象。

在第四部分,我们将特别详细讨论如何创建进程内对象,以及使创建过程更高效的方法。如果有时间,还将探讨实现一个对象的本质,包括创建一个对象和 IUnknown 的代码。

第四部分:对象类和对象库

第三部分,我们讨论了如何创建对象,以及如何破坏它们(不是真的破坏,只是释放它们的所有接口指针),还有如何调用接口的方法程序和切换接口。同时,也介绍了用于标识对象和接口的各种 GUID 的概念,还有所需的注册项,这样 COM 才能知道如何创建您的对象。

这次,我们将特别详细讨论如何创建进程内对象,以及使创建过程更高效的方法。也将探讨类对象(也称为类工厂)以及如何实现它。本周已经没有时间讨论如何实现您的实际对象了,但是这些内容将排在下周日程的前面。

调用 CoCreateInstance 时发生了什么?

我们已经讨论了,当调用 CoCreateInstance 时,COM 如何搜索注册表,找到 CLSID,这样它才能找到实现对象的 DLL(或 EXE)。但是,我们没有详细讨论这是如何发生的, CoCreateInstance 封装了下列功能:

IClassFactory *pCF;
CoGetClassObject(rclsid, dwClsContext, NULL,
      IID_IClassFactory, (void **)&pCF);
hresult = pCF->CreateInstance(pUnkOuter, riid, ppvObj)
pCF->Release();

正如我们看到的,一共有三个步骤。第一步骤是通过一个类对象的 IID_IClassFactory 接口得到该类对象。下一步,调用这个类对象的 IClassFactory::CreateInstance。(这个调用的参数从 CoCreateInstance调用传递进来。)pUnkOuter 参数用于一个重用方法程序 aggregation 调用,将在稍后讨论。现在假设它是 NULL。在 *ppvObj 中就有了我们对象一个实例的指针。最后,释放该类对象。

那么,这个类对象又是什么?为什么麻烦它?

类对象

这个类对象是一个特殊的 COM 对象,它的主要目的是实现 IClassFactory 接口。(您将经常听到这个对象被引用为“类工厂”,甚至“类工厂对象”,但是更准确地应该引用为类对象。)

如果您经常使用 Java,可以粗略地将 COM 类对象看成一个 "Class" 类的 Java 对象。而且您将发现 Java 的Class.newInstance 类似于 IClassFactory::CreateInstance,COM 的 CoGetClassObject 类似于Class.forName 静态方法程序。

这个对象是特殊的,因为与大多数 COM 对象不同,它不是通过调用 CoCreateInstance 或IClassFactory::CreateInstance 创建的。相反,它总是通过调用 CoGetClassObject 创建的。在本篇文章的末尾,我们将看到特殊 COM 对象的其他实例。(正如所证明的,CoGetClassObject 并不总是创建一个类对象。如果 COM 有正确类的一个类对象,它可以只返回该类对象的一个接口指针。)

在调用 CoGetClassObject 之后,代码不必关心它创建了哪种对象,例如不管该对象是一个进程内服务程序还是本地服务程序。类对象管理所有这些差别。为了知道如何创建和查找 CLSID 所需的一个类对象,CoGetClassObject 不需要查找注册表(以及已有的注册了的类对象的列表)。

类对象是多态性的强大的一个良好实例。为了得到该对象,我们调用一个 COM API。但是,当得到该对象之后,我们可以断定它支持所需的标准接口 (IClassFactory),然后可以调用该接口的方法程序,这里是IClassFactory::CreateInstance。注意,我们还不知道类对象的 CreateInstance 是如何工作的。所知道的一切是,如果它成功了,它会返回一个指向该对象的接口指针。我们不必也不想知道别的了(即封装内容),而且通过进行相同的函数调用(即多态性),可以得到特定类对象的正确行为—正是类对象的一致性确定了正确的行为。

每个类对象实例都与一个特定的 CLSID 相关—注意,IClassFactory::CreateInstance 不需要一个 CLSID 作为它的参数。相反,类对象知道要创建什么 CLSID。这意味着,对于您想要创建的每个单独的 CLSID,至少需要一个类对象。

除了 IclassFactory,类对象可以实现任何接口。例如,您可以定义一个接口,该接口允许为从特定类对象创建的对象实例设置默认行为。但是,要注意,并不能保证对于一个给定的 CLSID,只有一个类对象,所以如果您多次调用 CoGetClassObject,可能会得到指向不同类对象的接口指针。(由于您可以控制类对象的创建,可以在实现过程中定义这一点。)

为什么要有一个类对象

正如我们讨论过的,COM 需要实现一个类对象的最重要原因是,这样 COM 可以具有创建任何种类对象的标准多态方法,而客户程序不必知道创建过程的确切细节。类对象封装了这些内容,这样客户程序就不必知道。这暗示着,类对象和“真正的”对象具有非常紧密的关系—并且经常互相很了解。

但是,为什么不采用一个更简单的方案呢?例如,您可以想象在您的 COM DLL 中有一个函数,就叫DLLCreateInstance 吧,它可以接受一个 CLSID,并且创建一个新实例。一个函数,比一个 COM 对象和IclassFactory 要简单地多吧。

但是,它无法为 EXE 工作。您不能从 EXE 导出函数。而且,该函数也肯定不能很好地为远程对象工作。所以,当我们让类对象作为一个 COM 对象时,COM 会照顾所有的进程内和线外问题。这是个好买卖。

由于类对象是一个知道如何正确创建目标对象的实例的 COM 对象,注意,一旦创建了类对象,对于创建实例来说,COM 就不相关了。所以,对于所创建的特定类型的第一个对象,COM 必须做大量工作。首先,它需要在注册类对象列表中查找 CLSID(或者,如果不存在类对象,就在注册表中查找)。如果需要创建类对象,COM 会创建它,可能包括加载一个 DLL 或启动一个 EXE。最后,COM 调用正确类对象的IClassFactory::CreateInstance 来创建您的实例。哇塞!

但是,如果您保留该类对象,对于后续实例,您可以越过大多数工作:为了创建其他对象,只需自己调用IClassFactory::CreateInstance。这几乎与直接接通接线员一样快,而比让 COM 创建新对象要快得多。

重要内容 如果您保留一个类对象,则必须调用 IClassFactory::LockServer,以便告诉 COM 将服务程序保留在内存中。对类对象的引用不会自动将服务程序保留在内存中。这个行为是 COM 常规行为的例外。如果您不能锁定服务程序,在服务程序卸载之后,试图访问类对象就有可能造成一个保护性错误。当您处理完类对象之后,不要忘记解锁该服务程序。

最后,类对象支持创建对象的其他方法,例如使用 IClassFactory2 接口,而不使用 IClassFactory 创建许可控件。许可控件是在创建该控件之前需要用户拥有正确许可 ID 的控件。

创建对象的另一种方法,以及何时使用该方法

如果您只创建一个对象的一个实例,并且可以使用 IClassFactory 创建该对象,您也可以用CoCreateInstance(或 CoCreateInstanceEx,它可以创建远程对象)。但是,如果您创建一个对象的多个实例,或者如果为了创建该对象,除了 IClassFactory,还需要使用一个接口,那么您需要获得(可能还需要保留)一个类对象。

获得类对象很容易—就象 CoCreateInstance 做的那样:调用 CoGetClassObject。一旦您拥有指向类对象的接口指针,需要调用 IClassFactory::LockServer(TRUE) 将服务程序锁定到内存中。然后,您可以让接口指针继续指向类对象,并且每当需要一个新实例时都调用 IClassFactory::CreateInstance。最后,当您创建完对象后,需要通过调用 IClassFactory::LockServer(FALSE) 释放服务程序,并且通过调用 Release释放接口指针。记住,释放接口必须是操作接口的最后一件事。

实现类对象

那么,这个类对象是什么样呢?它只是一个简单的 COM 对象。这意味着,它至少实现一个接口:IUnknown。几乎所有的类对象也实现 IclassFactory,所以它们可以创建实例。

我们可能有一个如下声明的类对象:

class CMyClassObject : public IClassFactory
{
protected:
   ULONG m_cRef;
public:
   CMyClassObject() : m_cRef(0) { };
      //IUnknown members
      STDMETHODIMP QueryInterface(REFIID, void **);
      STDMETHODIMP_(ULONG) AddRef(void);
      STDMETHODIMP_(ULONG) Release(void);

      //IClassFactory members
      STDMETHODIMP CreateInstance(IUnknown *, REFIID iid, void **ppv);
      STDMETHODIMP LockServer(BOOL);
};

当然,这个类包含 IclassFactory::CreateInstance 和 LockServer 中每个函数的声明。还有(奇怪!)IUnknown 函数。(记住,IClassFactory 是从 IUnknown 演化来的,象所有的 COM 接口一样。)注意,有一个成员保存了该对象的引用计数,并且我们在构造器中将它初始化为零。也要注意,我们使用正式的 COM 宏来声明方法程序的实现。

这个类对象是如何创建的

有各种方法创建一个类对象,没有一种涉及 CoCreateInstance。由于我们实际只需要这个对象的一个实例,而且由于是一个没有构造器的小对象,我决定在代码中只声明一个全局对象:

CMyClassObject g_cfMyClassObject;

这意味着,当加载了 DLL 之后,该对象将一直存在。

为了实现 IClassFactory::LockServer,也需要所有非类对象实例和 LockServer 调用次数的全局计数:

LONG g_cObjectsAndLocks = 0;
CoGetClassObject 如何获得类对象

对于进程内 DLL 服务程序,就简单了:COM 调用一个在您的 DLL 中名为 DllGetClassObject 的函数。如果您的 DLL 中包含 COM 能创建的 COM 对象,必须导出该函数。DllGetClassObject 具有下列原型:

STDAPI DllGetClassObject(const CLSID &rclsid, const IID &riid,
void ** ppv);

COM 传递进一个 CLSID 和一个 IID;DllGetClassObject 在 *ppv 中返回一个指向所需接口的指针。如果不能创建该类对象,或者所需的接口不存在,会在 HRESULT 返回值中返回一个错误(注意,STDAPI 被定义 (#defined)用来返回一个 HRESULT)。

对于 EXE 服务程序,这个过程就不同了:您为每个 COM 能创建的类注册一个类对象,还需要为每种类对象调用CoRegisterClassObject。这将类对象放在注册类对象列表中。当 EXE 过程结束时,对于每个类对象,它调用一次 CoRevokeClassObject,以便将该对象从注册列表中删除。如果想要详细了解,请参阅 COM 文档或各种 COM 图书。在这里,我想集中讨论进程内 (DLL) 服务程序。

注意,当您调用 CoGetClassObject 时,COM 如何真正获得类对象取决于该对象是由一个 DLL 还是一个 EXE 实现的。如果是由 DLL 实现的,COM 加载这个 DLL(如果还没有加载),并且调用 DllGetClassObject。对于一个 EXE,COM 加载这个 EXE(如果还没有加载),并且等到 EXE 注册它寻找的类对象,或者等到发生超时。

我们的 DllGetClassObject 可能会象这样:

STDAPI DllGetClassObject(REFCLSID clsid, REFIID iid, void **ppv) {
   if (clsid != CLSID_MyObject) // Right CLSID?
      return CLASS_E_CLASSNOTAVAILABLE;

   // 从全局对象获得接口。
   HRESULT hr = g_cfMyClassObject.QueryInterface(iid, ppv);
   if (FAILED(hr))
      *ppv = NULL;
   return hr;
}

我们必须检查,看所需的 CLSID 是否是我们支持的。如果不是,就返回 E_FAIL。接下来,调用所需接口的QueryInterface。如果失败,就将输出指针设置为 NULL,并返回 E_NOINTERFACE。如果成功,就返回 S_OK 和接口指针。

实现类对象的方法程序
IUnknown::AddRef 和 IUnknown::Release

我们的类对象是全局的。它总是存在,并且不能被破坏(至少是在卸载 DLL 之前)。由于我们从来也不删除这个对象,而且对类对象的引用不能使一个服务程序加载,所以几乎不需要实施引用计数。但是,引用计数对调试会有帮助,所以我们还是实现这个对象的引用计数。

AddRef 和 Release 负责维护对象的引用计数。注意,我们有一个初始化为零的实例变量 m_cRef。AddRef 和Release 只是增加和减少这个引用计数器,并且返回引用计数器的新值。

如果对象是动态创建的,当引用计数为零时,删除该对象是 Release 的责任。由于我们的对象是全局分布的,所以不能这样做。

STDMETHODIMP_(ULONG) CMyClassObject::AddRef() {
   return InterlockedIncrement(&m_cRef);
}

STDMETHODIMP_(ULONG) CMyClassObject::Release() {
   return InterlockedDecrement(&m_cRef);
}

我使用有线程保护的增加和减少函数,而不是使用 ++m_cRef 和 --m_cRef,以符合多线程操作的思维习惯。

如果您想让 AddRef 和 Release 真的简单,只需让它们返回一个非零值,也可以删除类对象的用于引用计数的成员变量(不是删除该对象,和锁定全局变量的计数!)。

IUnknown::QueryInterface

这个对象的 QueryInterface 的实现是 100% 标准的,没有特殊的内容,因为该对象是一个类对象。需要我们做的只是看所需的接口是否是我们支持的两种接口之一(IUnknown 和 IclassFactory)。如果是,就在正确地进行类型转换之后,向对象返回一个接口指针;而且,对于正确的指针,调用 AddRef 以便引用计数。如果不是,就返回正确的错误代码 E_NOINTERFACE。

STDMETHODIMP CMyClassObject::QueryInterface(REFIID iid, void ** ppv) {
   *ppv = NULL;
   if (iid == IID_IUnknown==iid || iid == IID_IClassFactory) {
      *ppv = static_castthis;
      (static_cast*ppv)->AddRef();
      return S_OK;
   else {
      *ppv = NULL; //  COM 规范,如果失败需要为 NULL
      return E_NOINTERFACE;
   }
}

注意那个新的 static_cast 操作符。在 ANSI C++ 中,通过使用不同的操作符,您可以区分类型转换的三种不同语义使用。static_cast 操作符在指针和不同的类类型之间进行相应的类型转换,如果必要就更改指针的值(这个不是这种情况,因为我没有使用多重继承)。

IClassFactory::CreateInstance

这里是我们的类对象的中心—创建实例的函数。

STDMETHODIMP CMyClassObject::CreateInstance (IUnknown *pUnkOuter,
   REFIID iid, void ** ppv)
{
   *ppv=NULL;

// 对集合只需说不。
   if (pUnkOuter != NULL)
      return CLASS_E_NOAGGREGATION;

   //创建该对象。
   CMyObject *pObj = new CMyObject();
   if (pObj == NULL)
      return E_OUTOFMEMORY;

   //获得第一个接口指针(执行了一次 AddRef)。
   HRESULT hr = pObj->QueryInterface(iid, ppv);

   // 如果接口不可用,就删除该对象。
   //假设初始的引用计数为零。
   if (FAILED(hr))
      delete pObj;
  
   return hr;
}

首先,我们不支持集合。所以如果该指针不是 NULL,则不能创建该对象,因为我们被要求支持集合。下一步,分配该对象,如果不能分配该对象,就返回 E_OUTOFMEMORY。

接下来,对于新创建的对象,调用 QueryInterface,以得到要返回的接口指针。如果失败,就删除该对象,并且返回错误代码。如果成功,就从 QueryInterface 返回成功代码。注意,如果成功,QueryInterface 将调用AddRef,使我们得到对象的正确引用计数。

也要注意,我们并没有增加对象,也没有锁定计数器 g_cObjectsAndLocks。如果创建成功了,我们本来可以这样做,但是也必须在实例对象的 Release 或析构器中减少计数器。我们将把计数器的减少代码放在对象本身的析构器中—在第五部分。但是如果减少代码是在析构器中,那么增加代码应该放在构造器中,而不是在这里。

有很多不同方式对对象进行初始的 QueryInterface 操作,这取决于对象本身是如何进行初始引用计数的。随之出现的一个问题是,有时一个对象会在 QueryInterface 过程中进行某些动作,这些动作会引起 AddRef 和Release 这对调用的执行。如果对象的初始引用计数为零,对 Release 的调用会使对象释放自己—甚至在CreateInstance 返回之前。这可不太好。

一个常用的技术是,将对象的初始引用计数设置为一个非零的数。可以很容易地在对象的构造器中做到这一点(请参阅第五部分)。但是,如果您这样做了,就必须修改 CreateInstance,在它调用 QueryInterface 之后调用 Release,这样就会正确设置引用计数。

如果您这样做了,就忽略了删除该对象。如果 QueryInterface 失败了,它将不调用 AddRef—所以对象的引用计数将会是 1 而不是 2。如果这时调用了 Release,对象的引用计数将变成零,并且对象将删除自己。如果QueryInterface 成功了,它将引用计数增加为 2,然后 Release 将引用计数减少为 1,这时才正确。

如果您假设初始引用计数为 1,可将 QueryInterface 结尾的的 CreateInstance 代码确定为如下所示:

// ...

//获得第一个接口指针(执行了一次 AddRef)。
   HRESULT hr = pObj->QueryInterface(iid, ppv);

   // 如果接口不可用,就删除该对象。
   // 假设初始的引用计数为 1,而不是零。
    pObj->Release(); // 如果 QI 成功了,就变回 1,如果没成功就删除它

   return hr;
}

我们将在第五部分将这些代码用于我们的对象:它很简单,而且好用。对于 CreateInstance 必须知道对象的实现细节,医生不认为这是一个缺点—毕竟,这就是 CreateInstance 的用处:为了封装这些细节,这样客户程序就不必管它们。

IClassFactory::LockServer

LockServer 只是用来增加和减少全局锁定和对象计数。当计数变成零时,它不会试图释放 DLL。(如果这是一个 EXE 服务程序,并且没有任何交互用户,则当计数变成零时,服务程序将关闭。)

STDMETHODIMP CMyClassObject::LockServer(BOOL fLock) {
   if (fLock)
      InterlockedIncrement(&g_cObjectsAndLocks);
   else
      InterlockedDecrement(&g_cObjectsAndLocks);
   return NOERROR;
}

另外,我选择让这些代码是线程保护的。当计数变成零时,可以删除该对象。

DllCanUnloadNow

COM 将调用 DllCanUnloadNow,以决定是否卸载一个 DLL。如果可以卸载,我们只是简单地返回 S_OK,如果不可以,将返回 S_FALSE。如果没有对象或对服务程序的锁定,就可以卸载。

STDAPI DllCanUnloadNow() {
   if (g_cObjectsAndLocks == 0)
     return S_OK;
   else
      return S_FALSE;
}
回顾与前瞻

我们部分地讨论了如何创建进程内对象,以及让创建过程更有效的方法。也讨论了类对象(也称为类工厂),以及如何实现一个类对象。但是,我们没有开始真的实现一个对象。

下一次,我们将讨论实现一个实例对象的本质,包括 IUnknown 所需的代码和您自己的自定义接口—而且,还有可能讨论特殊的、高效的、 不使用 COM 来创建的 COM 对象。

注意,我们使用了 C++ 来实现,但是也可以使用 C 语言。医生不认为这可取(特别是,将 C 和 C++ 程序混合就不错了)。但是,如果有什么特殊理由使您真的想这样做,在 MSDN 上有一些示例,包括 Inside OLE 第二章的标题 "RectEnumerator in C: ENUMC.C,"

轮到您了

您是否有想让医生讨论的主题?可以给 drgui@microsoft.com 来信。虽然,医生手术计划不包括个别答复,但是 GUI 医生保证阅读和考虑所有邮件。


  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值