COM深入理解(上)
——方法参数类型为CRuntimeClass*、void*等
经常见到论坛上有问如何传递void*或一个自定义类,如:class CA; CA*等。本文就COM的运作方式说明如何传递一自定义类对象的指针,并澄清MIDL的作用,以说明其并不是必须的。注意,本文所说的自定义类型传递与自动化技术(基于IDispatch)中的自定义结构的传递不是同一个概念,没有任何关系。
COM的服务提供方式
COM这个数学模型的本质是一个服务提供方式,与DLL(动态连接库技术)一样,都是对外提供服务,在某种意义上这又被称作代码重用,但应分清它们之间的区别——代码重用只是提供服务的一种方式,这也正是DLL的目的。
DLL在其PE格式(Portable Executable Format,一种文件格式,exe、dll、obj等多种可执行文件都是这种格式)中定义一个节(section,即一段连续存储空间),称为导出节。在这个节中,记录着当前DLL文件中每个导出函数的入口代码的相对虚拟地址(Relative Virtual Address——RVA),其相当于是函数地址。当此DLL文件被映射进装载进程的虚拟内存空间时,线程就可以根据导出节中的相对虚拟地址而跳到DLL的导出函数入口处执行导出函数的代码。此时就称导出函数的代码被重用了。
上面的实现方式中,DLL的函数导出方式是静态导出(即使在文件映射时刻可能由于重定位的要求而使函数地址发生变换,但在其整个载入期间函数地址都不发生变化)。COM与此完全不同,其是动态导出的,即又增加了一级指针,客户得到的不是函数指针,而是函数指针数组,虽然DLL的导出节也相当于函数指针数组,但COM在此又增加一级包装,通过一个函数获得动态的通过在堆或栈上分配的内存构建的函数指针数组,而不是DLL导出节那样的只读型(顶多可以Copy-On-Write)内存(虽然不是重点),且只有一个。
前面提到的函数指针数组在COM的实现中就表示经常听说的接口。而获得动态函数指针数组的函数,COM运行时期库则提供了一个函数来实现——CoGetClassObject,其返回客户使用的第一个函数指针数组(接口),其功能上和GetProcAddress非常类似,只不过后者只返回一个函数指针,而前者返回的是函数指针数组。由于返回函数指针数组的方式是传递一个指针(即传递函数指针的指针,而不是调用什么API),因此此方法可以被随意的应用于任何地方,包括接口导出的函数中。COM中的IUnknown::QueryInterface就专门用于此项服务。
由于C++提供的虚函数实现机制中也有函数指针数组,故用C++编写COM组件时,接口一般就用一纯虚基类来表示。而C中,由于没有虚函数的概念,只好使用结构来定义一个函数指针数组。
C++类的实现方式
二进制代码,即机器代码中,是没有类的概念的。但COM是建立在二进制基础上的服务提供,因此下面简略说明一下C++中的类的实现方式。
类其实就是一块连续内存和一些在此内存块上执行操作的函数,而此连续内存块就是一个结构的实例,而成员函数其实只是多了一个以此结构为类型的名字为this的参数的普通C函数。如下一个类定义:
class CA
{
long a;
public:
CA() : a( 0 ) {}
long GetA()
{
return a;
}
};
CA aa;
long a = aa.GetA();
其实际为:
struct S_CA
{
long a;
};
long S_CA_GetA( S_CA *this )
{
return this->a;
}
void S_CA_CA( S_CA *this )
{
this->a = 0; // 假设没开任何优化开关
}
S_CA s_aa;
S_CA_CA( &s_aa );
long a = S_CA_GetA( &s_aa );
因此,类其实就是一个结构和一些其相关的函数,其存在的目的就是便于代码的书写并引入了语义以提供对面向对象编程思想的支持。而当类有虚函数时,和上面一样,只不过结构中多了一个成员专门记录一个函数指针数组,代表此类的所有虚函数,这也正是COM接口的实现形式。如下:
class CA
{
long a;
public:
CA() : a( 0 ) {}
virtual long GetA()
{
return a;
}
virtual void SetA( long var )
{
a = var;
}
};
CA aa;
long a = aa.GetA();
aa.SetA( 34 );
变为:
struct S_CA_D;
struct S_CA_F // 相当于一个只有两个元素的函数指针数组
{
long ( *GetA )( S_CA_D* );
void ( *SetA )( S_CA_D*, long );
};
struct S_CA_D
{
S_CA_F *pF;
long a;
};
long S_CA_GetA( S_CA_D *this )
{
return this->a;
}
void S_CA_SetA( S_CA_D *this, long var )
{
this->a = var;
}
S_CA_F g_S_CA_F = { S_CA_GetA, S_CA_SetA };
void S_CA_CA( S_CA_D *this )
{
this->pF = &g_S_CA_F; // 设置虚函数指针
this->a = 0; // 假设没开任何优化开关
}
S_CA_D s_aa;
S_CA_CA( &s_aa );
long a = ( s_aa.pF->GetA )( &s_aa );
( s_aa.pF->SetA )( &s_aa, 34 );
无MIDL支持的参数传递
了解了C++中类的实现方式后,因此要传递一个类指针,实际就是传递一个结构指针,而论坛中对此的问题就是MIDL编译时总是说“类型未定义”之类的错误,下面先了解MIDL的作用。
MIDL只是Microsoft提供的一个对IDL(Interface Definition Language,接口定义语言)或ODL(Object Definition Language,对象定义语言)编写的代码进行编译的编译器。此编译器编译.IDL或.ODL文件后,生成类型库(如果有必要)和代理/占位组件(关于代理/占位组件可参考我写的另一篇文章《COM线程模型》)的工程文件。
当MIDL是为COM(也可是RPC)生成代理/占位组件时,其生成的工程文件包括:XXX_i.c、XXX_p.c、dlldata.c、XXX.h及一个MAKE文件XXXps.mk(VC自代的工具NMAKE程序可使用此文件)以帮助生成代理/占位组件(其中被编译的.idl文件名为XXX.idl)。
应当注意,此代理/占位组件不是必须的,其只有当需要时(如接口指针需要进行汇集操作,组件请求调用环境等)才发生作用,除此以外的任何时候其都不发生作用。因此,如果可以保证将生成的组件不会被跨套间进行调用或不会要求COM提供附加功能(如调用CoGetCallContext)时,可以不生成代理/占位组件,带来的后果就是此COM组件的适用范围被严重限制(此时相当于一个DLL,但还是具有动态提供服务的功能),当需要汇集时,如果没有编写防御代码,程序很可能崩溃。下面举例说明如何编写相当于DLL,不需要.idl文件的COM组件。
下面是欲实现的接口:
interface IModule : IUnknown
{
HRESULT GetViewRuntimeClass( [out] CRuntimeClass **pClass );
};
由于已经没有MIDL的唠叨,因此还可以如下:
interface IModule : IUnknown
{
HRESULT GetViewRuntimeClass( [out] CRuntimeClass **pClass );
void GetModuleName( [out] CString *pName );
};
手工生成一个.h文件,如下:
/////////////////////ModuleInterface.h///////////////////////////
#pragma once
#include <objbase.h>
class __declspec( uuid( "0E0042F0-0000-0360-3400-0000EA0030AB" ) )
IModule : public IUnknown
{
public:
virtual HRESULT STDMETHODCALLTYPE
GetViewRuntimeClass( CruntimeClass **pClass ) = 0;
virtual void STDMETHODCALLTYPE
GetModuleName( CString *pName ) = 0;
};
/////////////////////////////////////////////////////////////////
然后在工程中的stdafx.h中添加#include "ModuleInterface.h"以将IModule导入到工程中。然后就可以在工程中任何欲实现此接口的源文件(.cpp)中就编写的方法(MFC还是ATL或其他组件实现方式)进行相应的编写。下面使用ATL来编写一个实现了IModule接口的组件。
/////////////////////PopedomModule.h/////////////////////////////
#pragma once
class ATL_NO_VTABLE CPopedomModule :
public CComObjectRootEx< CComSingleThreadModel >,
public CComCoClass< CPopedomModule, &CLSID_PopedomModule >,
public IModule
{
public:
DECLARE_REGISTRY_RESOURCEID( IDR_POPEDOMMODULE )
BEGIN_COM_MAP( CPopedomModule )
COM_INTERFACE_ENTRY( IModule )
END_COM_MAP()
DECLARE_PROTECT_FINAL_CONSTRUCT()
// 接口实现
public:
// IModule
STDMETHOD(GetViewRuntimeClass)( CRuntimeClass **pClass );
void STDMETHODCALLTYPE GetModuleName( CString *pName );
};
OBJECT_ENTRY_AUTO( __uuidof( PopedomModule ), CPopedomModule )
/////////////////////////////////////////////////////////////////
/////////////////////PopedomModule.cpp///////////////////////////
#include "stdafx.h"
#include "PopedomModule.h"
STDMETHODIMP CPopedomModule::GetViewRuntimeClass( CRuntimeClass **pClass )
{
if( !pClass )
return E_INVALIDARG;
*pClass = RUNTIME_CLASS( CPopedomView );
return S_OK;
}
void STDMETHODCALLTYPE CPopedomModule::GetModuleName( CString *pName )
{
if( pName )
*pName = L"权限";
}
/////////////////////////////////////////////////////////////////
上面的做法是不提倡的,但是可以运行,只要保证不发生汇集操作(如跨套间访问)等,则不会出什么问题。这里使用了MFC特定的类型CRuntimeClass,因此需要保证客户端也加载MFC库文件,并且其使用的参数是MFC特定的类型,很明显,不能和其他语言公用了。并且其由于导出类,必须保证组件和客户是使用同一个编译器编译的,因为不同的编译器对成员函数的名字修饰不同(如上面我就修饰成S_CA_GetA,但其他人可能修饰成CA_GetA)。这也是为什么其相当于一个高级的DLL,而高级也就是还保持着语义,依旧有着COM编程模型中接口的意义(关于COM编程模型,可以参考我写的另一篇文章《COM样例(二)》)。
代理对象
上面的做法说白了其实就是传递一个四字节数字(假设为32位系统开发)而已,所以才被称为高级的DLL。但是,当将这个进程内的一个自定义类CA的对象指针传递给另一个进程,则上面的解决方法就无效了,因为指针所指的内存是进程相关的,不能跨进程使用。
由于类对象的指针其实就是一个结构实例的指针,因此只需将那个结构实例复制一份,通过任何跨进程手段传递过去就可以了(这里没考虑静态成员变量)。这是传值操作,但类对象的指针这一点已经表明是传址操作。而所谓的传址操作的意义就是引用,也就是说关心的不是类对象的内容,而是类对象的控制。但由于类对象在内存中的所在位置无论如何客户(另一个进程)都无法访问到,这就和Windows提出的内核句柄一样,只能由核心代码访问内核句柄所在内存。因此就必须有人代客户去访问那块内存以表现出客户能够控制那个类对象,而这个人就是有名的“代理对象”。
因此为了传递自定义类对象的指针以实现引用目的(不是为了效率而使用指针),就必须为那个类提供一个代理类,而客户就通过这个代理类的对象来间接访问真正的类对象。也就是说,代理对象的存在是为了让客户可以操作真正的类对象,如果只是需要类对象的状态,那么代理对象则不是必须的,只需执行一个传值操作即可。
为了表现出是客户在操作真正的对象,代理对象的工作就是将客户的所有命令原封不动地传到真正对象所在进程,并扮演客户对类对象进行操作。因此代理对象就被分成两部分,一部分在客户进程中运作,扮演类对象,称为代理(proxy);另一部分在类对象所在进程运作,扮演客户,称为占位(stub)。
如果将欲传递的类对象的指针变成接口形式,就可以利用MIDL帮忙生成代理类的代码(也就是代理/占位组件),而不用自己编写了,但当要传递的类是无法改变的,如一个类库中的类,则只有自己编写代理类的代码了(因为IDL语言中没有类这个概念)。
出于篇幅和时间的考虑,如何使用MIDL来正确地传递一个自定义类对象的指针,即如何利用MIDL辅助编写代理/占位组件以支持传递自定义类对象的指针,而不是上面的极端做法,留于《COM深入理解(下)》中说明,待续。