COM编程攻略(二 COM最基本接口 IUnknown

发布于 2020-04-13  148 次阅读


上一篇说到,C++传统的局限性在于,不同编译器带来的不一致的二进制布局造成不兼容,以及接口版本更新造成布局变化的不兼容。

这一篇来详细说一下,微软是如何推出COM模型解决上述问题的。
一、如何解决上述两个问题

1、不同编译器带来不兼容

这个上一篇也说过,把编译器可以自由发挥的部分,封装在模块内部,外部就像调用一个普通函数那样。能够自由发挥的部分有对象的创建、转型等,这些是绝对不能在模块边界传递的。也就是说,如果一个对象需要创建,那么一定要由实现对象的模块创建;释放它也一定要由实现它的模块释放,不能出现exe中释放一个dll中对象的情况。

这意味着,对于各个对象,我们都要提供一个创建它的方法,那么创建对象实例的这个类,我们叫做工厂类,在COM中,直接被称作是类。我们通过某些方法拿到类,然后就可以通过类来创建实例。

对象的释放也是在实现它的模块中进行。每一个对象,在被客户持有时,自身的引用计数会加1,释放时会减去1。一旦它计数变为0,那么一般就会调用delete this删除自己。这也就意味着,客户通过减去引用计数,触发了模块中的对象的释放,从而避免了客户自己用delete来删除对象的操作,因为它是编译器相关的行为。

同理,为了实现转型,COM中也提供了特有的查询接口的方法,里面大部分的实现就是,将自己static_cast成另外一种类型。注意,这个static_cast是在模块内部进行,所以也不会出现客户跨边界static_cast一个对象的情况。

2、版本迭代造成布局变化的不兼容

为了解决这类问题,微软想出的解决方案是,让接口保持不变。接口其实并不是C++的一个概念,它是指不包含成员的,仅包含纯虚函数的类。我们可以认为,这样的类仅仅只需要一个虚表指针,就可以实现多态。基本上所有的编译器,其虚函数都是由虚表指针来实现,那么,我们仅仅需要把这一组纯虚函数暴露给客户,而不需要将具体的实现暴露给客户,便能实现功能。

举例来说:

struct IFile
{
  virtual void open() = 0;
  virtual void close() = 0;
}

struct File : public IFile
{
  virtual void open() { ... }
  virtual void close() { ... }
private:
  int m_fileid;
  ...
}

IFile是一个接口,File是IFile的一个具体实现。在上述模型中,客户是不会感知到File存在的,它只能拿到IFile接口,调用它的open和close,对于File中的实现,以及它其中一个成员m_fileid一概不知。这样,File便可以很方便进行修改,只要它能符合IFile接口要求就可以了。

二、COM底层接口IUnknown

有了以上几点,我们就可以引出COM的最底层的接口了:IUnknown

简单来讲,IUnknown长这个样子:

struct IUnknown
{
    virtual HRESULT STDMETHODCALLTYPE QueryInterface( 
        /* [in] */ REFIID riid,
        /* [iid_is][out] */ _COM_Outptr_ void __RPC_FAR *__RPC_FAR *ppvObject) = 0;

    virtual ULONG STDMETHODCALLTYPE AddRef( void) = 0;

    virtual ULONG STDMETHODCALLTYPE Release( void) = 0;
}

所有的COM对象,必须继承IUnknown接口,并且实现它的语义:

AddRef(): 表示此对象引用计数加一,比如被外面持有时,需要调用AddRef()。返回之后的引用计数。

Release(): 表示此对象引用计数减一,一旦引用计数为0,实现者必须要释放此对象。

上述两个方法很容易实现,例如引用计数声明为ULONG m_ref;那么AddRef()就是return ++m_ref,Release稍微要多做一点事情:

--m_ref;
if (m_ref == 0)
  delete this;
return m_ref;

顺便说一下,由于m_ref类型为ULONG,如果它从0往下减,那么会得到一个非常大的值(0xFFFFFFFF...FFFF),这样我们几乎永远都不能释放这个对象了。我们之后可以在ATL中看到它们的默认实现来处理这个问题。

QueryInterface(): 这个是IUnknown最核心的一个接口,叫做“变身(误)”。它的第一个参数名字叫做接口ID,接口ID是我们人为给每个接口生成的一个唯一的GUID,它一般命名为IID_接口名。如上面的IFile,它的接口ID为IID_IFile。第二个接口得到对象变身后的值。

举例:

有一个COM对象,它既可以读取,也可以写入。我们有两个这样的接口:

struct IRead : public IUnknown
{
  virtual byte* read() = 0;
};
struct IWrite: public IUnknown
{
  virtual void write(byte) = 0;
}

这个对象被实现为:

class ReadWrite : public IRead, public IWrite
{
  ULONG AddRef();
  ULONG Release();
  HRESULT QueryInterface(REFIID iid, void** ppvObject);
  byte* read();
  void write(byte);
};

此时我通过一种方法,拿到了这个对象的IRead接口:

IRead* reader = getReader();

我既然已经知道它可以写入,那如何朝它写入呢?

这个时候,应该大喊一声:IRead变身为IWrite!

IWrite* write;
reader->QueryInterface(IID_IWrite, (void**)&write);
...

这样,我们便从一个IRead里面拿到了一个IWrite。事实上,它的实现也非常简单:既然ReadWrite实现了IRead, IWrite,那么QueryInterface的伪代码如下所示:

if (iid是IRead)
  *ppv = static_cast<IRead*>(this);
else if (iid是IWrite)
  *ppv = static_cast<IWrite*>(this);
...

可见,QueryInterface担当的其实就是一个转型的工作。

由于QueryInterface返回的对象被外部持有,所以最后应该在QueryInterface返回前调用AddRef()。

为了实现一个QueryInterface,我们必须遵循MSDN中所说的几个原则:

1、如果可以成功拿出接口,返回S_OK。如果ppvObject为空,返回E_POINTER。如果不能拿出接口,那么返回E_NOINTERFACE。

2、QueryInterface(下面简称QI)是静态的,不是动态的。这说明,一个对象QI能否成功,和时间没有关系。如果某个特定的类的实例QI(A)->B(执行QueryInterface拿到B),那么任何时候都应该能拿到B。在上述static_cast版的实现中是符合要求的,因为static_cast任何时候都能成功。

3、QI是自反的(如果QI(A)->B,那么QI(B)->A。在上面static_cast版本中,这是显然的,因为返回的都是this。)

4、QI是对称的。

5、QI是可传递的。

6、如果需要取的是IUnknown(IID_IUnknown),那么必须要返回相同的指针。在上述static_cast中我们漏了对IUnknown的判断,事实上在上面的例子中,应该增加一个判断就是,如果是要拿IID_IUnknown,那么就返回static_cast<IUnknown*>(this);

三、IUnknown对象实现模型

看到这里,你可能会疑惑,难道还有每次返回不是static_cast<XXX*>(this)的情况吗?这就要说到COM对象的两种实现模型了。

我们知道MSDN只是解释了接口方法的含义,但是没有要求我们具体实现方式。大致来说,有两种COM对象实现模型:

继承模型

这个模型很好理解。以刚刚的ReadWrite类为例,它自身实现了AddRef, Release, QueryInterface。它继承了IRead, IWrite,而IRead, IWrite又继承了IUnknown,这说明ReadWrite类存在两个虚表指针,每个虚表指针的AddRef, Release, QueryInterface都指向了ReadWrite中的AddRef, Release和QueryInterface。你可能会想着使用虚继承(virtual IUnknown)等技术,不过这无疑又是编译器可以自由发挥的一个特性。

反正微软觉得,均继承IUnknown也不是什么很大的开销,而且QueryInterface的实现方式也就是不断static_cast自己,所有就这样延续下来了。

  1. 聚合模型

聚合模型,就是我们常说的“继承”的对立面——组合模型。在这个模型里,实现AddRef, Release的,是真正业务类的一个壳,而QueryInterface拿出来的,才是真正的业务中实现的类。在聚合模型中,如果实现ReadWrite类,可能是这个样子:

struct OuterUnk : public IUnknown
{
  OuterUnk(ReadWrite*);
  ULONG AddRef();
  ULONG Release();
  HRESULT QueryInterface(REFIID iid, void** ppvObject);

  ReadWrite* m_pImpl;
}

对于ReadWrite,它除了原来的实现外,还持有一个外部的指针:

class ReadWrite : public IRead, public IWrite
{
  ULONG AddRef();
  ULONG Release();
  HRESULT QueryInterface(REFIID iid, void** ppvObject);
  byte* read();
  void write(byte);

  OuterUnk m_pOutter;
};

在这种模型下,我们拿到的IUnknown接口,永远都是OuterUnk,我们通过QueryInterface拿到接口,才是真正的ReadWrite的接口。这说明了几个问题:

首先是,QueryInterface并不一定只能是static_cast自己,也可以是返回内部成员的接口指针;

二、由于ReadWrite也是IUnknown,所以对它进行QueryInterface,不应当返回ReadWrite的this, 而应该返回m_pOutter,不然不满足QueryInterface第6点要求。

三、引用计数的增加、减少的具体实现,其实是实现在了OuterUnk中,通过OuterUnk拿到的ReadWrite对象后所进行的ReadWrite::AddRef和ReadWrite::Release,内部实现其实是转调m_pOutter->AddRef()和m_pOutter->Release()。
四、IUnknown的官方实现

仔细看文档中最开始的COM的描述,它描述的都是协议,也就是你应当要怎么做,但是并没有给出具体的代码。我们写的每一套COM程序,都必须自己实现AddRef, Release等方法。除此之外,还有一下若干点需要考虑:

1、这个COM对象是单线程还是可以跑在多线程中?如果是多线程中的COM对象,那么引用计数的操作就应该是原子操作。否则,简单的++运算符足矣。

2、这个COM对象是继承模型,还是聚合模型?

3、这个对象如何被创建?

事实上,我们并不关系如何创建它,是否是聚合还是继承,AddRef和Release如何实现,因为它们千篇一律。我们需要定制的,是它支持什么接口。为了解决这样繁琐的工作,微软推出了动态模板库(ATL),提供了一套COM编程的模板,解决了我们对象生命周期管理、模型管理、创建管理,业务方只需要专注地编写业务代码即可。下一篇文章,我将会来解析,微软的ATL中,是如何实现IUnknown,如何创建IUnknown对象的。

欢迎关注我的小程序,小程序内容与网站自动保持同步

欢迎关注我的微信公众号,本网站所有的文章以及更新以后都会手动同步到微信公众号上。


公交车司机终于在众人的指责中将座位让给了老太太