借助 C++ 进行 Windows 开发

C++ 与 Windows API

Kenny Kerr

Kenny Kerr
Windows API 向 C++ 开发人员提出了一项挑战。组成 API 的众多库大都表现为 C 语言风格的函数和句柄或是 COM 风格的接口。这些用起来都不太方便,需要进行一定的封装或间接操作。

C++ 开发人员的难题是如何确定合理的封装级别。与 MFC 和 ATL 这样的库一起成长起来的开发人员可能倾向于将所有内容都包装为类和成员函数,因为这是他们长久以来依靠的 C++ 库所表现出的模式。也有些开发人员可能对任何形式的封装都嗤之以鼻,而只是直接使用原始函数、句柄和接口。可以说这部分开发人员不是真正的 C++ 开发人员,而只是有身份问题的 C 开发人员。我相信,现在的 C++ 开发人员有着更为自然的中间立场。

我在 MSDN 杂志 重新开始了我的专栏,我在此将向您展示如何使用 C++0x(很可能会命名为 C++ 2011),以及如何使用 Windows API 将本机 Windows 软件开发从黑暗时代解救出来。接下来几个月,我将带您更深入体验 Windows 线程池 API。在这个过程中,您会看到如何编写极具可扩展性的应用程序,而无需使用花哨的新语言以及复杂或昂贵的运行时。您只需要有优秀的 Visual C++ 编译器、Windows API 和掌握技巧的愿望就足够了。

像所有好项目一样,良好的基础是成功的一半。那么,我要如何“包装”Windows API 呢?我不想在后面每个专栏中拘泥于这些细节,因此打算在本专栏中讲清楚建议的做法,在以后依此执行。有关 COM 风格接口的问题暂不做讨论,因为下面几个专栏还用不到这种接口。

Windows API 由很多库组成,这些库公开一组 C 语言风格的函数,以及一个或多个称为句柄的不透明指针。这些句柄通常表示库或系统资源。有相应的函数可用于创建、操作和释放使用句柄的资源。例如,CreateEvent 函数创建一个事件对象,并返回一个到该事件对象的句柄。要释放该句柄并告知系统您已使用完该事件对象,只需将该句柄传递给 CloseHandle 函数即可。如果同一事件对象再无其他现用句柄,系统将销毁该对象:

auto h = CreateEvent( ...
);
CloseHandle(h);

C++ 新手

如果您是初次接触 C++ 2011,我要指出一点:auto 关键字会告知编译器根据初始化表达式推断变量类型。 这在您不知道表达式类型时非常有用,在进行元编程或只想保存一些击键时,常常会出现这种情况。

但您几乎在任何时候都不应编写这样的代码。 毫无疑问,C++ 提供的最有价值功能就是类的功能。 模板很酷,标准模板库 (STL) 很神奇,但如果没有类,C++ 中的一切都毫无意义。 C++ 程序简明可靠的优点要归功于类。 我说的不是虚函数、继承和其他花哨的功能。 我说的只是构造函数和析构函数。 这往往就是您所需要的一切,还有, 这不需要付出任何成本。 在实践中,您需要了解异常处理的开销,本专栏末尾将讨论这个问题。

要驯服 Windows API 并使其为 C++ 开发人员所用,需要一个封装句柄的类。 是的,您所喜爱的 C++ 库已经有一个句柄包装,但它是完全为 C++ 2011 设计的吗? 您能放心地将这些句柄存储在 STL 容器中,然后在程序中传递它们并跟踪其所有者吗?

C++ 类是完美的句柄抽象。 请注意,我没有说“对象”。要记住,句柄是对象在程序中的代表,往往不是对象本身。 需要看管的是句柄,而不是对象。 Windows API 对象与 C++ 类之间若存在一对一关系有时会非常方便,不过这是另外一个问题。

虽然句柄一般是不透明的,但仍会存在不同类型的句柄,而且往往有微妙的语义区别,这就有必要使用类模板以常规方式对句柄充分进行包装。 需要使用模板参数来指定句柄类型以及句柄的具体特性或特征。

在 C++ 中,特征类通常用于提供关于给定类型的信息。 这样,我可以为多个句柄编写一个类模板,并为 Windows API 中不同类型的句柄提供不同的特征类。 句柄的特征类还需要定义句柄的释放方式,以使句柄类模板能够根据需要自动释放句柄。 例如,下面就是事件句柄的一个特征类:

struct handle_traits
{
  static HANDLE invalid() throw()
  {
    return nullptr;
  }

  static void close(HANDLE value) throw()
  {
    CloseHandle(value);
  }
};

因为 Windows API 中的很多库共享这些语义,所以它们不单用于事件对象。 如您所见,特征类只包含静态成员函数。 如此一来,编译器即可轻松嵌入代码而不引入任何开销,同时为元编程提供极大的灵活性。

无效函数返回无效句柄的值。 这个值通常为 nullptr,是 C++ 2011 中的一个新关键字,表示 null 指针值。 不同于传统的同类值,nullptr 是强类型的,因此适用于模板和函数重载。 无效句柄有时也定义为非 nullptr 的值,这就会导致特征类中包含无效函数。 close 函数封装关闭或释放句柄的机制。

给出了特征类的轮廓,我可以继续并开始定义句柄类模板,如图 1 所示。

图 1 句柄类模板

template <typename Type, typename Traits>
class unique_handle
{
  unique_handle(unique_handle const &);
  unique_handle & operator=(unique_handle const &);

  void close() throw()
  {
    if (*this)
    {
      Traits::close(m_value);
    }
  }

  Type m_value;

public:

  explicit unique_handle(Type value = Traits::invalid()) throw() :
    m_value(value)
  {
  }

  ~unique_handle() throw()
  {
    close();
  }

我将它命名为 unique_handle,因为它与标准的 unique_ptr 类模板有些神似。 很多库还使用相同的句柄类型和语义,因此有必要为最常用的情况提供一个 typedef,简单地叫它 handle 就好了:

typedef unique_handle<HANDLE, handle_traits> handle;

现在,我可以创建一个事件对象并将其声明为“handle”,如下所示:

handle h(CreateEvent( ...
));

我已将 copy 构造函数和 copy 赋值运算符声明为私有,并且保持它们未实现。 这会阻止编译器自动生成它们,因为它们很少适合句柄。 Windows API 允许复制特定类型的句柄,但这是与 C++ copy 语义非常不同的概念。

构造函数的值参数依靠特征类提供默认值。 析构函数调用私有的 close 成员函数,该函数又依靠特征类根据需要关闭句柄。 这样,我就得到了一个堆栈友好且异常安全的句柄。

不过这些还不够。 close 成员函数依靠 Boolean 转换来确定是否需要关闭句柄。 虽然 C++ 2011 引入了显式转换函数,但在 Visual C++ 还没有这样的函数,因此我使用一种通用的 Boolean 转换方法来避免编译器在正常情况下允许的令人担心的隐式转换:

private:

  struct boolean_struct { int member; };
  typedef int boolean_struct::* boolean_type;

  bool operator==(unique_handle const &);
  bool operator!=(unique_handle const &);

public:

  operator boolean_type() const throw()
  {
    return Traits::invalid() != m_value ?
&boolean_struct::member : nullptr;
  }

这意味着我现在可以简单地测试句柄是否有效,而不会允许危险的转换暗地进行:

unique_handle<SOCKET, socket_traits> socket;
unique_handle<HANDLE, handle_traits> event;

if (socket && event) {} // Are both valid?
if (!event) {} // Is event invalid?
int i = socket; // Compiler error!
if (socket == event) {} // Compiler error!

使用更明显的布尔运算符会允许最后两个错误暗地发生。 虽然如此,这的确会允许两个套接字之间的比较,因此需要显式实现相等运算符或将它们声明为私有并保持未实现。

unique_handle 拥有句柄的方式与标准 unique_ptr 类模板拥有对象并通过指针管理对象的方式相似。 因此,可以通过提供我们所熟悉的 get、reset 和 release 成员函数来管理基础句柄。 get 函数非常简单:

Type get() const throw()
{
  return m_value;
}

reset 函数稍微复杂,但我们已经讨论过它的构建基础:

bool reset(Type value = Traits::invalid()) throw()
{
  if (m_value != value)
  {
    close();
    m_value = value;
  }

  return *this;
}

我冒昧对 unique_ptr 提供的 reset 函数模式做了轻微的改动,使其返回一个布尔值,指示对象是否已用有效句柄进行了重置。 这样方便进行错误处理,稍候我们再讨论这个问题。 release 函数现在显而易见:

Type release() throw()
{
  auto value = m_value;
  m_value = Traits::invalid();
  return value;
}

复制与 移动

最后是考虑复制与移动语义。 因为我已经禁止了句柄的复制语义,所以应该允许移动语义。 如果要在 STL 容器中存储句柄,这一点非常重要。 这些容器在传统上依赖复制语义,但随着 C++ 2011 的引入,开始支持移动语义。

这里我不详细介绍移动语义和 rvalue 引用,只是告诉大家,其基本理念是允许对象值以开发人员可预测并且对于库作者和编译器一致的方式在对象间传递。

在 C++ 2011 之前,开发人员不得不求助于各种复杂的技巧来避免语言(广义上讲为 STL)对复制对象的过度喜爱。 编译器常常创建一个对象副本,然后立即销毁原始对象。 使用移动语义,开发人员可以声明一个对象将不再使用,其值移至别处,通常伴随有尽可能少的指针交换。

在某些情况下,开发人员需要明确指出这一点;但大多数情况下,编译器可以利用移动感知对象并执行前所未有的超高效优化。 好消息是对您自己的类启用移动语义非常简单。 就像复制依赖于复制构造函数和复制赋值运算符一样,移动语义依赖于移动构造函数和移动赋值运算符:

unique_handle(unique_handle && other) throw() :
  m_value(other.release())
{
}

unique_handle & operator=(unique_handle && other) throw()
{
  reset(other.release());
  return *this;
}

rvalue 引用

C++ 2011 引入了一种新型引用,名为 rvalue 引用。 这种引用通过 && 声明;前面代码中在 unique_handle 成员中使用过。 虽然与现在名为 lvalue 引用的旧引用相似,但新的 rvalue 引用在初始化和重载解析方面展现出些许不同的规则。 这一话题先到此为止(稍后会有进一步讨论)。 句柄至此有了移动语义,其主要优点是可以在 STL 容器中正确有效地存储句柄。

错误处理

对 unique_handle 类模板的讨论到此为止。 本月的最后一个主题是错误处理,这也是为后续专栏做的准备。 对异常和错误代码利弊的争论看似无休无止;但是,只要您想使用标准 C++ 库,就必须习惯异常。 当然,Windows API 使用错误代码,因此需要有所妥协。

我的错误处理方法是尽可能地少做错误处理,编写异常安全的代码但避免捕获异常。 如果没有异常处理程序,Windows 会自动生成一个错误报告,其中包含可以事后调试的小型崩溃转储。 仅在发生意外的运行时错误时引发异常,通过错误代码来处理所有其他情况。 引发异常的原因不外乎代码中的 bug 和计算机上降临的灾难。

我喜欢以访问 Windows 注册表为例。 如果无法写入注册表值,通常揭示程序中存在难以合理处理的较大问题。 这种情况应导致异常。 不过,无法读取注册表值应是可以遇见的情况,并妥善加以处理。 这种情况不应导致异常,而应返回一个布尔值或枚举值,以指示是否无法读取值以及发生此情况的原因。

Windows API 与其错误处理方式不甚一致;这是 API 多年演变的结果。 错误大都作为 BOOL 或 HRESULT 值返回。 对于某些其他错误,我习惯通过比较返回值与文档记录值来明确处理。

如果我的程序依赖某给定函数调用的成功才能继续可靠工作,那么我使用图 2 中所列的一个函数检查返回值。

图 2 检查返回值

inline void check_bool(BOOL result)
{
  if (!result)
  {
    throw check_failed(GetLastError());
  }
}

inline void check_bool(bool result)
{
  if (!result)
  {
    throw check_failed(GetLastError());
  }
}

inline void check_hr(HRESULT result)
{
  if (S_OK != result)
  {
    throw check_failed(result);
  }
}

template <typename T>
void check(T expected, T actual)
{
  if (expected != actual)
  {
    throw check_failed(0);
  }
}

关于这些函数,有两点需要指出。 第一点是 check_bool 函数经过重载,以便同时检查句柄对象的有效性,它理应不允许隐式转换为 BOOL。 第二点是 check_hr 函数,它显式与 S_OK 进行比较,而不是使用更为常见的 SUCCEEDED 宏。 这可避免静默接受开发人员向来不愿看到的其他可疑成功代码(如 S_FALSE)。

我初次尝试编写这些检查函数时使用了一组重载。 但当我在各种不同项目中使用这些重载时,我意识到 Windows API 定义了太多的结果类型和宏,要创建一组适用于所有结果类型和宏的重载根本不可能。 这就用到了装饰函数。 有几次,我发现由于发生意外的重载解析而未能捕获错误。 引发 check_failed 类型非常简单:

struct check_failed
{
  explicit check_failed(long result) :
    error(result)
  {
  }

  long error;
};

我可以给它装饰各种花哨的功能,如添加错误消息支持,但有什么用呢? 我提供了错误值,以便对崩溃的应用程序进行检查时可以轻松找出错误。 除此之外,别的功能只是徒增障碍而已。

有了这些检查函数,我可以创建一个事件对象并为其设置信号,在出现错误时引发异常:

handle h(CreateEvent( ...
));

check_bool(h);

check_bool(SetEvent(h.get()));

异常处理

异常处理的另一个问题与效率有关。 开发人员对此又一次出现分歧,往往是因为他们有一些先入为主、不切实际的观念。

异常处理的成本体现在两个方面。 一方面是引发异常的成本。 引发异常往往比使用错误代码慢,这也正是只应在出现致命错误时才应引发异常的原因之一。 如果一切顺利,您根本不需要付出这样的代价。

性能问题的另一方面,也是更常见的一方面原因是:万一引发异常,那么为了确保调用正确的析构函数,会产生运行时开销。 需要通过代码来跟踪需要执行哪些析构函数;这当然也会增加堆栈大小,这在大型代码库中对性能会有显著影响。 请注意,无论是否实际引发异常,都需要付出此代价,因此尽量减少这种情况对确保良好的性能十分重要。

这意味着要确保编译器对于什么函数可能引发异常拥有良好的判断。 如果编译器可以证明某些函数不会引发任何异常,它可以优化生成的代码来定义和管理堆栈。 这就是我用异常规范装饰整个句柄类模板和特征类成员函数的原因。 虽然在 C++ 2011 中已不再使用,但它是一项非常重要的平台特定优化功能。

本月内容到此结束。 您现在掌握了使用 Windows API 编写可靠程序的关键要素之一。 下个月,请和我一起探索 Windows 线程池 API。

Kenny Kerr 是一位热衷于本机 Windows 开发的软件专家。您可以通过 kennykerr.ca 与他联系。