COM的接口就是一个包含了一个函数指针数组的内存结构,数组元素包含的是组件所实现的函数的地址。
在C++中,可以用抽象基来实现COM接口,且由于一个COM组件可以支持任意数量的接口,我们可应用多态来实现。
接口的作用
对于客户来说,组件就是个接口集。客户只能通过接口才能同COM组件打交道。在某些条件下,客户甚至不必知道一个组件的所有接口。
而对程序要来讲,接口对于一个应用程序而言是最重要的,组件本身只不过是接口的实现细节。在COM中,接口比实现接口的组件更为重要。
可复用应用程序框架
一个程序的组件可以不断替换升级的关键,就是要有支持的接口。使用组件可以用于构建可复用应用程序。
组件可从应用程序中删除并可用另外一个组件来取代之。只要新的组件支持同组件相同的接口,那么整个应用程序将仍然能够工作。单个的组件并不能对整个应用程序产生决定性的作用。相反,用以连接组件的接口将对整个应用程序产生决定性的作用。只要接口保持不变,那么组件可以任意地更换。
接口同木板房中的大梁非常类似。这些大梁决定了整个房屋的结构。没有它们,房顶和四壁将无法抵抗住风吹雨打的侵袭。只要大梁不被破坏,那么整个房屋的结构将保持不变。就如将原来是由砖砌成的墙壁改为木制,虽然会导致房屋外观的改变,但并不影响其整体结构-样。同样,可以将应用程序所用的组件替换掉,这样应用程序的行为将会发生变化,但从结构上讲,整个应用程序并没有发生任何变化。
使用组件来构造应用程序的最大的优点在于可以复用应用程序的结构。如果接口设计得好的话,将可以得到可复用极高的结构。例如,可以通过允许改变若干关键性的组件,同样的结构可以支持几种不同类型的应用。
COM接口的其他优点
- 可以保护系统免受外界变化的影响
- 可以使客户可以用同样的方式来处理不同的组件(多态)
COM接口的实现
其中组件CA将使用IX和IY来实现两个接口。
class IX //First interface 第一个接口
{
public:
virtual void Fx1() = 0;
virtual void Fx2() = 0;
};
class IY //Second interface 第二个接口
{
public:
virtual void Fy1() = 0;
virtual void Fy2() = 0;
};
//为实现IX和IY的成员函数,CA实现了多重继承
class CA :public IX, public IY //Component 组件
{
public:
//Implementation of abstract base class IX
//纯抽象基类IX的实现,用于实现接口
virtual void Fx1()
{
cout << "CA::Fx1" << endl;
}
virtual void Fx2()
{
cout << "CA::Fx2" << endl;
}
//Implementation of abstract base class IY 纯抽象基类IY的实现
virtual void Fy1()
{
cout << "CA::Fy1" << endl;
}
virtual void Fy2()
{
cout << "CA::Fy2" << endl;
}
};
- 纯抽象基类:仅包含纯抽象函数的基类
- 纯虚函数:用 =0 标记的虚函数,定义纯虚函数的类中不会实现他们,纯虚函数在派生类中实现
抽象类定义了一个函数,而函数的具体功能实现靠派生类实现
可以将一个抽象类看作是一个表单,派生类所做的就是填充此表单中的空白。抽象基类指定了其派生类应提供哪些函数,而派生类则具体实现这些函数。对纯虚函数的继承被称作是接口继承,这主要是因为派生类所继承的只是基类对函数的描述。抽象类并没有提供任何可供继承的实现细节。
本书的接口统一用纯虚函数实现,接口的内存块必须具有一定的结构,而纯抽象基类在许多C++编译器中都可以生成具有这种结构的内存块。
在本书中我们将使用纯虚函数来实现所有的接口。由于COM是与语言无关的,对于什么是接口,它有一个二进制的标准。也就是说,表示一个接口的内存块必须具有一定的结构。在本章后面的“接口内幕”一节中将详细介绍这一结构。幸运的是,当使用纯抽象基类时,许多C++编译器将可以生成具有这种结构的内存块。
以上代码还不是真正意义上的COM接口,我们要继承一个IUnknown接口来实现此。
实际上,上面的IX和IY并不是真正意义上的COM接口。为使之成为COM接口,它们必须继承一个名为IUnknown的接口。下一章将详细讨论IUnknown接口,故在此不对之作更详细的介绍。在本章的后面,我们将还是把IX和IY看作是COM接口。
编码约定
在接口名称的前面,都加有一个字母“I”,这样IX表示的是“接口X”。
在类名称的前面所加的前缀则为“C”,如CA表示的是“类A”。
另外一个约定是在本书中我们并不将一个接口定义成一个类,而是使用了Microsoft Win32 软件开发工具(SDK)中OBJBASE.H头文件中的定义:
#define interface struct
在上述定义中使用struct的原因在于struct的成员将自动具有公有的属性,因此不需要另外在定义中加上public关键字。去掉public关键字可以减少一些混乱。根据这个约定,上例中的接口可以重新定义如下:
#include<objbase.h>//Use for #define interface struct
interface IX
{
virtual void __stdcall Fx1() = 0;
virtual void __stdcall Fx2() = 0;
};
interface IY
{
virtual void __stdcall Fy1() = 0;
virtual void __stdcall Fy2() = 0;
};
完整例子
在以下例子中,为了简单起见,并未用到动态链接
我们使用类CA实现了一个支持接口IX和IY的组件,而其客户则是函数main
#include<iostream>
#include<objbase.h>// Define interface 定义接口
using namespace std;
void trace(const char* pMsg)
{
cout << pMsg << endl;
}
// Abstract interfaces 抽象接口
interface IX
{
virtual void __stdcall Fx1() = 0;
virtual void __stdcall Fx2() = 0;
};
interface IY
{
virtual void __stdcall Fy1() = 0;
virtual void __stdcall Fy2() = 0;
};
//Interface implementation 接口实现
class CA :public IX, public IY
{
public:
//Implement interface IX 实现接口IX
virtual void __stdcall Fx1()
{
cout << "CA::Fx1" << endl;
}
virtual void __stdcall Fx2()
{
cout << "CA::Fx2" << endl;
}
//Implement interface IY 实现接口IY
virtual void __stdcall Fy1()
{
cout << "CA::Fy1" << endl;
}
virtual void __stdcall Fy2()
{
cout << "CA::Fy2" << endl;
}
};
//C1ient
int main()
{
trace("CIient:Create an instance of the component.");//客户端:创建组件的实例
CA* pA = new CA;
//Get an IX pointer.获取一个IX指针。
IX* pIX = pA;
trace("C1ient:Use the IX interface.");
pIX->Fx1();
pIX->Fx2();
//Get an IY pointer.
IY* pIY = pA;
trace("C1ient:Use the IY interface.");
pIY->Fy1();
pIY->Fy2();
trace("C1ient:Use the IY interface.");
delete pA;
return 0;
}
输出:
CIient:Create an instance of the component.
C1ient:Use the IX interface.
CA::Fx1
CA::Fx2
C1ient:Use the IY interface.
CA::Fy1
CA::Fy2
C1ient:Use the IY interface.
在上例中读者可以看到客户和组件是通过两个接口来通信的。在接口的实现中使用了两个纯抽象基类IX和IY。例中的组件是由类CA实现的,它继承了IX和IY,并实现了这两个接口中的所有成员。
客户main函数创建了组件的一个实例,并获取了组件所能提供的、指向接口的指针。然后它像使用C++指针那样使用了这两个指针,这是由于接口是用纯抽象基类实现的。
根据以上代码,我们可以明白一下要点:
- COM接口在C++中是用纯抽象基类实现的。
- 一个COM组件可以提供多个接口。
- 一个C++类可以使用多继承来实现一个可以提供多个接口的组件。
_stdcall或Pascal调用约定
在上面的程序中,应用到了一个 _stdcall
_stdcall标记的函数会使用标准的调用约定:这些函数将在返回到调用者之间将参数从栈中删除(Pascal函数对于栈的处理方式同样)。我们在常规的c/c++调用约定中,栈的清理工作则是由调用者完成。
我们还可以用pascal来代替_stdall,在WINDEF.H中,pascal定义如下
#define pascal _stdcall
如果读者认为将pascal这个词放在这里会莫名其妙,也可以用ORJBASE.H中的宏定义:
#define STDMEFHODCALLTYPE _stdcall
非接口通信
在上面程序中,客户与组件的通信是通过指向类CA的指针而并非通过接口的指针完成,这并没有遵守客户和组件之间之呢个由接口通讯的规则。
使用指向CA的指针要求客户知道类CA的定义(通常是一个头文件)。在类CA的声明中有许多有关实现的细节。对这些实现细节的修改将使得客户必须被重新编译。但前面已经讲过,增加或减少组件的接口不应打断已有的客户。
所以坚持客户和组件之间只应通过接口进行通信。接口是由没有实现细节的虚纯基类实现的。
当然当客户和组件在同一源文件中时,并没有必要将它们分开。但是当客户和组件是在动态链接的情况下,此种隔离则是必需的。
在没有源代码的情况下更是如此。在第3章中我们将对前面的例子进行修改,使之不再使用指向CA的指针。这样一来,客户也将不再需要CA的类声明。
上面例子违背只用接口进行通信的表现除了使用指向CA的指针,还有在客户程序中使用new和delete:
- new和delete可以控制组件的生命期
- new和delete也不是任何接口的一部分
- 只是C++才包含new和delete操作。
第4章将介绍一种使用接口而不是与某种语言特定的操作符来删除组件的方法。在第6章及第7章中我们将给出一种更有效的建立组件的方法。
实现细节
上面的示例代码虽然恰好是我们建立COM组件和客户的第一步,但相较于真正的COM组件还是有很大问题。在本小节我们将依据示例代码揭示COM对组件的需求和组件如何实现这两个问题的区别。
一、类并非组件
类CA实现了单个的组件。但COM并没有要求一个C++类只能同一个COM组件相应。实际上一个组件可以是由多个C++类实现的,并且不用任何C++类也可以实现一个组件,例如,当用C来实现组件时就不会用到类。在开发组件时并不一定非用类。只不过当我们选择c++开发时用类来实现组件将比其他方法更为容易。
二、接口并非总是继承的
CA继承了它所支持的各接口。但COM并没有要求实现某个接口的类必须从那个接口继承,这是因为客户并不需要了解COM组件的继承关系。对接口的继承只不过是一种实现细节而已。除了可以使用一个类来实现几个不同的接口外,还可以用单个的类来实现每一个接口,再使用指向这些类的指针。在Kraig Brockschmidt所著的《OLE技术内幕》一书中使用的就是这种方法。一般使用一个类来实现所有的接口,这样比较简单,易于理解,并且可使得用C++进行COM编程更为自然。
三、多重接口及多重继承
组件可以支持任意数目的接口。为支持多重接口,可以使用多重继承。CA使用多重继承继承了它所支持的两个接口IX和IY。这种支持多重接口的组件可以被看作是接口的集合。
一个接口是一个函数集合,一个组件则是一个接口集,而一个系统则是一系列组件的集合。可以把一个接口看作一个行为。一个组件所支持的接口同此组件的各种行为是相对应的。
一个系统是一系列组件的集合;每个组件提供了一个接口集;而每一个接口则包含一系列函数
这种特点使得我们可以按一种递归的方式对组件架构进行层次的划分。在这种层次结构中,一个接口是一个函数集合,一个组件则是一个接口集,而一个系统则是一系列组件的集合。有些人将接口同特性等同起来。当向组件中增加一个接口时,此时他们会说组件现在可以支持此种特性了。但作者认为将接口看作是行为可能更为合适。一个组件所支持的接口同此组件的各种行为是相对应的。
四、命名冲突
接口函数的命名并不会影响COM接口,COM接口是一个二进制标准;客户同接口的连接并不是通过其名称或其成员函数的名称完成的,而是通过它在表示它的内存块中的位置完成的。
对于一个支持多个接口的组件,接口函数的名字出现冲突是经常会遇到的。此种情况下,改变某个发生冲突的函数名称即可,COM对此并不关心。这是因为COM接口是一个二进制标准;客户同接口的连接并不是通过其名称或其成员函数的名称完成的,而是通过它在表示它的内存块中的位置完成的。在本章的末尾我们将讨论这一内存结构。在第8章中将使用这种改变接口及成员函数名称的技术。
解决命名冲突的另外一种方法是不使用多重继承。实现组件的类并不需要继承每一个接口,而可以合用指向实现某些接口的类的指针。
接口名称之间出现冲突的情况也是可能的。但如果所有的程序员都使用某种简单的约定则可以减少此种可能性。例如,可以在接口和函数名称的前面加上公司名称或产品名称。如对产品Xyz中的接口 IFly,可以使用IXyzFly作为其名称。
接口理论:第二部分
在开始实现接口之前,作者曾提到过要进一步拓宽读者对于接口的了解。在这一节
中我们将完成这一任务。
在本节所讨论的问题包括:COM接口的不变性、多态以及接口继承。
接口的不变性
从示例程序中可以明显地看出,接口不会发生任何变化。实际上这是COM接口最令人震撼之处。一旦公布了一个接口,那么它将永远保持不变。当对组件进行升级时,一般不会修改已有的接口,而是加人一些新的接口。这些多重接口使得组件除了可以支持原有接口之外,还可以支持新的接口。因此多重继承为组件和客户可以智能地同对方的新版本进行交互提供了坚实的基础。在下一章中我们将讨论版本的问题。
多态
我们应用多态可以实现多重接口,使不同的组件支持同一个接口,那么客户可以按照同样的方式来处理不同组件。一个组件所支持的接口越多,这些组件就应该越小。较小的接口表示较为简单的行为,而大的接口则表示更多的行为。“一个接口所表示的行为越多,它的特定性将越强,因此它被其他组件复用的可能性将越小。对于不能复用的接口,使用此接口的客户代码也将不能复用。
多态指的是可以按同一种方式来处理不同的对象。多重接口的支持能力给多态提供了更多的机会。若两个不同的组件支持同一接口,那么客户将可以使用相同的代码来处理其中的任何一个组件。也就是说,客户可以按照相同的方式来处理不同的组件多重接口使得多态的重要性更为突出。一个组件所支持的接口越多,这些组件就应该越小。较小的接口表示较为简单的行为,而大的接口则表示更多的行为。“一个接口所表示的行为越多,它的特定性将越强,因此它被其他组件复用的可能性将越小。对于不能复用的接口,使用此接口的客户代码也将不能复用。
例如可以判断一下,是用一个接口来表示一架直升机的所有行为,如飞行、浮动、盘旋、上升、滚动、颤动、摇摆、降落等的可复用性更高呢,还是用多个接口分别来表示这些行为的可复用性更高。显然一个表示飞行的接口将县有比表示直升机的接口具有更高的可复用性,因为大多数事物的行为同直升机的行为都是不同的,但却有许多事物能够飞行。
使用多态的一种令人吃惊的结果就是整个应用程序都将是可复用的。假定有一个名为Viewer的显示位图的应用程序。此位图是用支持一个名为IDisplay的接口的COM组件实现的。Viewer仅通过其IDisplay 接口同组件进行交互。当需要用Viewer来显示VRML文件时,此时所需要做的只是实现一个支持IDisplay接口的COM组件,而无需重新构造Viewer的一个新版本。新组件将显示VRML文件而不是位图。虽然显示VRML文件也需要进行大量工作,但无需将整个应用程序重写一遍。
这种复用整个应用架构的能力并不是随便就能出现的。在此需要精心设计接口以使之能够支持各种不同的实现。这不但要求接口具有较高的通用性,而且客户也应按一种比较通用的方式来使用此接口,以避免对接口的实现造成不必要的限制。对于那些并没有考虑到将来可能会出现的组件的接口及应用程序,它们将不能充分利用多态所具有的各种优点,也将无法实现对整个应用架构的复用。例如在上例中,如果事先没有进行很好的规划,接口IDisplay可能并不会具有VRML文件及位图所要求的通用性及灵活性。开发组件软件的一个最大的问题是如何设计出具有高复用性、适应性、灵活性的接口,并考虑到将来可能会出现的新情况。
至此我们已经知道了如何实现一个组件以及组件具有哪些功能。下面我们来看一下COM接口到底是什么以及为什么可以用纯抽象基类来实现组件。
接口的背后
本章我们讨论的主要问题是:使用C++的纯抽象基类来实现COM接口。本节我们将揭示纯抽象基类来实现COM接口是可行的。纯抽象基类所定义的内存结构可以满足COM对接口的需求。
虚拟函数表
当定义一个纯抽象基类时,所定义的实际上是一个内存块的结构。纯抽象基类所有实现都是一些具有相同的基本结构的内存块。下列代码定义的便是纯抽象基类的内存结构:
interface IX
{
virtual void_stacall Fx1() =0;
virtual void_stacall Fx2() =0;
virtual void_stacall Fx3() =0;
virtual void_stacall Fx4() =0;
};
定义一个纯抽象基类也就是定义了相应的内存结构。但此内存只是在派生类中实现此抽象基类时才会被分配。当派生类继承一个抽象基类时,它将继承此内存结构。
下图中,一个指向抽象基类的的指针指向vtbl指针,vtbl指针指向虚拟函数表(vtbl),表中有派生类实现的Fx1,Fx2等等函数的地址(&)。以上C++编译器为抽象基类所生成的内存结构便与COM接口的内存结构相同。
从图2-4可以看出一个纯抽象基类所定义的内存结构可以分成两部分。图2-4的右边是虚拟函数表(vtbl),其中包含一组指向虚拟函数实现的指针。上例中vthl中的第一项为派生类中所实现的Fx1函数的地址,第二项则是函数Fx2的地址,如此等等。图中左侧为一个指向vtbl的指针(称作是vtbl指针)。而指向抽象基类的指针则指向此vtbl指针。
似乎是一个偶然的巧合,COM接口的内存结构同C++编译器为抽象基类所生成的内存结构是相同的。因此,可以合用抽象基类来定义COM接口。从这个意义上讲,IX既
是一个接口,也是一个抽象基类。说它是一个COM接口是由于其内存结构符合COM规范的要求,而说它是一个抽象基类则是由于我们就是用此种方式来实现它的。
当然没有任何标准要求C++编译器生成图2-4所示的内存结构。这是由于用C++编写的程序通常都是用同一个编译器。因此编译器只要同自己兼容就够了。但是大多数编译器所生成的内存结构都同图2-4所示相同。所有与Windows兼容的C++编译器都能生成COM可以使用的正确的vtbl1。
对于一个COM接口还有其他一些需求。例如,所有的COM接口都必须继承一个名为IUnknown的接口。关于此接口我们将在下一章讲述。这意味着所有COM接口的前三个项都是相同的,其中保存的是IUnknown中三个成员函数的实现的地址。关于此我们将在第3章中详细讨论。
vtbl指针及实例数据
vtbl 指针的作用在于当由抽象基类函数指针到函数的过程中增加了一个额外的级别。正是这一额外的级别给接口的实现带来极大的灵活性。
C++编译器生成代码时,实现抽象基类的类可能会将特定于实例的信息同vtbl一块保存。例如,下面代码中的类CA实现了前面代码中的抽象基类IX。
class CA: public IX
{
public:
//Implement interface IX.
virtual void_stdcall Fxl() |cout << "CA :: Fxl" << endl ;1
virtual void_stdcall Fx2() {cout << m_ Fx2 << endl ;3
virtual void_stdcall Fx3() {cout << m_Fx3 << endl ;}
virtual void_stdcall Fx4() {cout << m_Fx4 << endl ;}
//Constructor
CA(double d)
: m_Fx2(d *d), m_Fx3(d * d* d), m_Fx4(d * d* d* d)
// Instance Data
double m_Fx2;
double m_Fx3;
double m_Fx4;
};
对于上面我们所讲的那种编译器,vtbl和CA的类数据将如下面的图2-5所示。注意,实例数据理应是可以通过指向类的指针pA来访问的,但由于客户通常并不知道实例数据是按此种方式保存的,因此客户也就无法访问它了。
多重实例
vtbl的作用绝不仅是给实例数据的保存提供一个方便的位置。实际上它还可以同一个类的不同实例共享同一vtbl。若我们建立CA的两个不同实例,那么将会有两组不同的实例数据。但不同的实例可以共享同一vtbl及相同的实现。例如,假定我们建立两个CA对象:
int main()
{
//Create first instance of CA.
CA* pAl = new CA(1.5) ;
// Create second instance of CA.
CA* pA2 = new CA(2.75) ;
}
上述对象将共享同一vtbl。其中的元素将指向虚拟成员函数的同一实现。但是各对
象将各自有不同的实例数据(参见图2-6)。
虽然COM组件可以使用vtbl指针来共享vtbl,但这一点并不是必需的。COM组件的每一个实例中已有一个不同的vtbl。
不同的类,相同的vtbl
接口的真正的威力在于继承此接口的所有类均可以被客户按同一方式进行处理。假定我们实现如下的一个也是继承了IX的类CB:
class CB: public IX
{
public:
//Implement inerface IX.
virtual void_stdcall Fxl() {cout << "CB :: Fxl" << endl ;!
virtual void_stdcall Fx2() {cout << "CB :: Fx2" << endl ;l
virtual void_stdcall Fx3() {cout << "CB :: Fx3" << endl ;|
virtual void_stdcall Fx4() {cout << "CB :: Fx4" << endl ;l
};
这样客户可以通过同一个IX指针来访问CA和CB。
void foo( IX* pIX)
{
pIX->Fxl() ;
pIX->Fx2();
}
int main()
{
// Create instance of CA.
CA* p = new CA(1.789) ;
// Create instance of CB.
CP* pB= new CB ;
// Get IX pointer to CA.
IX* pIX = pA ;
foo(pIX) ;
// Get IX pointer to CB.
pIX= pB;
foo(pIX) ;
...
}
在上例中,我们将CA和CB都当成是IX接口来使用。这是多态的一个例子。图2-7显示了上例的内存结构。图中实例数据是用空框表示的,这是由于COM程序员一般都不太关心实例数据究竞是什么。
图2-7显示的两个类CA和CB分别具有不同的实例数据、vtbl以及实现。但各vtbl因其具有相同的格式而可以按相同的方式来访问。函数Fx1的地址在两个vtbl中都占据了第一个表项,而Fx2均占据第二个表项,如此等等。这种格式是编译器根据抽象基类的定义而生成的。在某个类实现抽象基类时,此时它将被强制使用此种格式。对于组件也将是这样。当组件返回一个IX接口指针时,它必须保证此指针指向正确的结构。
本章小结
在本章中我们从接口的一般性概念讲起,一直讲到其内存结构。我们看到接口可以通过封装其内部实现细节而使一个由组件构成的系统免受变化的影响。只要接口不发生变化,那么客户或组件可以在不影响整个系统正常运行的情况下自由地变化。这使得我们可以用新的组件来代替老的组件。客户也可以一致地对待实现同一接口的所有组件。
另外我们介绍了如何在C++中使用纯抽象基类来实现接口。C++编译器为纯抽象基类所生成的内存结构同COM接口所要求的内存结构是相同的。
这一章介绍了什么是接口、如何实现接口以及如何使用接口。但本章中的接口并不是真正的COM接口。COM需要所有的接口都支持三个函数。这三个函数必须是接口的vtbl中的前三个函数。在下一章中我们将讨论这三个函数中的第一个函数QueryInterface。