【教学】COM基础选讲
acmilan2015/09/07软件综合 IP:四川
COM是什么?COM是一种组件调用协议。COM在Windows操作系统中被广泛使用,是Win32的补充。COM是Windows操作系统的重要组成部分,Windows的许多功能都要通过调用COM组件来实现,其重要性不言而喻。

一、COM基础知识

1. 关于接口

COM是由各式各样的接口组成的。COM接口的背后是组件在进行真正的工作,但是客户是看不见组件本身的,它们只能看见一个一个的接口。所有的接口都继承自IUnknown。IUnknown负责管理接口背后的组件的生命周期和查找该组件的其它接口,包含QueryInterface、AddRef、Release三个函数。也就是说所有COM接口都包含这三个函数。

实际上COM的接口并不是通常意义上的C++接口。它有它自己的结构。它在C语言和C++中都能工作。下面给出了最简单的COM接口IUnknown在C/C++中的表现形式,在这里并不要求理解,只需要有个印象就可以了。它们定义在#include <windows.h>包含的Unknwn.h中。

在C语言中它是这样的:
typedef struct IUnknownVtbl
{
        HRESULT (__stdcall *QueryInterface )(IUnknown * This, const IID * const riid, void **ppvObject);
      
        ULONG (__stdcall *AddRef )(IUnknown * This);
      
        ULONG (__stdcall *Release )(IUnknown * This);
      
} IUnknownVtbl;
      
struct IUnknown
{
    struct IUnknownVtbl *lpVtbl;
};
     
typedef struct IUnknown IUnknown;

而在C++中则是一个纯虚的C++类:
struct __declspec(uuid("00000000-0000-0000-C000-000000000046")) __declspec(novtable) IUnknown
{
public:
        virtual HRESULT __stdcall QueryInterface(const IID &riid, void **ppvObject) = 0;
      
        virtual ULONG __stdcall AddRef( void) = 0;
      
        virtual ULONG __stdcall Release( void) = 0;
      
        template<class q>
        HRESULT __stdcall QueryInterface(Q** pp)
        {
            return QueryInterface(__uuidof(Q), (void **)pp);
        }
};</class>

这两种结构在二进制结构上是等价的,因此都可以正常运行。C++的更加方便一些,因此我们使用COM的时候推荐使用C++进行编译,即使不懂C++也没关系,因为C++是基本兼容C语言的。

2. COM 的唯一标识符——GUID、CLSID、IID

系统中COM的组件和接口众多,COM开发者遍及全世界,为了避免名称相同造成的系统错乱和程序不稳定,COM使用GUID在系统范围内区分各个组件和接口。对于每一个组件、每一个接口来说,GUID是全系统唯一的,一般也是全世界唯一的。其中组件的ID称作CLSID(即class id),而接口的ID称作IID(即interface id)。CLSID的信息保存在注册表HKEY_CLASS_ROOT\CLSID中,由系统进行识别,而IID则由具体的COM提供程序的QueryInterface进行识别。

GUID是一个128位的二进制代码,类似上边{00000000-0000-0000-C000-000000000046}这样的结构。GUID在头文件的定义如下:
typedef struct _GUID {
    unsigned long  Data1;
    unsigned short Data2;
    unsigned short Data3;
    unsigned char  Data4[ 8 ];
} GUID;

也就是说,GUID是由1个32位整数,2个16位整数,8个字节整数组成。我们在初始化GUID时,常采用以下方式:
// {12345678-abcd-1234-abcd-1234567890ab}
GUID myguid = { 0x12345678, 0xabcd, 0x1234, { 0xab, 0xcd, 0x12, 0x34, 0x56, 0x78, 0x90, 0xab } };

可以用Visual Studio自带的GUID创建工具创建全世界唯一的GUID:
无标题guid.png

3. COM 如何返回错误——HRESULT

COM调用有可能出现错误,因此对于COM组件来说,如何处理错误至关重要。COM规定,除了少数特殊的函数以外,大部分函数都应返回一个称作HRESULT的32位整型值,作为状态代码。第1节中的QueryInterface就返回了一个HRESULT。

对于HRESULT来说,有几点要特别注意:
1.当HRESULT小于0时,表示调用失败。可以用FAILED(hr)宏进行检验。
2.当HRESULT大于等于0时,表示调用成功。可以用SUCCEEDED(hr)宏进行检验。
3.如果COM过程返回TRUE,则HRESULT值为S_OK=0
4.如果COM过程返回FALSE,则HRESULT值为S_FALSE=1(和C/C++的TRUE/FALSE定义正好相反!)

为了方便起见,我们可以定义一个宏来确保在COM调用失败时显示错误代码,并退出程序:
#define ValidateHR(hr) \
    if (FAILED(hr)) { \
        printf("HRESULT错误:%p,在%s第%d行\n", hr, __FILE__, __LINE__); \
        exit(1); \
    }

捕获line.png

[修改于 9年5个月前 - 2015/09/08 23:18:21]

来自:计算机科学 / 软件综合
4
 
已屏蔽 原因:{{ notice.reason }}已屏蔽
{{notice.noticeContent}}
~~空空如也
acmilan 作者
9年5个月前 修改于 9年5个月前 IP:四川
789533
二、我们的第一个COM程序

1. COM环境的建立

首先用Visual Studio建立一个普通的控制台程序(去掉预编译头)。在前面添加windows.h的包含文件:
#include <windows.h>

使用COM之前首先要初始化COM环境——初始化COM环境使用CoInitialize(0)函数。它唯一的参数是保留的,必须始终为0:
HRESULT hr = CoInitialize(0);
ValidateHR(hr);
其中ValidateHR(hr)宏在上一节已经定义过了,用于检验COM调用是否成功,若失败则报错并退出程序。

在程序结束之前,要调用CoUninitialize()卸载COM环境,这个函数很简单,没有参数,没有返回值,直接调用即可:
CoUninitialize();

至此,我们的程序如下,这便是最简单的COM程序了:
#include <stdio.h>
#include <stdlib.h>
                                     
#include <windows.h> // 使用COM组件需要包含windows.h
                                     
#define ValidateHR(hr) \
    if (FAILED(hr)) { \
        printf("HRESULT错误:%p,在%s第%d行\n", hr, __FILE__, __LINE__); \
        exit(1); \
    }
                                     
int main()
{
    HRESULT hr = CoInitialize(0); // 初始化COM环境,参数保留,必须为0
    ValidateHR(hr);
                                     
    // 什么也不做
                                     
    CoUninitialize(); // 卸载COM环境
    return 0;
}</windows.h></stdlib.h></stdio.h>

2. 接口实例的获取、使用与释放

不同于大多数C++对象,COM组件是使用引用计数管理生命周期的。每一个接口实例都对应着组件的一个引用计数。每获得一个新的接口实例,组件的引用计数加一。每释放一个旧的接口实例,组件的引用计数减一。当引用计数减为0时,COM组件将析构并释放自己的内存。

获取一个COM接口实例有CoCreateInstanceQueryInterfaceAddRef等很多种方法,而释放一个COM接口实例一般是调用Release

a.释放一个接口实例

释放一个接口实例比较简单,但是正确地释放接口实例却非常重要。如果不正确释放,就会导致内存泄漏或者程序崩溃。上面已经讲了,每一个接口都有IUnknown的三个函数:QueryInterface、AddRef、Release。在使用完一个实例之后,程序要调用此接口的Release成员函数释放该实例。Release会使得引用计数减一,并于引用计数归零时删除组件本身每一个实例都需要调用一次Release:
p1->Release(); // p1 是程序所拥有的一个COM接口实例

b.获取一个接口实例


获取接口实例最通用的的方法,是使用CoCreateInstance函数。由于CoCreateInstance建立了新的接口实例,因此CoCreateInstance调用后,它会使组件的引用计数加一

CoCreateInstance函数的使用方法如下:
HRESULT hr = CoCreateInstance(CLSID_组件ID, NULL, CLSCTX_ALL, IID_接口ID, (void**)&接口指针);
第一个参数是组件ID(CLSID),第四个参数是接口ID(IID),可以在相关头文件中找到它的常量定义。最后一个参数是返回值,用以接收CoCreateInstance返回的接口指针。
第二个参数(外层IUnknown指针)不常用,一般指定为NULL。第三个参数(组件环境)一般指定CLSCTX_ALL,表示可以接受任何形式的COM服务(进程内、进程外、远程都可以)。

获取相同组件相同接口的新实例的方法,是使用AddRef它也会使得组件的引用计数加一AddRef的用法如下:
IMyInterface *p2 = p1; // p2和p1指向相同的实例
p2->AddRef(); // 在p2建立IMyInterface新的实例,引用计数加一

获取相同组件任意接口的新实例的方法,是使用QueryInterface它同样会使组件的引用计数加一
HRESULT hr = p1->QueryInterface(IID_接口ID, (void**)&接口指针);
QueryInterface的两个参数和CoCreateInstance最后两个参数的含义相同。

3. 获取、使用和释放接口实例的实际操作

在这里我们将要调用的是Windows的文字转语音(Text-To-Speech)引擎,需要包含头文件#include <sapi.h>
#include <sapi.h></sapi.h>

在sapi.h中,文字转语音使用的接口是ISpVoice,其组件ID为CLSID_SpVoice,接口ID为IID_ISpVoice。ISpVoice的成员函数Speak可以让电脑开始说话。首先,我们要建立一个ISpVoice实例:
// 建立ISpVoice接口实例(组件ID为CLSID_SpVoice,接口ID为IID_ISpVoice)
ISpVoice *iSpVoice = NULL;
hr = CoCreateInstance(CLSID_SpVoice, NULL, CLSCTX_ALL, IID_ISpVoice, (void**)&iSpVoice);
ValidateHR(hr);

ISpVoice实例建立成功之后,我们就可以使用->操作符直接调用它的的Speak函数了:
// 调用ISpVoice的Speak成员函数
iSpVoice->Speak(L"电脑在说话!", SPF_DEFAULT, NULL);
ValidateHR(hr);

最后,别忘了释放ISpVoice的实例!
// 释放ISpVoice接口实例
iSpVoice->Release();

最后的程序如下。把音量开到最大,按Ctrl+F5运行,即可听到电脑在说话![s::lol]
#include <stdio.h>
#include <stdlib.h>
                                    
#include <windows.h> // 使用COM组件需要包含windows.h
#include <sapi.h> // 使用ISpVoice语音组件需要包含sapi.h
                                    
#define ValidateHR(hr) \
    if (FAILED(hr)) { \
        printf("HRESULT错误:%p,在%s第%d行\n", hr, __FILE__, __LINE__); \
        exit(1); \
    }
                                    
int main()
{
    HRESULT hr = CoInitialize(0); // 初始化COM环境,参数保留,必须为0
    ValidateHR(hr);
                                    
    // 建立ISpVoice接口实例(组件ID为CLSID_SpVoice,接口ID为IID_ISpVoice)
    ISpVoice *iSpVoice = NULL;
    hr = CoCreateInstance(CLSID_SpVoice, NULL, CLSCTX_ALL, IID_ISpVoice, (void**)&iSpVoice);
    ValidateHR(hr);
                                    
    // 调用ISpVoice的Speak成员函数
    hr = iSpVoice->Speak(L"电脑在说话!", SPF_DEFAULT, NULL);
    ValidateHR(hr);
                                    
    // 释放ISpVoice接口实例
    iSpVoice->Release();
                                    
    CoUninitialize(); // 卸载COM环境
    return 0;
}</sapi.h></windows.h></stdlib.h></stdio.h>
引用
评论
加载评论中,请稍候...
200字以内,仅用于支线交流,主线讨论请采用回复功能。
折叠评论
acmilan作者
9年5个月前 修改于 9年5个月前 IP:四川
789538
三、使用ATL简化编程

使用原生的COM接口的话,需要自己管理生存期,上边的程序很简单并无大碍,但稍微复杂一点的程序便十分棘手,还容易出错。为了减少编程人员的负担,使COM编程更加方便和可靠,微软的ATL(活动模板库)为我们准备了一个COM接口的智能指针:CComPtr<T>。

CComPtr<T>的主要作用是容纳接口的指针T*。CComPtr<T>在赋值时会自动进行Release和AddRef,在出作用域时自动进行Release,实现了自动管理生命周期。它同时重载了模板化赋值运算符,自动调用QueryInterface。还简化了CoCreateInstance等函数的调用。

首先,使用ATL要使用C++编译器编译,并且包含头文件atlbase.h:
#include <atlbase.h>

CComPtr<T>的使用很简单。建立一个空指针的操作如下:
CComPtr<ISpVoice> pISpVoice; // 自动设为NULL

在CComPtr<T>上建立新实例(注意是点运算符,不是箭头):
hr = XXXXXXXXXXXXCreateInstance(CLSID_SpVoice); // 省略了后两个参数:pUnkOuter=NULL, dwClsContext=CLSCTX_ALL

调用成员函数(箭头运算符):
hr = pISpVoice->Speak(L"电脑在说话!", SPF_DEFAULT, NULL);

赋值:
pISpVoice2 = pISpVoice; // 自动调用pISpVoice2的Release和pISpVoice的AddRef

转换接口类型:
CComPtr<IUnknown> pUnk = pISpVoice; // 自动调用QueryInterface,失败则返回NULL

取地址:
pUnk = NULL; // 作为输出(out)参数必须先置为NULL
pISpVoice.QueryInterface(&pUnk);
// &运算符获得pUnk.p成员变量的地址

CComPtr<T>在离开作用域时会自动释放。但有时需要手动释放,比如需要再次调用CoCreateInstance时。手动释放有两种方法:
1.赋值NULL
pISpVoice = NULL; // 自动调用Release
2.调用.Release(),注意不是->Release(),它会同时将值设为NULL,防止悬空指针产生
XXXXXXXXXXXXlease(); // 调用Release,同时设为NULL

有了CComPtr<T>,我们的程序可以简化为这样。可以看到,我们已经不再需要手工释放COM组件了,CoCreateInstance的调用也大为简化:
#include <stdio.h>
#include <stdlib.h>
          
#include <windows.h> // 使用COM组件需要包含windows.h
#include <sapi.h> // 使用ISpVoice语音组件需要包含sapi.h
#include <atlbase.h> // 使用CComPtr<t>需要包含atlbase.h
          
#define ValidateHR(hr) \
    if (FAILED(hr)) { \
        printf("HRESULT错误:%p,在%s第%d行\n", hr, __FILE__, __LINE__); \
        exit(1); \
    }
          
int main()
{
    HRESULT hr = CoInitialize(0); // 初始化COM环境,参数保留,必须为0
    ValidateHR(hr);
          
    {
        // 建立ISpVoice接口实例(组件ID为CLSID_SpVoice)
        CComPtr<ispvoice> pISpVoice;
        hr = pISpVoice.CoCreateInstance(CLSID_SpVoice); // 点运算符。省略了后两个参数:NULL, CLSCTX_ALL
        ValidateHR(hr);
          
        // 调用ISpVoice的Speak成员函数
        hr = pISpVoice->Speak(L"电脑在说话!", SPF_DEFAULT, NULL);
        ValidateHR(hr);
          
    } // pISpVoice在作用域边界会自动释放
              
    CoUninitialize(); // 卸载COM环境
    return 0;
}</ispvoice></t></atlbase.h></sapi.h></windows.h></stdlib.h></stdio.h>

为了方便起见,以后的程序均使用CComPtr<IMyInterface>,不再使用IMyInterface*。
引用
评论
加载评论中,请稍候...
200字以内,仅用于支线交流,主线讨论请采用回复功能。
折叠评论
acmilan作者
9年5个月前 修改于 9年4个月前 IP:四川
789660
四、关于字符串、字符集那些事

和现代Windows操作系统一样,COM也使用UTF-16 LE字符集的16位wchar_t字符串(文档中称之为LPCOLESTRLPCWSTR)。C/C++对wchar_t字符串也有支持,比如C的wcslen、wcscpy等函数、C++的wstring字符串对象等,WinAPI也有lstrlenW、lstrcpyW等函数可以处理它们。

由于字符串都是基于wchar_t的,因此COM和WinAPI之间可以正常交换字符串。所以在Win32程序中使用COM并不需要进行转码。但是对于C/C++的控制台输入输出函数(如scanf、printf)来说,使用wchar_t却容易出问题。由于它们是基于char的,wchar_t需要在内部转换成char才能正常输出。输出不正常的主要原因是VC运行库默认总是使用"C"这种区域设定(即将Unicode的00-FF段直接对应到单字节,大于FF的直接丢弃)进行转码。解决这个问题通常有三个方法:

第一个方法是使用setlocale(LC_ALL, ".OCP");locale::global(locale(".OCP"));重置一下区域设定。但是C语言中对于宽字符的输入输出,需要使用特殊的函数,其中许多带有下划线(如_getws_s),不太美观。并且不支持UTF-8(setlocale(LC_ALL, ".65001")返回NULL)。

第二个方法是不使用VC运行时的转码机制,改用WinAPI转码。Windows有两个WinAPI可以实现wchar_t和char之间的转码:MultiByteToWideChar和WideCharToMultiByte。它们不受VC运行库的限制,但是使用较为麻烦,这里不考虑。

第三个方法是使用ATL为我们定义了两个伪函数CW2ACA2W。它们其实是转码中间层,我们调用的是它的构造函数。由于它们实际上也是调用的WinAPI转码,因此同样不用受VC运行库的限制。CW2A的作用是将wchar_t字符串(Wide)转换为临时char字符串(ANSI),而CA2W则是将char字符串(ANSI)转换为临时wchar_t字符串(Wide)。使用它们要包含atlbase.h

CW2A和CA2W的使用方法很简单:

1.将char字符串传入CA2W,可得到临时wchar_t字符串(用于COM调用)
wcscpy(my_wide_string, CA2W("我是普通char字符串")); // 使用C式wchar_t[]字符串保存
wstring my_wide_string2 = CA2W("我是普通char字符串"); // 使用C++式wstring字符串保存
wstring my_wide_string3 = CA2W("我是普通char字符串", 950); // 使用C++式wstring字符串保存,char使用Big5字符集(950)
hr = pISpVoice->Speak(CA2W("我是普通char字符串"), SPF_DEFAULT, NULL); // 在COM中直接使用

2.将wchar_t字符串传入CW2A,可得到临时char字符串(用于C/C++的输入输出)
strcpy(my_ansi_string, CW2A(L"我是wchar_t字符串")); // 使用C式char[]字符串保存
string my_ansi_string2 = CW2A(L"我是wchar_t字符串"); // 使用C++式string字符串保存
string my_ansi_string3 = CW2A(L"我是wchar_t字符串", 65001); // 使用C++式string字符串保存,char使用UTF-8字符集(65001)
printf("字符串:%s\n", (char*)CW2A(L"我是wchar_t字符串", 1)); // 输出字符串,OEM代码页,这里要手动转换为(char *)

CW2A和CA2W有个可选参数,可指定代码页,其中有一些系统指定的特殊代码页:
0 当前ANSI代码页CP_ACP(记事本编码)(简体中文936,繁体中文950,英文1252)(默认值)
1 当前OEM代码页CP_OEMCP(命令提示符编码)(简体中文936,繁体中文950,英文437)
65000 UTF-7代码页CP_UTF7(不常用)
65001 UTF-8代码页CP_UTF8(常用国际编码

有些文章可能提到可以用wchar_t *或char *变量来接收传过来的字符串,这是错误的。C++中通过直接调用构造函数创建的对象,生命期仅限本条语句。CA2W和CW2A对于128个字符以内的字符串是分配在栈上的,即使析构了字符串有时还可以从栈中读出。但是对于特别长的字符串则会分配在堆上,用char *或wchar_t *接收的只是地址而已,CW2A或CA2W的析构函数已经调用,为字符串所分配的空间已释放,再访问它肯定会崩溃。

在这里我们实现一个可以交互式阅读文本的程序,在命令提示符(OEM代码页)输入语句,即可让Windows的语音引擎为我们读出来。

首先我们要使用gets_s读入字符串(使用起来比gets差不多,但能正确判断缓冲区大小):
char strinput[200] = "";
gets_s(strinput); // 从控制台读入字符串

然后我们将pISpVoice->Speak第一个参数L"电脑在说话!"替换为CA2W(strinput, 1)即可:
hr = pISpVoice->Speak(CA2W(strinput, 1), SPF_DEFAULT, NULL);
ValidateHR(hr);

为了能够重复输入,我们将上述两个过程套进while(true)循环里,并判断是否输入了exit,若有则退出:
if (strcmpi(strinput, "exit") == 0) break; // 如果输入了exit则退出

最终代码如下。现在,你可以让系统读出你输入的任意字符串了。[s::lol]
#include <stdio.h>
#include <stdlib.h>
                                                                                     
#include <windows.h> // 使用COM组件需要包含windows.h
#include <sapi.h> // 使用ISpVoice语音组件需要包含sapi.h
#include <atlbase.h> // 使用CComPtr<t>和CA2W需要包含atlbase.h
                                                                                     
#define ValidateHR(hr) \
    if (FAILED(hr)) { \
        printf("HRESULT错误:%p,在%s第%d行\n", hr, __FILE__, __LINE__); \
        exit(1); \
    }
                                                                                     
                                                                                     
int main()
{
                                                                                     
    HRESULT hr = CoInitialize(0); // 初始化COM环境,参数保留,必须为0
    ValidateHR(hr);
                                                                                     
    {
        // 建立ISpVoice接口实例(组件ID为CLSID_SpVoice)
        CComPtr<ispvoice> pISpVoice;
        hr = pISpVoice.CoCreateInstance(CLSID_SpVoice); // 省略了后两个参数:NULL, CLSCTX_ALL
        ValidateHR(hr);
                                                                                     
        while (true)
        {
            char strinput[200] = "";
            gets_s(strinput); // 从控制台读入字符串(OEM代码页)
                                                                                     
            if (strcmpi(strinput, "exit") == 0) // 如果输入了exit则退出
                break;
                                                                                     
            // 调用ISpVoice的Speak成员函数(使用CA2W转码)
            hr = pISpVoice->Speak(CA2W(strinput, 1), SPF_DEFAULT, NULL);
            ValidateHR(hr);
        }
                                                                                     
    } // pISpVoice在作用域边界会自动释放
                                                                                         
    CoUninitialize(); // 卸载COM环境
    return 0;
}</ispvoice></t></atlbase.h></sapi.h></windows.h></stdlib.h></stdio.h>
引用
评论
加载评论中,请稍候...
200字以内,仅用于支线交流,主线讨论请采用回复功能。
折叠评论

想参与大家的讨论?现在就 登录 或者 注册

所属专业
上级专业
同级专业
acmilan
进士 学者 笔友
文章
461
回复
2934
学术分
4
2009/05/30注册,5年11个月前活动
暂无简介
主体类型:个人
所属领域:无
认证方式:邮箱
IP归属地:未同步
插入公式
评论控制
加载中...
文号:{{pid}}
投诉或举报
加载中...
{{tip}}
请选择违规类型:
{{reason.type}}

空空如也

加载中...
详情
详情
推送到专栏从专栏移除
设为匿名取消匿名
查看作者
回复
只看作者
加入收藏取消收藏
收藏
取消收藏
折叠回复
置顶取消置顶
评学术分
鼓励
设为精选取消精选
管理提醒
编辑
通过审核
评论控制
退修或删除
历史版本
违规记录
投诉或举报
加入黑名单移除黑名单
查看IP
{{format('YYYY/MM/DD HH:mm:ss', toc)}}