上一篇:
Froser:COM编程攻略(二十一 异步)zhuanlan.zhihu.com假设我们有一个这样的接口,并且它是一个跨进程的服务(注意:本篇文章中的例子都是需要通过代理的例子,而不是处于同一个套间可以直接调用的例子):
interface IMessage : IUnknown
{HRESULT AddOne(int*);
};
先看第一个问题:从上面的idl上来看,我们知道AddOne接受一个int指针。如果AddOne和客户端在同一个进程,那么没有关系,我们可以通过int指针来访问整型。但是,如果AddOne是实现在了一个远程计算机或者另外一个进程,那么我们客户端传递int指针那就不起作用了,因为它们不能共享内存。因此,在这种情况下的指针传递,实际上是对其值的传递,我们需要解引用这个int指针,然后再传递给服务端。服务端拿到这个int指针后,将开辟一个int大小的空间进行操作——这类似于深拷贝。
第二个问题是,我们不知道服务端如何使用这个指针。在C编程中,我们通过传递指针到另外一个函数,例如传递int*,非常有可能的情况是这个值会在函数中被改变;如果我们传递const int*,那么它仅仅是作为一个参数来防止拷贝。
这样的问题,在COM的远程调用中表现得尤为明显。如果这个int是会被修改的,那么客户端列集之后发送给服务端,服务端需要在修改完毕后,重现发回客户端。但是,如果这个int*不会被修改,那么客户端发送给服务端后,服务端便不需要返回它了,从而节约带宽,提高效率。
如何知道这个参数是否需要服务端返回或者需要发送给服务器?idl为了解决这个问题,提供了[in]和[out]属性。
一般来说,[in]表示入参,一个参数被标记为[in]之后,COM不会讲其从服务端返回:
[out]表示出参,这个参数结果由服务端生成:
如果不指定,那么它默认就是[in, out],表示它会被服务端来回传递:
下面是一个具体的例子。假设我们的AddOne的实现是这样的:
virtual HRESULT __stdcall AddOne(int* in) override
{*in = *in + 1;return S_OK;
}
它接受来自客户端的指针,并且在原有基础上+1,然后返回给客户端。客户端测试代码如下:
int main()
{CoInitialize(NULL);{CComPtr
}
interface IMessage : IUnknown
{HRESULT AddOne([in] int*);
};
我们跑出来的结果是i=5,原因是[in]只是把指针解引用后传递给了服务端,尽管服务端+1,但是它并不会传回给客户端。
interface IMessage : IUnknown
{HRESULT AddOne([out] int*);
};
跑出来的结果是i=1,原因是我们的&i并不会传递给服务端,服务端参数中的i,解引用后得到0,然后加1返回给客户端。
interface IMessage : IUnknown
{HRESULT AddOne([in, out] int*);
};
此时我们总算得到正确的结果了,参数先被传递到服务端stub,然后加一之后,再传回客户端,我们最终得到i=6。
因此,正确地区分[in],[out]至关重要,它决定代码的正确性,以及对网络带宽的优化。
在COM中,由于要考虑远程调用的问题,指针总会带来“无尽的麻烦”,其根本原因是传递地址对于跨套间跨进程的服务来说是无效的——它们不在同一个进程地址空间内。
COM中,对指针的用途进行了详细的分类,对它们作用进行一些限制。这些限制,会体现在代理中——也就是它会影响传递给Stub的行为。
C++中的引用,其实相当于一个别名。它最重要的特性就是,它不能为空,且永远指向一个实体。在COM中,如果一个指针它指向了一个合法地址,并且它不会改变指向的地址(但是地址中的内容可能改变),那么它就是具有引用语义的,本质上和C++的引用一样。
这样的指针,idl中提供了属性[ref],表示某个指针是引用语义。
引用语义的特点是,它不能接受一个空指针,因为引用不得为空。假设上面的例子中的idl是这样的:
interface IMessage : IUnknown
{HRESULT AddOne([in, out, ref] int*);
};
我们的客户端代码是这样的:
int main()
{CoInitialize(NULL);{CComPtr
}
原因是我们的参数标记为了ref,但是我们传递了NULL,那么在列集的时候,它会检查这个指针是否为空,如果是则返回一个错误。
如果一个为一个指针传入NULL是合法行为,那么它就可以被称为单值指针。例如,某个函数接受一个参数NULL,表示取默认值,Win32 API就经常这么干。
这种情况,我们需要用[unique]属性标记出来,这样就可以向代理传递空指针了。
我们现在将idl改成如下:
interface IMessage : IUnknown
{HRESULT AddOne([in, unique] int*);
};
此时表明,客户端代码中AddOne(NULL)是合法的,这也意味着实现者必须要对参数进行判空。为了能让代理感知unique属性,你一定要注册自己的PS模块(https://stackoverflow.com/questions/17013719/right-way-to-pass-a-null-pointer-to-a-out-of-process-com-method-in-an-atl-projec/17118542),否则oleautomation是无法帮你感知unique的,你仍然会得到一个错误。
测试代码:
int main()
{CoInitialize(NULL);{CComPtr
}
在说全指针之前,我们先看下面一个接口定义:
interface IMessage : IUnknown
{HRESULT Inc([in, out, ref] int* a, [in, out, ref] int* b);
};
Inc的实现是,将a和b自增1:
virtual HRESULT __stdcall Inc(int* a, int* b) override
{(*a)++;(*b)++;return S_OK;
}
测试代码:
int main()
{CoInitialize(NULL);{CComPtr
}
我们给Inc传入的是同一个地址,那么问题来了,服务端的Inc实现中,a和b是指向同一个地址吗?答案是否定的。按照MSDN的说法,无论是unique还是ref,它不会对指针进行等价的判断,也就是说,服务端Inc的形参a和b,虽然内容都是由客户端传入,但是实际上它们指向两个地址,在网络中它们也会被传输2次,因此它们只是各自加1,所以我们最终的结果是a=1。
这显然与我们对指针的认识背道而驰,因为如果是同一个地址,那么它应该增加2次,最终a应该为2。其原因是unique和ref不会进行指针的判断,它们带有自己的语义。为了能表达出最原始的指针的语义,微软提供了[ptr]属性,表示一个最接近C语言指针语义的全指针。
interface IMessage : IUnknown
{HRESULT Inc([in, out, ptr] int* a, [in, out, ptr] int* b);
};
使用以上[ptr]属性后,注册PS模块,然后运行上面的测试代码,我们发现服务端的Inc形参a和b都是指向同一个地址了,最终我们得到了a=2。
需要说明的是,[ptr]属性并不是微软建议我们首选的属性,毕竟传入相同的地址也是比较少见的。虽然[ptr]可以防止一个相同地址的数据多次传递,但是它需要PS模块的支持,实际上最常见的语义还是unique和ref。
我们在接口中使用pointer_default,可以定义未显示标识指针语义的指针的默认语义:
pointer_default attribute - Win32 appsdocs.microsoft.com在idl中,我们简单地把固定的长度写在数组后面,则规定了数组的长度:
interface IMessage : IUnknown
{HRESULT Get([in, out] int array[8]);
};
然后测试代码:
int main()
{CoInitialize(NULL);{CComPtr
}
我们想强行传递10个int给服务端,不过实际上代理只会传递8个int,因为它感知到了Get方法中的array只有8个元素。这样就能保证我们在实现Get方法时,只需要考虑8个元素了。这一点,它和C/C++中的传递数组不太一样。
由于C/C++中,数组传递会退化为指针,失去了其元素个数的信息,因此我们往往需要传递数组的元素个数。在idl中也类似。如果需要指定我传递的数组有多少个元素,则要使用size_is属性。例如:
interface IMessage : IUnknown
{HRESULT Get([in] int count, [in, out, size_is(count)] int* array);
};
我们仍然需要注册自己的PS模块,才能让上面idl生效。下面是测试代码:
int main()
{CoInitialize(NULL);{CComPtr
}
这一次,我们通过_countof,把10传给了Get作为第一个参数,代理感知到这个是作为了数组的元素个数,因此它会传递arr中的10个元素到Stub。
如果没有size_is会怎么样?arr会作为一个普通指针被传递,那么Stub只能拿到它的第一个元素1。
需要注意的是,size_is中是一个算数表达式,所以可以是变量名、数字字面值,或者一个表达式。与size_is类似的属性是max_is,它表示最大合法索引值,所以[size_is(n)]和[max_is(n-1)]是等价的。
另外一种情况:假设你需要服务端修改一个非常大的数组,例如int[1024],但是其实里面只有一小部分数组需要被修改,将数组全部传递过去显然太浪费带宽了,所以idl提供了另外一些属性[length_is], [first_is], [last_is],它提示了代理应该究竟传哪些数据过去:
[length_is]:一共要传递多少数据?
[first_is]:从第几个数据传起?
[last_is]:传到第几个数据?
举例如下:
interface IMessage : IUnknown
{HRESULT Get([in, out, first_is(10), length_is(5)] int array[1024]);
};
在客户端调用Get传递array给服务端stub过程中,只有第9个元素之后的5个元素(10, 11, 12, 13, 14)会被实际传输到服务端。服务端会构造1024个int,但是只有10, 11, 12, 13, 14这5个位置会用客户端的array填充,其它地方用0来填充,以此来充分节约带宽。
我们在实际过程中,可以将length_is和size_is相结合,来形成一个开放数组,充分发挥最高空间效率。
多维数组size_is中间可以有多个参数,用逗号分隔,分别表示它某一围上面的边界,如:
[in, size_is(5, 6)]** array 表示一个array[5][6]的二维数组。
SAFEARRAY的出现是一个历史原因,为了能让Visual Basic也支持非固定的数组。
idl中,使用SAFEARRAY(类型)表示一个数组:
interface IMessage : IUnknown
{HRESULT PrintArray([in] SAFEARRAY(int)* array);
};
对应地C++代码是这样的:
IMessage : public IUnknown
{
public:virtual HRESULT STDMETHODCALLTYPE PrintArray( /* [in] */ SAFEARRAY * *array) = 0;
};
在C++中,SAFEARRAY(int)被展开成了SAFEARRAY**。SAFEARRAY结构如下:
typedef struct tagSAFEARRAY {USHORT cDims; // 多少维?USHORT fFeatures; // 什么特征?比如是IDispatch*数组,或者其它ULONG cbElements; // 每个元素大小?ULONG cLocks; // SAFEARRAY对象被锁定次数PVOID pvData; // 实际元素数据SAFEARRAYBOUND rgsabound[1];
} SAFEARRAY;
在实现层,我们不需要手动获取SAFEARRAY成员,因为COM提供了一套API来操作SAFEARRAY,例如SafeArrayCreate, SafeArrayAccessData, SafeArrayUnaccessData, SafeArrayGetLBound, SafeArrayGetUBound, SafeArrayCreateVector等。
SafeArrayCreate function (oleauto.h) - Win32 appsdocs.microsoft.comSafeArrayAccessData function (oleauto.h) - Win32 appsdocs.microsoft.comSafeArrayUnaccessData function (oleauto.h) - Win32 appsdocs.microsoft.comSafeArrayGetLBound function (oleauto.h) - Win32 appsdocs.microsoft.comSafeArrayGetUBound function (oleauto.h) - Win32 appsdocs.microsoft.comSafeArrayCreateVector function (oleauto.h) - Win32 appsdocs.microsoft.comSAFEARRAY通常在脚本语言,例如Visual Basic中自动生成。例如:
Sub PrintArray(ByVal pas As Integer())
表示一个SAFEARRAY。
我下面例子中,我们用C++来测试和实现一个SAFEARRAY的例子。我们实现上面PrintArray的方法,打印出所有传入的元素的值:
virtual HRESULT __stdcall PrintArray(SAFEARRAY** array) override
{long lBound, uBound;HRESULT hr &#61; SafeArrayGetLBound(*array, 1, &lBound); // 下界hr &#61; SafeArrayGetUBound(*array, 1, &uBound); // 上界int* data &#61; nullptr;hr &#61; SafeArrayAccessData(*array, (void**)&data); // 获取数据for (int i &#61; 0; i <&#61; uBound - lBound; &#43;&#43;i){std::cout <}
在测试代码中&#xff0c;我们创建一个SAFEARRAY并进行测试&#xff1a;
int main()
{CoInitialize(NULL);{CComPtr
}
如果我们的服务进程有控制台界面的话&#xff0c;就可以看到它确实把客户端的0, 1, 2, ..., 9全部打印了出来&#xff0c;说明我们SAFEARRAY使用方法正确。
除了上面列举的一些属性(v1_enum, in, out, unique, ptr, ref...)&#xff0c;下面说一下一些常用的属性&#xff1a;
[object]&#xff1a;所有的COM接口必须要标记此属性&#xff0c;并且使用[uuid]来指定一个唯一标识&#xff1a;
[object,uuid(f39553d7-1cc7-4ca3-9295-5adbfe2b9146)
]
interface IMessage : IUnknown
{HRESULT PrintArray([in] SAFEARRAY(int)* array);
};
常用的接口属性如下&#xff1a;
[oleautomation]: 标记接口为可自动化的(https://docs.microsoft.com/en-us/windows/win32/midl/oleautomation)&#xff0c;那么可以使用这个属性。不过&#xff0c;不受支持的结构&#xff0c;特性(unique, ptr)等&#xff0c;还是需要注册自己的PS模块。本质上oleautomation就是为接口设置微软默认提供的PS模块而已。
[async_uuid]: 上篇文章讲过&#xff0c;为接口生成一个异步接口。
[nonextensible]: 不可拓展的IDispatch接口。默认地我们可以在运行时为IDispatch接口增加或者删除接口[1]&#xff0c;但是有了这个属性后我们就不可以这么做了。
[pointer_default]: 定义默认指针语义。
[dual]: 是否为一个双接口(IDispatch) (https://zhuanlan.zhihu.com/p/128124471)
[helpstring]: 对接口或者库的一段描述文本
[propget], [propput], [propputref]: 指定某个方法是个获取属性、设置属性的方法。
[retval]: 表明方法中某个属性是返回值&#xff0c;它在脚本语言中会直接作为返回值返回。
[optional]: 表明方法中某个属性是可选的。用于脚本语言中&#xff0c;如Visual Basic。
[defaultvalue]: 指明方法中某个属性的默认值。
还有一些属性&#xff0c;例如[default], [source]用于IConnectionPoint&#xff0c;coclass用于创建类对象&#xff0c;请参考之前的文章。
全部属性请参考&#xff1a;
MIDL Language Reference - Win32 appsdocs.microsoft.com