借助 C++ 进行 Windows 开发

重新访问 COM 智能指针

Kenny Kerr

Kenny Kerr在 COM 再度降临(也称为 Windows 运行时)后,对 COM 接口高效可靠智能指针的需求比以往更加重要。但是,如何确保良好的 COM 智能指针?ATL CComPtr 类模板事实上已成为 COM 智能指针数十年。Windows SDK for Windows 8 引入了作为 Windows 运行时 C++ 模板库 (WRL) 一部分的 ComPtr 类模板,其中一些被誉为 ATL CComPtr 的现代替代模板。起初,我还认为这是一个很好的进步,但长期使用 WRL ComPtr 得出的大量经验表明,应避免使用它。为什么会这样呢?往下读。

那么,该怎么办呢?我们应返回到 ATL 吗?决不,但也许是时候将某些由 Visual C++ 2015 提供的现代 C++ 应用于新型 COM 接口智能指针的设计了。在 Connect(); Visual Studio 2015 & Microsoft Azure 特刊中,我介绍了如何使用 Implements 类模板来充分利用 Visual C++ 2015 轻松实现 IUnknown 和 IInspectable。现在,我要向您展示如何使用多个 Visual C++ 2015 来实现新的 ComPtr 类模板。

众所周知,智能指针很难编写,但 C++11 的出现,使编写不再像以前那么困难了。其中部分原因与库开发人员为解决 C++ 语言和标准库缺少表现力这一问题而设计的所有巧妙技巧相关,从而使他们自己的对象在保持正确高效的同时如同内置指针一样运行。特别是,rvalue 引用对简化我们库开发人员的工作大有帮助。另一部分是简单的事后分析 — 了解现有设计的表现情况。当然,还有每个开发人员的难题:表现出约束,且不尝试将每个可想象到的功能打包到一个特定的抽象概念中。

在最基本的级别,COM 智能指针必须提供底层 COM 接口指针的资源管理。这意味着智能指针将是类模板,并存储所需类型的接口指针。从技术上讲,智能指针实际上并不需要存储特定类型的接口指针,但可以改为只存储一个 IUnknown 接口指针,不过在智能指针取消引用的任何时候,智能指针都将不得不依赖 static_cast。这可能是有用且从概念上讲是危险的,但我将在以后的专栏中对此进行讨论。现在,我将从用于存储强类型化指针的基本类模板开始:

template <typename Interface>
class ComPtr
{
public:
  ComPtr() noexcept = default;
private:
  Interface * m_ptr = nullptr;
};

长期使用 C++ 的开发人员可能起初不知道基本类模板是什么,但很有可能最活跃的 C++ 开发人员不会感到太惊讶。M_ptr 成员变量依赖于允许非静态数据成员在声明位置进行初始化的出色新功能。当逐渐添加和更改构造函数时,这将显著减少意外忘记初始化成员变量的风险。特定构造函数显式提供的所有初始化均优于此就地初始化,但大多数情况下,这意味着构造函数不必担心设置此类成员变量,这些变量将转为从不可预知的值开始。

鉴于接口指针现在保证会进行初始化,我也可以依靠另一项新功能来显式请求特殊成员函数的默认定义。在前面的示例中,我正在请求默认构造函数的默认定义 — 默认的构造函数(如果您愿意)。不要针对 messenger。尽管如此,从声明的角度来说,不履行或删除特殊成员函数的能力以及初始化成员变量的能力,是我最喜爱的 Visual C++ 2015 功能。这是很重要的细节。

帮助开发人员抵御侵入性的 COM 引用计数模型风险,是 COM 智能指针必须提供的最重要服务。实际上,我喜欢使用 COM 方法引用计数,但我希望有一个库帮助我管理。这显示在整个 ComPtr 类模板的许多细微之处,但当调用方取消引用智能指针时可能最明显。我不想让调用方无意或以其他方式编写以下类似内容:

ComPtr<IHen> hen;
hen->AddRef();

调用 AddRef 或 Release 虚函数的能力,只可以处于智能指针范围内。当然,智能指针必须仍允许通过此类取消引用操作来调用剩余的方法。通常情况下,智能指针的取消引用运算符可能如下所示:

Interface * operator->() const noexcept
{
  return m_ptr;
}

适用于 COM 接口指针且无需声明,因为访问冲突更加直观。但是,此实现仍将允许调用方调用 AddRef 和 Release。此解决方案仅仅是返回禁止 AddRef 和 Release 被调用的类型。一些类模板可以派上用场:

template <typename Interface>
class RemoveAddRefRelease : public Interface
{
  ULONG __stdcall AddRef();
  ULONG __stdcall Release();
};

RemoveAddRefRelease 类模板继承了所有模板参数的方法,但声明了 AddRef 和 Release 私有,这样调用方可能不会意外引用这些方法。智能指针的取消引用运算符只需使用 static_cast 来保护返回的接口指针:

RemoveAddRefRelease<Interface> * operator->() const noexcept
{
  return static_cast<RemoveAddRefRelease<Interface> *>(m_ptr);
}

这只是我的 ComPtr 偏离 WRL 方法的一个示例。WRL 选择要将所有 IUnknown 方法私有,包括 QueryInterface,但是我认为没有理由以这种方式限制调用方。这意味着 WRL 不可避免地必须为此基本服务提供其他替代方案,并给调用方增加了复杂性和困惑。

由于我的 ComPtr 可以果断采取引用计数命令,所以最好正确地这样做。那么,我将从一对私有 helper 函数开始,从适用于 AddRef 的那个开始:

void InternalAddRef() const noexcept
{
  if (m_ptr)
  {
    m_ptr->AddRef();
  }
}

这并不那么令人振奋,但是存在各种需要有条件地执行引用的函数,这将确保我每次的操作均正确。适用于 Release 的相应 helper 函数更微妙:

void InternalRelease() noexcept
{
  Interface * temp = m_ptr;
  if (temp)
  {
    m_ptr = nullptr;
    temp->Release();
  }
}

为什么是临时的?考虑下更直观但不正确的实现,这些实现大致反映了我在 InternalAddRef 函数内部(正确地)执行的操作:

if (m_ptr)
{
  m_ptr->Release(); // BUG!
  m_ptr = nullptr;
}

此处的问题是,调用 Release 方法可能会引起一系列可以看到对象被再次释放的事件。通过 InternalRelease 的第二个行程将再次找到一个非空接口指针,并尝试再次将其释放。这无疑是不常见的情况,但库开发人员的工作需要考虑此类事情。涉及一个临时的原始实现通过首先将接口指针与智能指针分离,然后仅调用 Release,便可避免此再次释放。仔细查看历史记录年鉴,似乎 Jim Springfield 首先在 ATL 中遇到了这一棘手的 bug。不管怎样,拥有这两个 helper 函数,我就可以开始实现某些特殊成员函数,以便使生成的对象起作用并使其像一个内置对象。复制构造函数是一个简单示例。

与提供独占所有权的智能指针不同,COM 智能指针应允许复制构造。必须不惜一切代价避免副本,但如果调用方真正需要副本,则会获得副本。下面是一个简单的复制构造函数:

ComPtr(ComPtr const & other) noexcept :
  m_ptr(other.m_ptr)
{
  InternalAddRef();
}

这是复制构造的明显案例。在调用 InternalAddRef helper 之前,它会复制接口指针。如果将它搁置在那里,复制 ComPtr 大多数情况下感觉像一个内置指针,但并不完全如此。例如,我可以创建像这样的副本:

ComPtr<IHen> hen;
ComPtr<IHen> another = hen;

这反映了我能够使用原始指针做什么:

IHen * hen = nullptr;
IHen * another = hen;

但是,原始指针还允许这种情况:

IUnknown * unknown = hen;

与我的简单复制构造函数相比,我无法使用 ComPtr 执行相同的操作:

ComPtr<IUnknown> unknown = hen;

即使 IHen 最终必须派生自 IUnknown,ComPtr<IHen> 也不会派生自 ComPtr<IUnknown>,且编译器会考虑这些不相关的类型。我需要的是,构造函数应可以充当其他逻辑相关的 ComPtr 对象的逻辑复制构造函数 — 具体而言,ComPtr 对象是指模板参数可转换为已构造 ComPtr 的模板参数的任何 ComPtr。在这里,WRL 依赖类型特性,但这实际上并不是必需的。我只需要一个函数模板来提供转换的可能性,然后只需让编译器检查此函数模板是否确实可以转换:

template <typename T>
ComPtr(ComPtr<T> const & other) noexcept :
  m_ptr(other.m_ptr)
{
  InternalAddRef();
}

当另一个指针用于初始化对象的接口指针时,编译器会检查复制是否有实际意义。因此这将编译:

ComPtr<IHen> hen;
ComPtr<IUnknown> unknown = hen;

但这不可以:

ComPtr<IUnknown> unknown;
ComPtr<IHen> hen = unknown;

这也应如此。当然,编译器仍会考虑这两种非常不同的类型,因此构造函数模板实际上不会具有访问其他私有成员变量的权限,除非我将其设置为朋友:

template <typename T>
friend class ComPtr;

您可能想要删除某些冗余代码,因为 IHen 可转换为 IHen。为什么不只是删除实际的复制构造函数?问题在于,编译器不会将此第二个构造函数视为一个复制构造函数。如果删除复制构造函数,则编译器将假定您是要删除此复制构造函数,并拒绝对此已删除函数的任何引用。继续。

关注复制构造时,非常重要的一点是,ComPtr 还应提供移动构造。如果在给定方案中允许移动,ComPtr 应允许编译器来为此作出选择,因为它将节省引用臃肿(这与移动操作相比更为昂贵)。移动构造函数甚至比复制构造函数更加简单,因为移动构造函数不需要调用 InternalAddRef:

ComPtr(ComPtr && other) noexcept :
  m_ptr(other.m_ptr)
{
  other.m_ptr = nullptr;
}

它在清除或重置 rvalue 引用中的指针之前会复制接口指针或将从中移动的对象。但是,在这种情况下,编译器不是那么严格,且只需避开支持可转换类型通用版本的此移动构造函数:

template <typename T>
ComPtr(ComPtr<T> && other) noexcept :
  m_ptr(other.m_ptr)
{
  other.m_ptr = nullptr;
}

而且这圆满完成了 ComPtr 构造函数。可以预测,析构函数非常简单:

~ComPtr() noexcept
{
  InternalRelease();
}

我已经注意到了 InternalRelease helper 内析构函数的细微差别,所以这里我只需重用该好处。我已经讨论了复制和移动构造,但还必须为此智能指针提供相应的赋值运算符,以使其像一个真正的指针。若要支持这些操作,我打算添加另一对私有 helper 函数。第一个 helper 用于安全地获取给定接口指针的副本:

void InternalCopy(Interface * other) noexcept
{
  if (m_ptr != other)
  {
    InternalRelease();
    m_ptr = other;
    InternalAddRef();
  }
}

假设接口指针不相等(或不是两个 null 指针),在获取指针副本和保护对新接口指针的引用之前,该函数会释放所有现有的引用。这样,我可以轻松地调用 InternalCopy 来取得对给定接口唯一引用的所有权,即使该智能指针已包含一个引用。同样,第二个 helper 用于安全地移动给定的接口指针,以及它所代表的引用计数:

template <typename T>
void InternalMove(ComPtr<T> & other) noexcept
{
  if (m_ptr != other.m_ptr)
  {
    InternalRelease();
    m_ptr = other.m_ptr;
    other.m_ptr = nullptr;
  }
}

尽管 InternalCopy 自然支持可转换类型,但此函数作为模板可以为类模板提供此功能。另外,InternalMove 很大程度上是相同的,但在逻辑上是移动接口指针,而不是获取附加引用。采用这种方式,我可以非常简单地实现赋值运算符。首先,复制赋值,与复制构造函数一样,我必须提供规范形式:

ComPtr & operator=(ComPtr const & other) noexcept
{
  InternalCopy(other.m_ptr);
  return *this;
}

然后,我可以提供一个适用于可转换类型的模板:

template <typename T>
ComPtr & operator=(ComPtr<T> const & other) noexcept
{
  InternalCopy(other.m_ptr);
  return *this;
}

但是,如同移动构造函数,我只需提供一个通用版本的移动赋值:

template <typename T>
ComPtr & operator=(ComPtr<T> && other) noexcept
{
  InternalMove(other);
  return *this;
}

当涉及到引用计数的智能指针时,尽管移动语义通常都要优于副本,但是移动并非无需成本,避免一些重要情形中移动的好办法是提供交换语义。与移动相比,许多容器类型倾向于交换操作,交换操作可以避免临时对象的巨大负载构造。对 ComPtr 实现交换功能是非常简单的:

void Swap(ComPtr & other) noexcept
{
  Interface * temp = m_ptr;
  m_ptr = other.m_ptr;
  other.m_ptr = temp;
}

我会使用标准交换算法,但至少在 Visual C++ 实现中,所需的 <实用程序> 标头也会间接包括 <stdio.h>,但是我真不想强制要求开发人员只是为了交换而包括所有这些。当然,对于查找我的 Swap 方法的泛型算法,我还需要提供非成员(小写)交换函数:

template <typename Interface>
void swap(ComPtr<Interface> & left, 
  ComPtr<Interface> & right) noexcept
{
  left.Swap(right);
}

只要与 ComPtr 类模板定义的命名空间相同,编译器自然会允许泛型算法利用交换。

C++11 的另一个优点是,显式转换运算符。以前,为了检查智能指针是否在逻辑上非 null,开发人员采用一些杂乱的操作来生成可靠、显式的布尔运算符。今天,检查变得像下面一样简单:

explicit operator bool() const noexcept
{
  return nullptr != m_ptr;
}

这样就解决了特殊和几乎特殊成员,从而使我的智能指针行为非常类似于内置类型,我可以尽可能地帮助编译器节省任何额外开销。只保留了在许多情况下 COM 应用程序需要的一小部分 helper 选择。此处应格外小心,避免添加过多花俏的附加功能。此外,还有少量几乎任何重要的应用程序或组件都将依赖的函数。首先,需要通过一种方法来显式释放底层引用。那就相当简单:

void Reset() noexcept
{
  InternalRelease();
}

然后需要一种方法来获取底层指针,调用方需要将其作为参数传递到某些其他函数:

Interface * Get() const noexcept
{
  return m_ptr;
}

我可能需要分离引用,或许将其返回给调用方:

Interface * Detach() noexcept
{
  Interface * temp = m_ptr;
  m_ptr = nullptr;
  return temp;
}

我可能需要制作现有指针的副本。这可能是我想控制的调用方持有的引用:

void Copy(Interface * other) noexcept
{
  InternalCopy(other);
}

或者我可能有一个原始指针,此原始指针拥有对其目标的引用,我想要在不获取其他引用的情况下附加此引用。在极少数情况下,这还有助于合并引用:

void Attach(Interface * other) noexcept
{
  InternalRelease();
  m_ptr = other;
}

最后的少数几个函数起着非常重要的作用,因此我将在这些函数上花费更多时间。长久以来,COM 方法通过指针到指针将引用返回为输出参数。重要的是,任何 COM 智能指针都可以提供一种方法来直接捕获此类引用。为此,我提供 GetAddressOf 方法:

Interface ** GetAddressOf() noexcept
{
  ASSERT(m_ptr == nullptr);
  return &m_ptr;
}

此处也是我的 ComPtr 以微妙但非常重要的方式偏离 WRL 实现。请注意,GetAddressOf 声明它在返回其地址之前保留引用。这非常重要,否则所调用的函数将只会覆盖任何保留的引用,且您会泄漏引用。如果没有此声明,就很难检测这样的 bug。而另一方面是分发引用功能,即底层对象可能会实现的相同类型引用或针对其他接口的引用。如果需要相同接口的另一个引用,我可以避免调用 QueryInterface,只需使用 COM 规定的约定返回其他引用:

void CopyTo(Interface ** other) const noexcept
{
  InternalAddRef();
  *other = m_ptr;
}

并且您可能会使用它,如下所示:

hen.CopyTo(copy.GetAddressOf());

否则,无需 ComPtr 帮助便可采用 QueryInterface 本身:

HRESULT hr = hen->QueryInterface(other.GetAddressOf());

这实际上依赖于由 IUnknown 直接提供的函数模板,可以避免不得不显式提供接口的 GUID。

最后,通常情况下,在传统 COM 约定中,应用或组件需要查询接口,且不必将其传递回调用方。在这些情况下,返回紧紧插在另一个 ComPtr 内的新接口指针会更明智,如下所示:

template <typename T>
ComPtr<T> As() const noexcept
{
  ComPtr<T> temp;
  m_ptr->QueryInterface(temp.GetAddressOf());
  return temp;
}

然后,我只需使用显式运算符 bool 来检查查询是否成功。最后,方便起见,ComPtr 还提供了所有预期的非成员比较运算符,可以支持各种容器和泛型算法。再次重申,这只是有助于发挥智能指针的作用和使其更类似于内置指针,同时提供基本服务来正确地管理资源,并提供该 COM 应用和组件期望的必要服务。ComPtr 类模板只是适用于 Windows 运行时的另一现代 C++ 示例 (moderncpp.com)。


Kenny Kerr 是加拿大的一名计算机程序员,也是 Pluralsight 的作者以及 Microsoft MVP。他的博客网址是 kennykerr.ca,您可以通过 Twitter twitter.com/kennykerr 关注他。

衷心感谢以下 Microsoft 技术专家对本文的审阅:James McNellis