借助 C++ 进行 Windows 开发

Visual C++ 2015 将现代 C++ 引入旧代码

Kenny Kerr

Kenny Kerr使用 Windows 的系统编程在很大程度上依赖于表示隐藏在 C 样式 API 后对象的非跳转句柄。除非您在非常高的级别进行编程,否则您很有可能要管理各种类型的句柄。许多库和平台中都存在句柄的概念,当然并不是仅针对 Windows OS。早在 2011 年,当 Visual C++ 开始引入一些初始的 C++11 语言功能时,我首次编写了有关智能句柄类模板的内容 (msdn.microsoft.com/magazine/hh288076)。Visual C++ 2010 使编写方便且语义正确的句柄包装器成为可能,但它仅对 C++11 提供最小限度的支持,仍需要大量的精力来正确编写这种类。今年引入了 Visual C++ 2015 后,我认为我会重新讨论这个话题,并分享一些有关如何使用现代 C++ 使某些旧的 C 样式库更加生动的更多想法。

最好的库都不分配任何资源,因此需要的包装也最少。我最喜欢的示例是 Windows slim 读取器/写入器 (SRW) 锁。下面是创建和初始化可供使用的 SRW 锁的全部操作:

SRWLOCK lock = {};

SRW 锁结构仅包含单个 void * 指针,且没有要清理的内容!必须在使用前进行初始化,唯一的限制是无法移动或复制。很明显,在保持锁定时,任何现代化与异常安全性更为相关,而不是资源管理。尽管如此,现代 C++ 仍可以帮助确保满足这些简单的要求。首先,当非静态数据成员声明准备使用 SRW 锁时,我可以使用此功能来将其初始化:

class Lock
{
  SRWLOCK m_lock = {};
};

这可以处理初始化,但锁仍可以复制和移动。为此,我需要删除默认复制构造函数,并复制赋值运算符:

class Lock
{
  SRWLOCK m_lock = {};
public:
  Lock(Lock const &) = delete;
  Lock & operator=(Lock const &) = delete;
};

这样可以避免复制和移动。在类的公共部分中声明往往会生成更好的编译器错误消息。当然,现在我需要提供一个默认的构造函数,因为无法再假定:

class Lock
{
  SRWLOCK m_lock = {};
public:
  Lock() noexcept = default;
  Lock(Lock const &) = delete;
  Lock & operator=(Lock const &) = delete;
};

尽管我本质上还没有编写任何代码,但编译器为我生成了所有内容,现在我可以十分方便地创建一个锁:

Lock lock;

编译器将禁止任何尝试复制或移动锁的操作:

Lock lock2 = lock; // Error: no copy!
Lock lock3 = std::move(lock); // Error: no move!

然后,我可以通过各种方式添加获取和释放锁的方法。SRW 锁如其名称所示,同时提供共享的读取器和独占的编写器锁定语义。图 1 提供了简单独占锁定的一组基本方法。

图 1 简单高效的 SRW 锁

class Lock
{
  SRWLOCK m_lock = {};
public:
  Lock() noexcept = default;
  Lock(Lock const &) = delete;
  Lock & operator=(Lock const &) = delete;
  void Enter() noexcept
  {
    AcquireSRWLockExclusive(&m_lock);
  }
  void Exit() noexcept
  {
    ReleaseSRWLockExclusive(&m_lock);
  }
};

有关这个不可思议的小锁定基元所依据的原理,请查看“Windows 和 C++ 同步功能的演变”(msdn.microsoft.com/magazine/jj721588) 以了解详细信息。剩下的工作是提供少量围绕锁所有权的异常安全。我肯定不希望编写类似以下内容:

lock.Enter();
// Protected code
lock.Exit();

而是想通过锁保护来负责获得和释放给定作用域的锁:

Lock lock;
{
  LockGuard guard(lock);
  // Protected code
}

此类锁保护只需对基础锁保持引用:

class LockGuard
{
  Lock & m_lock;
};

如同锁类本身,最好是保护类不允许复制或移动,或者:

class LockGuard
{
  Lock & m_lock;
public:
  LockGuard(LockGuard const &) = delete;
  LockGuard & operator=(LockGuard const &) = delete;
};

剩下的就是构造函数输入锁和析构函数退出锁。图 2 总结了该示例。

图 2 简单的锁保护

class LockGuard
{
  Lock & m_lock;
public:
  LockGuard(LockGuard const &) = delete;
  LockGuard & operator=(LockGuard const &) = delete;
  explicit LockGuard(Lock & lock) noexcept :
    m_lock(lock)
  {
    m_lock.Enter();
  }
  ~LockGuard() noexcept
  {
    m_lock.Exit();
  }
};

公平地讲,Windows SRW 锁是一个非常独特的小“珍宝”,而大多数库会需要少量存储或某种必须显式管理的资源。我已演示如何最好地管理“重新访问 COM 智能指针”(msdn.microsoft.com/magazine/dn904668) 中的 COM 接口指针,因此,现在我要关注更普遍的非跳转句柄。正如我之前提到的,句柄类模板必须提供一种方法进行参数化,这不仅针对句柄类型,还针对关闭句柄的方式,甚至是完全表示无效句柄的内容。并非所有的库都使用 null 或零值来表示无效句柄。我原来的句柄类模板假定调用方会提供一个句柄特征类,提供必要语义和类型信息。几年来我编写了很多特征类,开始意识到绝大多数特征类都遵循相似模式。此外,就像所有 C++ 开发人员都会告诉您的那样,模式是模板擅长描述的内容。因此,我现在随句柄类模板应用一个句柄特征类模板。句柄特征类模板不是必需的,但确实简化了大多数定义。以下是其定义:

template <typename T>
struct HandleTraits
{
  using Type = T;
  static Type Invalid() noexcept
  {
    return nullptr;
  }
  // Static void Close(Type value) noexcept;
};

请注意 HandleTraits 类模板提供了哪些功能,以及未提供的具体功能。我编写了如此多的无效方法,它们返回了看起来明显是默认值的 nullptr 值。另一方面,出于显而易见的原因,每个具体特征类都必须提供其自己的 Close 方法。注释仅依赖于要遵循的模式。类型别名同样是可选的,只不过定义我自己的派生自此模板的特征类很方便。因此,我可以定义由 Windows CreateFile 函数返回的文件句柄的特征类,如下所示:

struct FileTraits
{
  static HANDLE Invalid() noexcept
  {
    return INVALID_HANDLE_VALUE;
  }
  static void Close(HANDLE value) noexcept
  {
    VERIFY(CloseHandle(value));
  }
};

如果 CreateFile 函数失败,将返回 INVALID_HANDLE_VALUE 值。否则,必须使用 CloseHandle 函数关闭所得句柄。当然,这并不常见。Windows CreateThreadpoolWork 函数将返回 PTP_WORK 句柄来表示工作对象。这只是一个不透明指针,很自然地,nullptr 值会返回失败。因此,工作对象的一个特征类可以利用 HandleTraits 类模板,让我可以键入更少的内容:

struct ThreadPoolWorkTraits : HandleTraits<PTP_WORK>
{
  static void Close(Type value) noexcept
  {
    CloseThreadpoolWork(value);
  }
};

那么,实际 Handle 类模板的外观应是怎样的?它可以只是依赖于给定的特征类、推断出该句柄的类型并且根据需要调用 Close 方法。推断采用 decltype 表达式形式,以确定句柄类型:

template <typename Traits>
class Handle
{
  using Type = decltype(Traits::Invalid());
  Type m_value;
};

这种方法可以让特征类的作者无需加入类型别名或 typedef 来以显式方式和冗余方式提供类型。关闭句柄是第一步,安全的 Close 帮助程序方法隐藏在 Handle 类模板的专用部分中:

void Close() noexcept
{
  if (*this) // operator bool
  {
    Traits::Close(m_value);
  }
}

此 Close 方法依赖于显式布尔运算符,以确定在调用特征类以实际执行操作之前是否需要关闭句柄。从 2011 年开始,公共显式布尔运算符就对我的句柄类模板进行的另一个改进,因为它只是作为显式转换运算符实现:

explicit operator bool() const noexcept
{
  return m_value != Traits::Invalid();
}

这解决了所有类型的问题,而且相对于实现类似于布尔运算符,同时避免编译器可能允许的令人生畏的隐式转换的传统方法来说,定义起来肯定要简单得多。我在本文中利用的另一个语言改进是显式删除特殊成员的功能,并且我现在将此功能用于复制构造函数和复制赋值运算符:

Handle(Handle const &) = delete;
Handle & operator=(Handle const &) = delete;

默认构造函数可以依靠特征类以可预测的方式初始化句柄:

explicit Handle(Type value = Traits::Invalid()) noexcept :
  m_value(value)
{}

此外,析构函数可以仅依赖于 Close 帮助程序:

~Handle() noexcept
{
  Close();
}

因此,不允许复制,但除了 SRW 锁外,我不会考虑不允许在内存中移动其句柄的句柄资源。移动句柄的功能极其方便。移动句柄涉及两个单独的操作,我可以称其为分离和附加,也可以称其为分离和重置。分离包含将句柄所有权释放给调用方:

Type Detach() noexcept
{
  Type value = m_value;
  m_value = Traits::Invalid();
  return value;
}

句柄值返回给调用方,句柄对象的副本无效,这样可以确保其析构函数不会调用由特征类提供的 Close 方法。补充附加或重置操作包含关闭任何现有的句柄,然后假定新句柄值的所有权:

bool Reset(Type value = Traits::Invalid()) noexcept
{
  Close();
  m_value = value;
  return static_cast<bool>(*this);
}

Reset 方法默认为该句柄的无效值,并成为过早关闭句柄的简便方法。为方便起见,它还会返回显式布尔运算符的结果。我发现自己经常编写以下模式:

work.Reset(CreateThreadpoolWork( ... ));
if (work)
{
  // Work object created successfully
}

此处我依据显式布尔运算符来检查之后的句柄有效性。将其压缩成一个表达式非常方便:

if (work.Reset(CreateThreadpoolWork( ... )))
{
  // Work object created successfully
}

现在,拥有此握手后,我可以以移动构造函数开头,很容易地实现移动操作:

Handle(Handle && other) noexcept :
  m_value(other.Detach())
{}

Detach 方法在 rvalue 引用上调用,并且新构造的 Handle 有效地从另一个 Handle 对象中窃取了所有权。移动赋值运算符稍微复杂一些:

Handle & operator=(Handle && other) noexcept
{
  if (this != &other)
  {
    Reset(other.Detach());
  }
  return *this;
}

第一次执行标识检查是为了避免附加已关闭的句柄。基本的 Reset 方法不会执行这种检查,因为这将涉及每个移动赋值的两个其他分支。一个是明智的。两个是冗余的。当然,移动语义是非常有用的,但交换语义更佳,尤其是您要将句柄存储在标准容器中时:

void Swap(Handle<Traits> & other) noexcept
{
  Type temp = m_value;
  m_value = other.m_value;
  other.m_value = temp;
}

正常情况下,非成员和小写交换函数需要泛型:

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

最后是以一对 Get 和 Set 方法形式出现的 Handle 类模板。Get 很明显:

Type Get() const noexcept
{
  return m_value;
}

它只是返回传递给各种库函数时可能需要的基础句柄值。Set 可能不是那么明显:

Type * Set() noexcept
{
  ASSERT(!*this);
  return &m_value;
}

存在间接 Set 操作。声明强调了这一事实。我过去调用了此 GetAddressOf,但这个名称伪装或否定了其真正的用途。在库返回句柄作为输出参数的情况下,需要执行这种间接的 Set 操作。WindowsCreateString 函数只是众多示例中的一个:

HSTRING string = nullptr;
HRESULT hr = WindowsCreateString( ... , &string);

我可以按这种方式调用 WindowsCreateString,然后将所得句柄附加到 Handle 对象,或者只是使用 Set 方法直接假定所有权:

Handle<StringTraits> string;
HRESULT hr = WindowsCreateString( ... , string.Set());

这将更加可靠,并且可以清晰地表明数据流动的方向。Handle 类模板还提供了常用的比较运算符,但得益于该语言对显式转换运算符的支持,不再需要使用它们来避免隐式转换。它们迟早会派上用场,但我想让你们去探索。Handle 类模板只是适用于 Windows 运行时的另一现代 C++ 示例 (moderncpp.com)。


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