65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 C++0x 可变参数模板实现 COM 接口

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.92/5 (23投票s)

2011年9月3日

CPOL

11分钟阅读

viewsIcon

45148

downloadIcon

692

一个极简模式的逐步说明, 用于用少量代码实现一系列 COM 接口

引言

想象一下,有人还在关注 COM,更不用说写一篇关于它的文章了,这似乎有些可笑。是的,COM:Component Object Model,一项源自 20 世纪 90 年代的微软技术,它为软件开发任务的隔离提供了一种稳固的途径,使它们能够分布在可能彼此互不知情的团队之间。尽管如此,我仍然想分享一些我的经验,尽管我意识到 COM 辉煌的时代已经一去不复返,这项技术本身也已将王位让给了更强大的竞争者。

我开发了一个名为 KO Approach 的小工具,其主要目的是增强 Windows Shell。与 Shell 交互的主要方式是通过它提供的 COM 接口。因此,本文的主题在很大程度上是由架构条件决定的。

如果您尝试过手动实现 COM 接口,您就会知道它有多么笨拙。最让我抓狂的是需要两次提及接口

  1. 在将其指定为类父项时
    class TestClass :
      public IClassFactory,
      public ILog,
      public IBindCtx,
      public IAccessControl
    {
      ...
    };
  2. 在实现 QueryInterface 方法时
    if (theIID == IID_IClassFactory)
      * theOut = static_cast<IClassFactory *>(this);
    
    else if (theIID == IID_ILog)
      * theOut = static_cast<ILog *>(this);
    
    else if (theIID == IID_IBindCtx)
      * theOut = static_cast<IBindCtx *>(this);
    
    else if (theIID == IID_IAccessControl)
      * theOut = static_cast<IAccessControl *>(this);
    
    else if (theIID == IID_IUnknown)
      * theOut = static_cast<IUnknown *>( static_cast<IClassFactory *>(this) );
    
    ...

另一个令人沮丧的元素是引用计数。每次实现引用计数时,都需要重复完全相同的代码,例如以下代码:

class TestClass
{
  LONG myRefs;

public:
  TestClass() : myRefs(0L) { }
};

幸运的是,只需稍加努力,我们就可以压缩代码并重用通用功能。当然,您可以使用 ATL,但在本文中,我希望介绍一种简约而优雅的模式。我们的代码将更加紧凑,而且我们还将熟悉最新 C++0x 标准的一些尖端功能。

必备组件

  • 必需: 支持可变模板的 C++ 编译器。在本文中,我们将使用 GCC 4.6
  • 可选: 一个可以高亮文本并可视化构建项目的开发环境。对于本文,我选择了 NetBeans,因为它提供了与 GCC 令人满意的集成级别。
  • 可选doxygen 用于生成 HTML 文档。

分步模式解释

基本构建块

首先,我们引入一个提供 COM 接口发现信息的类。也就是说,它提供一个函数块来告知调用者类支持的单个接口

template<typename tInterface, LPCGUID tIID = &__uuidof(tInterface)>
class ComEntryDiscoverable
{
public:
  typedef tInterface Interface;

protected:
  template<typename tObject>

  static HRESULT InternalQueryInterface(REFIID theIID, tObject * theObj, void ** theOut)
  {
    if (theIID == *tIID)
      *theOut = static_cast<tInterface *>(theObj);
    else
      return E_NOINTERFACE;

    return S_OK;
  }
}

其次,我们编写一个小型类来实际实现 COM 接口

template<typename tInterface, LPCGUID tIID = &__uuidof(tInterface)>
class ComEntry :
  public tInterface, public ComEntryDiscoverable<tInterface, tIID>
{ };

我们为什么使用模板?因为我们希望我们的代码尽可能快地运行。为了实现这一点,编译器将根据我们在编写实际类时提供的模板参数执行实例化。此外,编译器可能会内联对 InternalQueryInterface 的调用,以便我们的面向对象代码将产生与手工编写的代码完全相同的指令。

如您所见,这两个类使用相同的模板参数

  1. 正在实现的接口的类型。
  2. 此接口的 GUID。由于我们使用的是不支持 Microsoft __uuidof 运算符的 GCC,因此我们将不得不使用一些技巧作为变通方法,我将在最后进行解释。

IUnknown 发现

我们目前缺少的是关于通用父接口 – IUnknown 的发现信息。如果一个类实现了至少一个 COM 接口,它就隐式实现了 IUnknown,因此当 IID_IUnknown 作为接口 GUID 提供时,其 QueryInterface 方法必须返回一个正确的指针。让我们添加一个辅助类来帮助 IUnknown 发现

template<typename tInterface, 
         typename tIntermediate, LPCGUID tIID = &__uuidof(tInterface)>
class ComEntry2StepDiscoverable
{
public:
  typedef tInterface Interface;

protected:
  template<typename tObject>
  static HRESULT InternalQueryInterface(REFIID theIID, tObject * theObj, void ** theOut)
  {
    if (theIID == *tIID)
      *theOut = static_cast<tInterface *>( static_cast<tIntermediate *>(theObj) );
    else
      return E_NOINTERFACE;

    return S_OK;
  }
};

它与 ComEntryDiscoverable 的唯一区别在于其 InternalQueryInterface 的工作方式:后者执行两次转换,一次转换为中间类型(由 tIntermediate 模板参数表示),另一次转换为最终类型。中间类型可以是任何继承链中恰好有一个 tInterface 的接口,否则编译器将因歧义的外部转换而报错。现在让我们拥抱 IUnknown

template <typename tIntermediate>
class ComEntryTerminator :
  public ComEntry2StepDiscoverable<IUnknown, tIntermediate>
{ };

我们将此类命名为 ComEntryTerminator,因为它将是 COM 接口发现序列中的最后一个条目……

实现多个接口

“等一下,什么序列?”到目前为止,我们只处理了一个相当受限的解决方案,即我们可以实现一个 COM 接口,并提供关于该接口以及(主要为了代码纯洁性)其父接口 IUnknown 的发现信息。因此,让我们继续并引入以下类

template <typename tEntry1, 
          typename tEntry2 = ComEntryTerminator<typename tEntry1::Interface> >
class ComEntryCompound : public tEntry1, public tEntry2
{
public:
  template<typename tObject>
  static HRESULT InternalQueryInterface(REFIID theIID, tObject * theObj, void ** theOut)
  {
    HRESULT aRes = tEntry1::InternalQueryInterface(theIID, theObj, theOut);

    if ( FAILED(aRes) )
      aRes = tEntry2::InternalQueryInterface(theIID, theObj, theOut);

    return aRes;
  }
};

ComEntryCompound 类使我们能够拥有一个由 tEntry1tEntry2 模板参数表示的两个接口发现块序列。如果我们想实现一个 COM 接口,代码将如下所示:

class TestClass : public ComEntryCompound< ComEntry<IClassFactory> >

{ };

但真正的威力来自于 ComEntryCompound 的递归使用。因此,对于两个接口,我们可以这样写:

class TestClass2 :
  public ComEntryCompound
  <
    ComEntry<IClassFactory>,
    ComEntryCompound< ComEntry<ILog> >
  >
{ };

为了让我们的代码看起来更漂亮,我们将开始掌握 C++ 的一个新功能,许多人期待已久的功能——可变模板。简而言之,可变模板是模板,它可以拥有任意数量的参数,类似于 printf 函数可以拥有任意数量的参数。但可变模板的美妙之处在于它们的实现方式。而 printf 需要一个运行时来检查堆栈并检索实际参数的数量,C++ 可变模板在编译时进行处理。因此,代码更安全、更快、更不易出错!

但首先,让我们引入一个辅助类

template <typename tInterface1>
class ComEntry1 :
	public ComEntryCompound < ComEntry<tInterface1> >
{ };

它唯一的模板参数是可以实现的接口类型,例如:

class TestClass : public ComEntry1<IClassFactory>
{ };

借助 ComEntry1,我们终于可以编写可变模板版本了。如果您不熟悉 C++0x,语法起初可能会显得有些奇怪,所以我将尝试逐步解释。首先,我们声明一个可变模板类:

template<typename ... tInterfaces>
class ComEntryV;

因为我们不确定模板参数的确切数量,但又需要在类中放入有意义的实现,所以我们提供了另一个将递归使用的模板,次数由实例化模板的代码决定。为了实现这一点,我们将可变长度的模板参数列表分成两部分:由一个参数组成的头部,以及一个可变长度的尾部

template<typename tInterface, typename ... tTail>
class ComEntryV<tInterface, tTail...> :
  public ComEntryCompound
      <

        ComEntry<tInterface>,
        ComEntryV<tTail...>
      >
{ };

如您所见,ComEntryV 隐式继承自其自身的一个特化版本,该版本将模板参数的数量减少了一个。所讨论的参数用于实现 COM 接口并提供其发现信息。我们唯一需要的是一个类特化版本来终止递归。由于它只实现一个接口,我们将使用前面介绍的辅助类 ComEntry1

template <typename tInterface>
class ComEntryV<tInterface> :
  public ComEntry1<tInterface>
{ };

有了 ComEntryV,我们就可以编写非常漂亮的、适用于任意数量接口的代码了:

class TestClass :
  public ComEntryV<IClassFactory, ILog, IBindCtx, IAccessControl>
{
  // TODO: implement IClassFactory
  // TODO: implement ILog
  // TODO: implement IBindCtx
  // TODO: implement IAccessControl
};

实例化

从前面的列表可以看出,我们的类没有实现 IUnkonwn,尽管它们通过接口发现声明支持它。因此,我们的 TestClass 无法直接实例化。相反,我们将引入另一个模板来处理实例化,通过以下功能:

  • 提供通用的引用计数机制
  • 提供调用被实例化类的任意构造函数的手段
  • 实现 IUnknown

首先,让我们引入一个负责引用计数的类:

template<bool tThreadSafe = false>
struct ComRefCount
{
  LONG myNumRefs;

  ComRefCount() : myNumRefs(1L) { }

  ULONG STDMETHODCALLTYPE AddRef()
  {
    if (!tThreadSafe)
      return ++myNumRefs;
    else
      return InterlockedIncrement(&myNumRefs);
  }

  template<typename tObj>
  ULONG STDMETHODCALLTYPE Release(tObj * theObj)
  {
    ULONG aNumRefs;

    if (!tThreadSafe)
      aNumRefs = --myNumRefs;
    else
      aNumRefs = InterlockedDecrement(&myNumRefs);

    if (aNumRefs <= 0L)
      delete theObj;

    return aNumRefs;
  }
};

取决于线程模型,此类可以是线程安全的,也可以是线程不安全的。请注意,引用的数量被自动设置为一,因为至少有一个对对象的引用,由创建对象的调用者持有。现在,让我们完成实例化模式:

template<typename tImpl, bool tThreadSafe = false>
class ComInstance : public tImpl
{
protected:
  ComRefCount<tThreadSafe> myRefs;

public:
  ComInstance()
  { }

  template<typename ... tParams>
  ComInstance(tParams && ... theParams)
    : tImpl( std::forward<tParams>(theParams)... )
  { }

  ULONG STDMETHODCALLTYPE AddRef()
  { return myRefs.AddRef(); }

  ULONG STDMETHODCALLTYPE Release()
  { return myRefs.Release(this); }

  STDMETHODIMP QueryInterface(REFIID theIID,  void ** theOut)
  {
    HRESULT aRes = tImpl::InternalQueryInterface(theIID, this, theOut);

    if ( SUCCEEDED(aRes) )
      AddRef();

    return aRes;
  }
};

除了前面讨论的线程安全参数外,该类还期望 tImpl 模板参数,该参数标识包含实际功能的类型。这就是实现 COM 接口的类,例如前面列表提到的 TestClass

这里我们看到了可变模板构造函数,它允许我们自动调用实现类中包含的任何构造函数,包括 C++0x 中引入的所谓的移动构造函数

就是这样。让我们看看如何创建实例:

ComInstance<TestClass> aStackObj( /*args for TestClass::TestClass*/ );

TestClass * aHeapObj = new ComInstance<TestClass>( /*args*/ );

auto aHeapObj1 = new ComInstance<TestClass>( /*args*/ );  // C++0x only

更复杂的接口层次结构

COM 拥有某些派生自 IUnknown 以外的接口的情况并不少见。一个典型的用例是所谓的接口版本控制,其中新功能包含在一个名称后缀有版本号的接口中。该接口本身将派生自其前一个版本。到目前为止我找到的最长的链是 ITaskbarListITaskbarList2ITaskbarList3ITaskbarList4。顺便说一下,这是追踪 Windows Shell 发展的一个好方法。但为了简洁起见,让我们处理一些自制的接口,而不是这些笨重的接口:

class IInterface : public IUnknown
{
public:   STDMETHOD (InterfaceMethod) () = 0;
};

class IInterface2 : public IInterface
{
public:   STDMETHOD (InterfaceMethod2) () = 0;
};

class IInterface3 : public IInterface2
{
public:   STDMETHOD (InterfaceMethod3) () = 0;
};

class IInterface4 : public IInterface3
{
public:   STDMETHOD (InterfaceMethod4) () = 0;
};

class Impl : public ComEntryV<IInterface4>
{
protected:
  STDMETHODIMP InterfaceMethod ()
    { cout << "InterfaceMethod" << endl; }

  STDMETHODIMP InterfaceMethod2 ()
    { cout << "InterfaceMethod2" << endl; }

  STDMETHODIMP InterfaceMethod3 ()
    { cout << "InterfaceMethod3" << endl; }

  STDMETHODIMP InterfaceMethod4 ()
    { cout << "InterfaceMethod4" << endl; }
};

对于派生自 IInterface4 的类,手动编写的 QueryInterface 实现将声明对所有父接口的支持。但是,由于我们希望避免显式编写方法,因此我们应该以不同的方式解决这个问题。

有几种方法可以确保这一点。首先,我们可以为 InternalQueryInterface 方法提供部分手动实现的版本:

static HRESULT InternalQueryInterface(
    REFIID theIID, Impl * theObj, void ** theOut)
{
  HRESULT aRes = ComEntryV<IInterface4>::InternalQueryInterface(
    theIID, theObj, theOut);

  if ( FAILED(aRes) )
  {
    aRes = S_OK;

    if      (theIID == IID_IInterface)
      *theOut = static_cast<IInterface  *>(theObj);

    else if (theIID == IID_IInterface2)
      *theOut = static_cast<IInterface2 *>(theObj);

    else if (theIID == IID_IInterface3)
      *theOut = static_cast<IInterface3 *>(theObj);

    else
      aRes = E_NOINTERFACE;
  }

  return aRes;
}

但我们基础设施的实际好处在很大程度上被忽略了。毕竟,如果现在我们只需要为另外两个接口添加类似的手动编写的代码块:我们的顶级 IInterface4IUnknown,那么为什么要费心处理所有这些类呢?

这就是为什么我们有另一个更优雅的解决方案,我们甚至不需要在接口发现过程中进行干预。优雅之处再次来自 C++0x 和模板特化。如果我们分析 Impl 类的继承链,我们会发现它本质上派生自 ComEntry<IInterface4>。如果我们能以某种方式给编译器一个关于 ComEntry<IInterface4> 支持的其他接口的提示呢?是的,我们可以。

template<>
class ComEntry<IInterface4> :
  public ComEntryWithParentDiscoveryV
    <IInterface4, IInterface3, IInterface2, IInterface>
{ };

上面的列表是一个将为我们完成这项工作的模板特化版本。有了这个特化版本,我们的 Impl 类将具有修改后的接口发现例程逻辑,仅仅因为我们已经明确告诉编译器如何进行重写。无需更改 Impl 类的一行代码!我们所需要的只是看看 ComEntryWithParentDiscoveryV 类是如何工作的。我们将使用 C++ 可变模板的熟悉概念,以便可以自动发现任意数量的父接口。首先,让我们定义单个父接口的情况下的逻辑。这与 IUnknown 的发现非常相似。唯一的区别是我们现在发现两个接口:正在实现的接口及其父接口:

template<typename tInterface, typename tParent>
class ComEntryWithParentDiscovery1 :
  public tInterface,
  public ComEntryCompound
    <
      ComEntryDiscoverable<tInterface>,
      ComEntry2StepDiscoverable<tParent, tInterface>
    >
{
public:
  typedef tInterface Interface;
};

现在让我们定义可变模板版本:

template <typename tChild, typename ... tParents>
class ComEntryWithParentDiscoveryV;

template <typename tChild, typename tParent, typename ... tTail>
class ComEntryWithParentDiscoveryV<tChild, tParent, tTail...> :
  public ComEntryCompound
    <
      ComEntry2StepDiscoverable<tParent, tChild>,
      ComEntryWithParentDiscoveryV<tChild, tTail...>
    >
{
public:
  typedef tChild Interface;
};

template <typename tChild, typename tParent>
class ComEntryWithParentDiscoveryV<tChild, tParent> :
  public ComEntryWithParentDiscovery1<tChild, tParent>
{ };

同样,这个概念应该已经很熟悉了。首先,我们定义类可以拥有任意数量的父接口,我们希望为其添加发现信息。其次,我们从父接口的可变长度列表中选取一个类,并以递归方式定义实际的发现信息逻辑。最后,我们用只有一个直接父类的类来终止递归。

__uuidof 运算符

在我们的构建环境中(GCC),需要进行特殊的技巧来模拟 __uuidof 运算符。一系列预处理器宏说明了这种方法:

#define DEFINE_UUIDOF_ID(Q, IID) template<> GUID hold_uuidof<Q>::__IID = IID;
#define DEFINE_UUIDOF   (Q)      DEFINE_UUIDOF_ID(Q, IID_##Q)
#define __uuidof        (Q)      hold_uuidof<Q>::__IID

这些宏依赖于这样一个事实:大多数接口不仅将它们的 GUID 声明为属性,还指定一个显式的 GUID 常量,名为 IID_InterfaceNameGoesHere。但是,开发人员仍然需要通过将这些宏放在某个.CPP 文件中来将项目中使用的接口映射到它们的 GUID。

DEFINE_UUIDOF(IUnknown)
DEFINE_UUIDOF(IClassFactory)
DEFINE_UUIDOF(ILog)
DEFINE_UUIDOF(IBindCtx)
DEFINE_UUIDOF(IAccessControl)

示例应用

提供的示例是一个基本的控制台应用程序,它使用 Microsoft XML 解析器从 KO Software 网站上托管的 PAD 文件中提取前面提到的 KO Approach 的版本号。PAD 文件是具有标准化结构的 XML 文件,允许软件站点提取有关软件产品的各种信息。

我们将使用 SAX 进行解析。首先,因为它很简单。但最重要的原因是我们使用 SAX 将不得不实现一个 COM 接口,而无需一个功能齐全的 COM 服务器。如果您不知道,SAX 是一种事件传递的 XML 解析范例。当解析器检测到 XML 文档中的各种实体时,它会引发事件,传递有关找到的实​​体的数据。开发者如何进一步处理这些实体由开发者决定。对于这个任务,我们将等待预定义 XML 元素中标识产品版本的内部文本。这是主要代码:

int main(int argc, char** argv)
{
  CoInitialize(NULL);

  // Create a SAX XML reader through COM, use the earliest version possible
  // (version 3.0) in this case.

  ISAXXMLReader * aRdr = 0;
  HRESULT aRes = CoCreateInstance
  (
    CLSID_SAXXMLReader30,
    NULL,
    CLSCTX_ALL,
    IID_ISAXXMLReader,
    (void **)&aRdr
  );

  if ( SUCCEEDED(aRes) && aRdr != 0)
  {
    // Create an instance of the content handler that will respond to various
    // SAX events.

    ComInstance<XmlHandler> aH;
    aRes = aRdr->putContentHandler(&aH);

    if ( SUCCEEDED(aRes) )
    {
      aRes = aRdr->parseURL(L"http://www.ko-sw.com/pad/approach.xml");

      if ( SUCCEEDED(aRes) )
        wcout << L"The current version of KO Approach is "
              << aH.GetParsedCurrentVersion() << endl;
      else
        wcerr << L"Unable to parse the URL: " << aRes << endl;
    }

    else
      wcerr << L"Unable to set the SAX content handler: " << aRes << endl;

    if (aRdr != 0)
      aRdr->Release();
  }
  else
    wcerr << L"Unable to create XML reader: " << aRes << endl;


  CoUninitialize();

  return 0;
}

负责解析的类是 XmlHandler。当它接收到事件时,它会累积我们感兴趣的产品版本信息:

class XmlHandler : public ComEntryV<ISAXContentHandler>
{
private:
  std::vector<std::wstring> myStack;
  std::wstring myCurVersion;


// Interface
public:
  const std::wstring & GetParsedCurrentVersion() const
    { return myCurVersion; }


// ISAXContentHandler members
protected:

  STDMETHODIMP startElement (LPCWSTR theLocalName, int theNumCharsLocalName)
  {
    std::wstring aName(theLocalName, theLocalName + theNumCharsLocalName);
    myStack.push_back(aName);

    return S_OK;
  }

  STDMETHODIMP endElement (LPCWSTR theLocalName, int theNumCharsLocalName)
  {
    std::wstring aName(theLocalName, theLocalName + theNumCharsLocalName);

    if (myStack.back() != aName)
      return E_FAIL;

    myStack.pop_back();
    return S_OK;
  }

  STDMETHODIMP characters (LPCWSTR theString, int theNumChars)
  {
    if (myStack.size() == 3)
      if ( myStack[0] == L"XML_DIZ_INFO" &&
           myStack[1] == L"Program_Info" &&

           myStack[2] == L"Program_Version" )
      {
        myCurVersion += std::wstring(theString, theString + theNumChars);
      }

    return S_OK;
  }
};

以下是撰写本文时应用程序的输出:

Demo Application Screenshot

结论

这里描述的概念说明了一个看似高度理论化、实验性、只对 C++ 纯粹主义者有意义的语言特性,如何在实际任务中帮助实现任意数量的 COM 接口,代码更少,而且性能开销很小(甚至没有)。这些概念绝不能替代专业的 COM 开发解决方案,例如 ATL。后者具有许多 COM 开发人员使用的真正高级功能,例如 tear-off 接口、类工厂实用程序、IDispatch 支持等等。

在本文选择的构建环境中,也极不可能用于 ATL 项目。不过,对于 Visual Studio 来说,代码仍然可以编译,但会受到前一版本对 C++0x 有限支持的限制。

  • 而不是可变模板 ComEntryV,必须使用固定长度模板版本:ComEntry1 .. ComEntry5,支持多达五个接口。实现六个及以上接口的概念仅仅是简单的复制粘贴。
  • 而不是可变模板 ComEntryWithParentDiscoveryV,必须使用固定长度模板版本:ComEntryWithParentDiscovery1 .. ComEntryWithParentDiscovery5,支持多达五个父接口。实现六个及以上父接口的概念仅仅是简单的复制粘贴。
  • 而不是可变模板构造函数,ComInstance 类将具有固定长度版本。支持多达五个参数。添加更多参数比上面更简单。

我希望随着 Visual Studio 2010 新版本的发布,对 C++0x 的支持将更加一致,开发人员也能够在更方便的开发环境中利用这些代码。

历史

  • 2011-09-03:初次发布
  • 2011-09-04:对代码列表和文本进行了少量更正
© . All rights reserved.