概述

学习微软技术COM是绕不开的一道坎,最近做项目的时候发现有许多功能需要用到COM中的内容,虽然只是简单的使用COM中封装好的内容,但是许多代码仍然只知其然,不知其所以然,所以我决定从头开始好好学习一下COM基础的内容,因此在这记录下自己学习的内容,以便日后参考,也给其他朋友提供一点学习思路。
COM的全称是Component Object Module,组件对象模型。组件就我自己的理解就是将各个功能部分编写成可重用的模块,程序就好像搭积木一样由这些可重用模块构成,这样将各个模块的耦合降到最低,以后升级修改功能只需要修改某一个模块,这样就大大降低了维护程序的难度和成本,提高程序的可扩展性。COM是微软公司提出的组件标准,同时微软也定义了组件程序之间进行交互的标准,提供了组件程序运行所需的环境。
COM是基于组件化编程的思想,在COM中每一个组件成为一个模块,它可以是动态链接库或者可执行文件,一个组件程序可以包含一个或者多个组件对象,COM对象不同于OOP(面向对象)中的对象,COM对象是定义在二进制机器代码基础之上,是跨语言的。而OOP中的对象是建立在语言之上的。脱离了语言对象也就不复存在.COM是独立在编程语言之上的,是语言无关的。COM的这一特性使得不同语言开发的组件之间的互相交互成为可能。

COM对象和接口

COM中的对象类似于C++中的对象,对象是某个类中的实例。而类则是一组相关的数据和功能组合在一起的一个定义。使用对象的应用(或另一个对象)称为客户,有时也称为对象的用户。
接口是一组逻辑相关的函数的集合,比如一组处理URL的接口,处理HTTP请求的接口等等。在习惯上接口通常是以”I”开头。对象通过接口成员函数为客户提供各种形式的服务。一个对象可以拥有多个不同的接口,以表现不同的功能集合。 在C++语言中,一个接口就是一个虚基类,而对象就是该接口的实现类,派生自该接口并实现接口的功能。

class IBook
{
public:
virtual void NextPage() = 0;
virtual void ForwardPage() = 0;
}

class IAppliances
{
public:
virtual void charge() = 0;
virtual void shutdown() = 0;
}

class CKindle: public IBook, IAppliances
{
public:
virtual void NextPage();
virtual void ForwardPage();
virtual void charge();
virtual void shutdown();
}

就像上面的例子,上面的例子中提供了一个书本的接口,书本可以翻到上一页,下一页,而电器有充电和关机的接口,最后我们利用kindle这个类来实现这两个接口。所以在使用上我们可以利用下面的伪代码来使用

pInterface = CreateInterface(ID_IBOOK, ID_KINDLE);
pInterface->NextPage();
if(Late())
{
pInter2 = pInterface->QueryInterface(ID_APPLIANCES);
pInter2->shutdown();
}

在平时我们使用kindle的翻页功能来看书,因为翻页功能在接口IBook,所以首先调用一个创建接口的函数,传入对应接口以及接口实现类的标识,用来生成相应的接口,其实在内部也就是根据类ID来创建一个对应的实现类的实例。然后根据需要转化为对应基类的指针。在看书看累的时候,将接口转化为电子产品的接口,调用对应的关机功能,关闭电子书。在之后比如说kindle进行了升级,也就是重写了实现这些接口的代码,但是接口原型不变,这样使用接口的代码不用改变,也就是说即使kindle对内部进行了升级,优化某些功能,用户在使用上仍然是那样在用,不必改变使用习惯。再比如kindle出了一个新款,提供了背光功能,这个时候可能提供一个新接口:

class IAppliances2 : public IAppliances
{
public:
virtual void Light() = 0;
}

然后只需要稍微更新一下CKindle这个实现类,新增一个Light接口的实现,在使用上如果不用背光功能原来的代码就够用了,如果要使用背光功能,只需要将原来的接口类型改为IAppliances2 ,并且添加调用背光功能的函数,而其余的功能也不变,这与实际生活相似,某个产品提供新功能时,一般保持原始功能的使用方法不变,新功能会有新的按钮或者其他方法进行打开。
再比如说我不想用kindle了改用其他的电子阅读器,只要接口不变,我的使用方法基本不变,唯一改变的可能是我以前拿着kindle,现在拿着其他品牌的阅读器,也就是说可能要改变传入CreateInterface函数中的类标识。

COM基本接口

COM中所有接口都派生自该接口:

struct IUnknown
{
virtual HRESULT QueryInterface(REFIID riid,void **ppvObject) = 0;
virtual ULONG AddRef( void) = 0;
virtual ULONG Release( void) = 0;
};

所有类都应该实现上述三个方法,AddRef主要将接口的引用计数+1, 而Release则是将引用计数 -1,当对象的引用计数为0,则会调用析构函数,释放对象的存储空间。每一次接口的创建和转化都会增加引用计数,而每次不再使用调用Release,都会把引用计数 -1,当引用计数为0时会释放对象的空间。
QueryInterface主要用来进行接口转化,将对象的指针转化为另外一个接口的指针,就好像上面例子中pInter2 = pInterface->QueryInterface(ID_APPLIANCES);这句代码将之前的Ibook接口转化为电子产品的接口。在C++中也就是做了一次强制类型转化。

对象和接口的唯一标识

在COM中,对象本身对于客户来说是不可见的,客户请求服务时,只能通过接口进行。每一个接口都由一个128位的全局唯一标识符(GUID,Global Unique Identifier)来标识。客户通过GUID来获得接口的指针,再通过接口指针,客户就可以调用其相应的成员函数。与接口类似,每个组件也用一个 128 位 GUID 来标识,称为 CLSID(class identifer,类标识符或类 ID),用 CLSID 标识对象可以保证(概率意义上)在全球范围内的唯一性。
实际上,客户成功地创建对象后,它得到的是一个指向对象某个接口的指针,因为 COM 对象至少实现一个接口(没有接口的 COM 对象是没有意义的),所以客户就可以调用该接口提供的所有服务。根据 COM 规范,一个 COM 对象如果实现了多个接口,则可以从某个接口得到该对象的任意其他接口。
由此可看出,客户与 COM 对象只通过接口打交道,对象对于客户来说只是一组接口。
在COM中GUID的定义如下:

typedef struct _GUID {
unsigned long Data1;
unsigned short Data2;
unsigned short Data3;
unsigned char Data4[ 8 ];
} GUID;

一般我们在程序中只是作为一个标志来使用,并不对它进行特别的操作。生成它一般是使用VS自带的GUID生成工具。
而CLSID的定义如下:

typedef GUID CLSID;

其实在COM中一般涉及到ID的都是GUID,只是利用typedef另外定义了一个名称而已
另外COM也提供了一组函数用来对GUID进行操作:

函数

功能

IsEqualGUID

判断GUID是否相等

IsEqualCLSID

判断CLSID是否相等

IsEqualIID

判断IID是否相等

CLSIDFromProgID

把字符串形式的CLSID转化为CLSID结构形式(类似于将字符串的234转化为数字,也是把字面上的CLSID转化为计算机能识别的CLSID)

StringFromCLSID

把CLSID转化为字符串形式

IIDFromString

把字符串形式的IID转化为IID接口形式

StringFromIID

把IID结构转化为字符串

StringFromGUID2

把GUID形式转化为字符串形式

COM接口的一般使用步骤

一般使用COM中的时候首先使用CoInitialize初始化COM环境,不用的时候使用CoUninitialize卸载COM环境,在使用接口中一般需要进行下面的步骤
1. 调用CoCreateInstance函数传入对应的CLSID和对应的IID,生成对应对象并传入相应的接口指针。
2. 使用该指针进行相关操作
3. 调用接口的QueryInterface函数,转化为其他形式的接口
4. 在最后分别调用各个接口的Release函数,释放接口
下面提供一个小例子,以供参考,也方便更好的理解COM

//组件部分
extern "C" __declspec(dllexport) void __stdcall ComCreateObject(GUID clsID, GUID interfaceID, void** pObj);
void __stdcall ComCreateObject(GUID clsID, GUID interfaceID, void** pObj)
{
if (clsID == CLSID_COMSTRING)
{
CComString *pComObject = new CComString;
*pObj = pComObject->QueryInterface(interfaceID);
}
}

class IComBase
{
public:
virtual void* QueryInterface(GUID gInterfaceId) = 0;
virtual void AddRef() = 0;
virtual void Release() = 0;
};

static const GUID IID_ICOMSTRING = { 0xb2fcd22c, 0x63fa, 0x4f61, { 0xbf, 0x12, 0xd3, 0xd2, 0x5a, 0x99, 0x59, 0x24 } };
class IComString : public IComBase
{
public:
virtual void Init(LPCTSTR pStr) = 0;
virtual int Find(LPCTSTR lpSubStr) = 0;
virtual int GetLength() = 0;
};

static const GUID CLSID_COMSTRING = { 0xf57f3489, 0xff2d, 0x4c97, { 0xb1, 0xf6, 0xc, 0x60, 0x7e, 0xf7, 0xae, 0xfc } };

class CComString : public IComString
{
public:
virtual void* QueryInterface(GUID gInterfaceId);
virtual void AddRef();
virtual void Release();

virtual void Init(LPCTSTR pStr);
virtual int Find(LPCTSTR lpSubStr);
virtual int GetLength();

protected:
int m_nCnt = 0;
CString m_csString;
};

//cpp
void* CComString::QueryInterface(GUID gInterfaceId)
{
if (gInterfaceId == IID_ICOMSTRING)
{
//该接口的引用计数+1
AddRef();
return dynamic_cast<IComString*>(this);
}
//如果它还实现了其他接口,可以再写判断,生成其他类型的接口
return NULL;
}

void CComString::AddRef()
{
m_nCnt++;
}

void CComString::Release()
{
m_nCnt--;
//引用计数为0,此时没有该类的接口被使用,应该释放该类
if (m_nCnt == 0)
{
delete this;
}
}

void CComString::Init(LPCTSTR pStr)
{
m_csString = pStr;
}

int CComString::Find(LPCTSTR lpSubStr)
{
return m_csString.Find(lpSubStr);
}

int CComString::GetLength()
{
return m_csString.GetLength();
}

这些代码被封装在一个dll中,dll中导出一个函数ComCreateObject,外部在使用时调用该函数传入对应的ID,以便生成对应的接口。
在这个dll里面提供一个接口的基类IComBase,这个是仿照了COM种的IUnknow基类,另外定义了一个IComString字符串的接口,同时定义了它的实现类CComString,为了简单,它的功能方法我直接使用了一个CString类实现。
在函数ComCreateObject,会根据传入对应的类ID,来生成对应的类实例,然后调用实例的QueryInterface,转化成对应的接口,在实现类中实现了这个方法,实现类中的QueryInterface方法主要完成了类型转化并将引用计数+1。
而Release函数在每次-1的时候会进行判断,当引用计数为0时销毁该类的实例
由于类是new出来创建在堆上的,所以每次用完一定要记得调用Release释放,否则会造成内存泄露
注意:在使用这里使用的是dynamic_cast进行类型转化,在进行类的强制类型转化时,特别是在有多重继承的情况下,最好使用dynamic_cast方式进行转化,当一个类拥有多个基类时,类中有多个虚函数表,为了能正常找到对应的虚函数表,就需要进行对应的偏移量的计算,C中的强制类型转化是直接将对象的首地址进行转化,这样在寻址虚函数表时可能会出错。而dynamic_cast会进行对应的计算。
在使用上

void ComInitialize();
void ComUninitialize();
typedef void(__stdcall *pfnCreateInstance)(GUID, GUID, void**);

pfnCreateInstance CreateInstance;
HMODULE hComDll = NULL;

int _tmain(int argc, _TCHAR* argv[])
{
ComInitialize();
IComString *pIString = NULL;
CreateInstance(CLSID_COMSTRING, IID_ICOMSTRING, (void**)&pIString);
pIString->Init(_T("Hello World"));
IComString* pIString2 = (IComString*)(pIString->QueryInterface(IID_ICOMSTRING));
int nLength = pIString2->GetLength();
int iPos = pIString2->Find(_T("World"));

printf("%d, %d\n", nLength, iPos);
pIString->Release();
pIString2->Release();
return 0;
}

void ComInitialize()
{
hComDll = LoadLibrary(_T("ComInterface.dll"));
if (NULL != hComDll)
{
CreateInstance = (pfnCreateInstance)GetProcAddress(hComDll, "ComCreateObject");
}
}

void ComUninitialize()
{
FreeLibrary(hComDll);
}

给使用者使用时只需要提供对应类和接口的GUID,然后将函数ComCreateObject原型提供给调用者,以便生成对应的接口。
这里为了模仿COM的使用定义了ComInitialize和ComUninitialize这两个函数,真实的初始化函数怎么写的,我也不知道,在这里只是为了模仿COM的使用。
至此相信各位小伙伴应该对COM有了一个初步的了解