在开发程序时可以从组件库中选择合适的组件快速构造出满足需要的应用程序。这大大提高了程序的可维护性和开发效率。
组件化程序设计思想是将复杂的应用程序被设计成小的、功能单一的组件模块。为了实现这样的软件,模块与模块之间需要一些规范,这些规范规定了模块如何划分以及模块之间的交互的问题。COM标准就是这样一组规范。
COM是Component object model的缩写。中文名为组件对象模型。Microsoft提出的COM规范不仅体现了组件化程序设计的思想,在实现中也采用面向对象程序设计方法。
COM对象是建立在二进制可执行代码级的基础上,因此COM对象是语言无关的。只要它们能生成符合COM规范的可执行代码,在任何平台都可以使用。这一特性使使用不同语言开发的组件进行交互成为可能。COM是Microsoft定义的。在windows系统平台上COM技术被应用于系统的各个层次。
组件技术是面向接口的。接口封装了软件内部的细节,只要留给客户的接口没有改变,客户端代码就不需要重新编译。软件工程一直强调软件模块的强内聚,低耦合。COM技术很好的遵循这种原则因而被广泛的使用。
在COM中,一个组件程序被称为一个模块。COM组件是以win32动态链接库dll或可执行文件的形式发布可执行代码。一个组件中可以有多个COM对象。
组件可以是一个dll,此时的组件为进程内组件。也可以是exe程序,这时的组件是进程外组件。
COM库提供了一组API,它提供对组建对象或是客户的管理服务。COM库对所有组件的操作提供了一致的接口。
由于COM的封装性,各种实现机制都被封装在了模块内部,客户在使用时仅仅需要得到接口。也就是说客户与组件是靠接口建立联系的。对于一个C++类来说它的接口是它public下的一组成员函数。对于一个dll来说它的接口是它导出的一组函数 。对于COM来说它的接口就是一组内存结构。由于使用C++来实现COM非常的简单,所以很多COM都是使用C++来写的,但是使用其他语言也是可以的,只要符合COM规范。
C++使用抽象基类来实现COM接口。COM可能有很多个接口,因此我们可以采用多重继承,从抽象基类中继承来实现多个接口。但是接口也并不一定需要继承得到,COM标准并没有要求实现某个接口的类要从某个抽象基类继承。对接口的继承仅仅是一种实现细节而已。因此,除了使用不同的抽象基类通过多重继承实现多个接口外,还可以在一个类中直接实现多个接口。
客户只能通过接口同组件打交道,对客户来说组件就是一组接口集。 客户它并不知道某个组件内部提供了哪些接口。事实上客户对组件知道的越少,在不影响客户运行的情况下组件就可以最大限度的发生变化。
接口查询
每一个COM接口都必须从IUnknown接口中继承而来。因为IUnknown接口提供了两个重要的特性:生存期控制和接口查询。
客户对于组件有哪些接口并不清楚,但是客户可以通过调用每个组件都有的IUnknown接口中的QueryInterface函数来查询组件中的接口。
IUnknown接口的实现。 class IUnknown { public: virtual HRESULT _stdcall QueryInterface(const IID&id,void **pv)=0; virtual ULONG _stdcall AddRef()=0; virtual ULONG _stdcall Release()=0; };
QueryInterface用于查询COM对象其他接口指针。AddRef和Release用于增加和减少引用计数。
由于所有的接口都从IUnknown继承而来因此每个接口的虚函数表的前三项都是QueryInterface,AddRef,Release。这使得所有的COM接口都被当作IUnknown接口来处理。这就是所谓的多态性。
所有的接口也都支持QueryInterface。因此所有的COM接口都可以被客户用来查询它所支持的接口。若支持则返回该接口的指针,否则返回一个错误代码。
HRESULT _stdcall QueryInterface(const IID&id,void **pv);
其中IID是一个数据结构,用于标识每个接口,ppv返回接口指针。返回值HRESULT是一个32位值,标识函数执行成功或失败。但返回值不能直接同S_OK或S_FAIL进行比较,而应该使用SUCCEEDED或FAILED宏进行判断。因为成功或失败的值或有多个,采用上面的宏会对返回值的最高两位进行判断,看是否成功或失败。
如
void foo(IUnknown *pI) { IX* ppv=NULL; HRESULT ret= pI->QueryInterface(IID_IX,(void**)&ppv); if(SUCCEEDED(ret)) { ppv->AddRef(); } }
QueryInterface实现。
假定CA支持两个接口IX和IY。
类IX的内存结构:
class IX:public IUnknown { public: virtual void IX_Func1()=0; }; vlass IY:public IUnknown { pulbic: virtual void IY_Func2()=0; }; vlass CA:public IX,public IY { pblic: HRESULT _stdcall QueryInterface(IID&id,void**ppv)=0; ULONG _stdcall AddRef(); ULONG _stdcall Release(); Void IX_Func1(); Void IY_Func2(); }; HRESULT _stdcall CA::QueryInterface(IID&id,void **ppv) { if(id==IID_IUnknown) { *ppv=static_cast<IX*>(this); } else if(id==IID_IX) { *ppv=static_cast<IX*>(this); } else if(id==IID_IY) { *ppv=static_cast<IY*>(this); } else { *ppv=NULL; return E_NOINTERFACE; } static_cast<IUnknown>(*ppv)->AddRef(); return S_OK; }
上述代码使用if-else语句来实现。但是不能使用switch-case。因为IID是一个结构而不是一个值。
在上述类型转换中,应特别注意,将this转换成IX*和IY*结果是不同的。
IX的this与CA的this指针位置相同。因此this==static_cast<IX*>(this); 但IY的this指针在CA中位置的不同,将this转换为IY*需要添加一个偏移量this+Δy==static_cast<IY*>(this);
由于CA多重继承与IX和IY,而IX,IY又继承于IUnknown,因此将this转换为IUnknown*具有二义性:
*ppv=static_cast<IUnknown*>(this);
此时*ppv的值是static_cast<IUnknown*>(static_cast<IX*>(this);
或static_cast<IUnknown*>(static_cast<IY*>(this));
在id==IID_IUnknown时,*ppv=static_cast<IX*>(this);
其实这时无关紧要的,*ppv=static_cast<IY*>(this)也是可以的。但是要保持一致。
接下来将会介绍动态链接库DLL对于组件构造软件系统的重要性。
我们会把组件放在DLL中。但是必须要明白的是DLL并不是组件,组件也并不是DLL。DLL只是实现组件的一种方式。如果你对DLL还不是很明白,可以参考我的博文:Windows核心编程系列谈谈dll。
前面代码中客户调用CreateInstance获得组件的一个实例并得到一个IUnknown接口指针。在使用dll实现组件时也有类似的函数。且此函数需要从dll中导出。
external “C” __declspec(dllexport) IUnknown *CreateInstance()
{
IUnknown*pI=static_cast<IX*>(new CA);
pI->AddRef();
return pI;
}
在客户代码中,与组件共享的文件包括dll的头文件dll.h以及各接口的声明的头文件。GUID.h中存储的是各接口的IID,也提供给了客户代码。
HRESULT经常被用作返回值。向其用户报告各种情况。如QueryInterface返回的就是一个HRESULT值。HRESULT实际上是一个32位的值。通常被定义为DWORD或long类型。系统返回的HRESULT的值在Win32的WINERROR.h有定义。HRESULT与win32错误代码有些类似,但是并不完全一样。HRESULT可分为三个部分。但是最重要的是最高的两位,它反映了函数调用结果的基本情况。如成功、失败、警告、错误。
因为成功或失败的情况并不止一个。比如函数调用失败,失败也包括很多具体的情况。后面的位数表示为什么失败。成功也是一样。这也是在调用QueryInterface或其他函数时使用SUCCEEDED或FAILED宏进行判断。因为这两个宏或检查返回值的最高两位检查成功或失败。不能简单的把返回值与S_OK或E_FAIL进行比较。因为函数执行成功后可能有不同的成功返回值,执行失败之后也会有不同的失败返回值。第16-28位表示设备代码。标识了操作结果来自那个设备。现在可以不作考虑。低16位为错误代码。只有当设备代码全为0时HRESULT值才与win32错误代码对应发现HRESULT值不在win32错误代码中,可查找低16位与之相同的win32错误代码。
前面我们曾说过IID是一个标识接口的常量。实际上IID是一个16字节的GUID结构。GUID是global unique ID全球唯一标识符。IID是接口ID的缩写。在不经过任何机构的协调通过编程方法来生成的GUID是唯一的。VC提供了一个GUIDGEN.exe的工具,每次运行都可以保证生成的GUID全球唯一。GUID具有时间和空间上的唯一。它是根据当前时间和网卡的地址生成的 。没网卡的计算机则使用其他算法生成。GUID结构很大,一般不在程序中到处复制。在传参时一般采用传常量引用的方式。
GUID不仅可以用于标识接口,还可以标识组件。当标识组件时为CLSID。即class id。同接口一样,每个组件都有一个不同的CLSID。
接下来我们将模拟COM的实现。
在DLL中定义了IX,IY接口,它们都继承与IUnknown。然后CA从IX,IY继承,并实现了接口中的成员函数。DLL中需要导出的函数是CreateInstance,它将创建CA并返回CA的接口。
DLL中实现代码:
//IX.h
#include"objbase.h"
class IX:public IUnknown
{
public:
virtual void IX_Func()=0;
};
//IY.h
#include"objbase.h"
class IY:public IUnknown
{
public:
virtual void IY_Func()=0;
};
//CA.h
#ifndef CA_H
#define CA_H
#include<iostream>
#include"IX.h"
#include"IY.h"
extern UINT g_NumOfCom;
class CA:public IX,public IY
{
public:
CA()
{
m_Ref=0;
g_NumOfCom++;
std::cout<<"CA构造函数被调用!!"<<std::endl;
}
~CA()
{
std::cout<<"CA析构函数被调用!!"<<std::endl;
g_NumOfCom--;
}
HRESULT _stdcall QueryInterface(const IID&id,void **ppv);
ULONG _stdcall AddRef();
ULONG _stdcall Release();
void IX_Func();
void IY_Func();
public:
ULONG m_Ref;
};
#endif
//CA.cpp
#include"StdAfx.h"
#include"CA.h"
extern IID IID_IX;
extern IID IID_IY;
// {AB4B7F96-B8A5-4BB3-BF44-8FB158ED36AD}
extern IID IID_CA;
HRESULT CA::QueryInterface(const IID&id,void**ppv)
{
std::cout<<"CA::QueryInterface被调用!!"<<std::endl;
if(id==IID_IUnknown)
{
*ppv=static_cast<IX*>(this);
}
else if(id==IID_IX)
{
*ppv=static_cast<IX*>(this);
}
else if(id==IID_IY)
{
*ppv=static_cast<IY*>(this);
}
else
{
*ppv=NULL;
return E_NOINTERFACE;
}
static_cast<IUnknown*>(*ppv)->AddRef();
return S_OK;
};
ULONG CA::AddRef()
{
std::cout<<"CA::Addref被调用!!"<<std::endl;
m_Ref++;
return m_Ref;
}
ULONG CA::Release()
{
std::cout<<"CA:release被调用!!"<<std::endl;
if(--m_Ref==0)
{
delete this;
}
return m_Ref;
}
void CA::IX_Func()
{
std::cout<<"IX_Func被调用!!"<<std::endl;
}
void CA::IY_Func()
{
std::cout<<"IY_Func被调用!!"<<std::endl;
}
//GUID.cpp
#include"StdAfx.h"
// {49FA8F03-1AB0-4D75-B023-54B70FA731C2}
IID IID_IUnknown =
{ 0x49fa8f03, 0x1ab0, 0x4d75, { 0xb0, 0x23, 0x54, 0xb7, 0xf, 0xa7, 0x31, 0xc2 } };
IID IID_IX =
{ 0x8af3709f, 0xa8eb, 0x46c4, { 0xb5, 0x1, 0xbc, 0xb6, 0x7d, 0x45, 0x9a, 0xfe } };
// {C18D13A4-57AF-41D7-B5F2-46C1FEA6BC37}
IID IID_IY =
{ 0xc18d13a4, 0x57af, 0x41d7, { 0xb5, 0xf2, 0x46, 0xc1, 0xfe, 0xa6, 0xbc, 0x37 } };
CLSID CLSID_CA =
{ 0xab4b7f96, 0xb8a5, 0x4bb3, { 0xbf, 0x44, 0x8f, 0xb1, 0x58, 0xed, 0x36, 0xad } };
客户代码:
int main(int argc,char**argv)
{
IUnknown*pI=CreateInstance();
IX*pIx=NULL;
HRESULT hr=pI->QueryInterface(IID_IX,(void**)&pIx);
pI->Release();
if(SUCCEEDED(hr))
{
std::cout<<"查询IID_IX接口成功!!";
pIx->IX_Func();
}
IY*pIy=NULL;
hr=pIx->QueryInterface(IID_IY,(void**)&pIy);
if(SUCCEEDED(hr))
{
std::cout<<"获得IID_IY接口!!"<<std::endl;
pIx->Release();
pIy->IY_Func();
IUnknown*pIU=NULL;
hr=pIy->QueryInterface(IID_IUnknown,(void**)&pIU);
if(SUCCEEDED(hr))
{
std::cout<<"获得IID_IUnknown接口!!"<<std::endl;
if(pIU==pI)
{
std::cout<<"获得的IID_IUnknown接口与原来的IUnknown接口相同!!"<<std::endl;
pIU->Release();
}
else
{
std::cout<<"获得的IID_IUnknown接口与原来的IUnknown接口不相同!!"<<std::endl;
}
}
pIy->Release();
}
}
最后我们判断了从pIy接口查询的IUnknown接口是否与原来的接口相同。结果是相同的。这符合COM的一条原则:无论从那个接口查询IUnknown接口得到的接口指针都是相同的。因此也可以使用这条原则来判断两个接口是否属于同一组件。只要它们返回的IUnknown接口指针相同它们就属于同一组件。
在IUnknown抽象基类中还有另外两个纯虚函数AddRef和Release。它们是对组件进行生命期控制。这种方式不直接让客户控制组件的生命期,而是让客户通知对某接口的引用。当客户对组件所有接口的引用为0时,组件就知道自己可以被释放了。
COM采用引用计数来解决内存管理问题。每一个COM对象有维护一个引用计数。它使得组件自己可以删除自己。当客户取得一个接口时,客户调用AddRef使组件的引用计数加一。当客户放弃对某个接口的引用时调用Release,引用计数减一。当引用计数为0,组件被从内存中删除。正确的对组件生命期进行管控需要以下三个原则:
一:在QueryInterface和CreateInstance返回之前调用AddRef。
二:在接口使用完毕后调用Release;
三:在对接口指针赋值后要调用AddRef。
前两个原则在上面的代码中都有体现。第三条原则很容易被忽略。有时候为了提高性能而在一些适当的场合不调用AddRef和Release,我觉得这是得不偿失的。与调用AddRef和Release的性能损失相比,引用计数控制错误会带来更大的灾难。
上面介绍了引用计数,也许读者会问在何种层次上进行引用计数。是为COM对象中的每个接口都维持一个引用计数? 还是在组件层次上为所有COM对象维护一个计数? 还是在COM对象层次中为一个COM对象的所有接口维护一个计数?这关系到计数粒度的问题。
在组件层次上为所有COM对象维护一个计数,计数粒度太大。当每个COM对象被使用完毕后当不能被释放,必须等到所有对象被使用完毕后随组件一同释放。
为每个接口都维护一个计数,计数粒度又太小。
综合以上两种方法,我们为每个COM对象中的所有接口维护一个引用计数,同时组件中维护着一个还在使用的COM对象的个数。当一个COM的接口引用计数为0时,它将自己从内存中删除,同时会通知组件。此时组件中可用COM对象的引用个数减一,当所有对象都被释放后,组件也就可以从内存中卸载了。这里为什么使用了“卸载”?因为进程内组件是以dll的形式提供的。DLL内可以有多个COM对象。当COM对象都被释放后,此DLL就可以从用户进程地址空间中卸载。
本文参考自《COM技术内幕》和》《COM原理和应用》如有错误,请不吝指教!!