4.3. 如何使用交换操作

交换功能究竟有何重要之处,以至于值得用单独一章来讲解?况且,为什么我们要使用交换,而不继续用原来的名称来引用对象呢?这主要与异常安全有关,这也是我们反复强调交换操作能否抛出异常的原因。

4.3.1 交换操作与异常安全

C++ 中交换功能最重要的应用在于,编写异常安全的代码,或者是编写错误安全的代码。问题在于:异常安全的程序中,,错误情况都不应使程序陷入未定义状态。这里的错误不一定通过异常来处理 —— 例如,函数返回一个错误码时,也应妥善处理,避免产生未定义行为。如果某个操作引发了错误,该操作已消耗的资源应当释放。通常,我们会期望实现更强的保证 —— 每个操作要么完全成功,要么完全回滚。

来看一个例子:对一个vector中的所有元素进行变换,并将结果存储在一个新vector中:

// Example 03a
class C; // 我们的元素类型
C transmogrify(C x) { // 对 C 进行的某种操作
  return C(...);
}

void transmogrify(const std::vector<C>& in,
                  std::vector<C>& out) {
  out.resize(0);
  out.reserve(in.size());

  for (const auto& x : in) {
    out.push_back(transmogrify(x));
  }
}

通过一个输出参数返回vector。假设使用一个已存在的vector作为输出是需求的一部分,并非出于性能原因这样做。在所有较新的 C++ 版本中,按值返回vector非常快速:编译器要么应用返回值优化并完全省略复制(复制省略虽不保证但很可能发生),要么用移动操作替代复制(同样快速,且有保证)。该vector首先清空,然后增长到与输入vector相同的大小。输出vector可能原有的数据都已丢失。reserve() 避免了vector增长过程中,内存的重新分配。

只要没有错误发生(即没有异常抛出),这段代码就能正常工作,但并不能保证。首先,reserve() 会进行内存分配,而该操作可能会失败。如果发生这种情况,transmogrify() 函数将通过异常退出,而输出vector将为空,因为 resize(0) 调用已经执行。输出vector的初始内容已丢失,且没有新内容写入来替代。其次,对vector元素的循环迭代都可能抛出异常。异常可能由输出vector新元素的复制构造函数抛出,也可能由变换操作本身抛出。无论哪种情况,循环都会中断。STL 保证即使 push_back() 调用内部的复制构造函数失败,输出vector也不会处于未定义状态 —— 新元素不会创建,vector大小也不会增加。

然而,已经存储的元素将保留在输出vector中(而原本存在的元素都已消失)。这可能并非我们本意 —— 要求 transmogrify() 操作要么完全成功并对整个vector应用变换,要么完全失败且不产生改变,这种要求并不过分。

实现此类异常安全的关键在于使用交换:

// Example 03b
void transmogrify(const std::vector<C>& in,
                  std::vector<C>& out) {
  std::vector<C> tmp;
  tmp.reserve(in.size());

  for (const C& x : in) {
    tmp.push_back(transmogrify(x));
  }

  out.swap(tmp); // 必须不抛出异常
}

这个例子中,整个变换过程都在一个临时vector上进行。在典型的输入输出vector为空的情况下,这并不会增加内存使用量。如果输出vector原本包含一些数据,那么新数据和旧数据将同时存在于内存中,直到函数结束。这是为了确保旧数据不会删除,除非新数据能够被完全计算出来。可以牺牲这种保证以换取更低的整体内存使用量,可以在函数开始时清空输出vector(另一方面,进行这种权衡的调用者都要在调用 transmogrify() 之前先清空vector)。

如果在 transmogrify() 函数执行期间(直到最后一行)抛出异常,则临时vector将删除,就像在栈上分配的局部变量一样(参见本书后面的第 5 章)。最后一行是异常安全的关键 —— 将输出vector的内容与临时vector的内容进行交换。如果该行可能抛出异常,那么我们所做的所有工作都将白费 —— 交换失败,输出vector将处于未定义状态,因为不知道在异常抛出之前交换操作成功执行了多少。但如果交换操作不会抛出异常(如 std::vector 的情况),只要程序执行到了最后一行,整个 transmogrify() 操作就算成功了,结果将返回给调用者。那么输出vector的旧内容会发生什么?现在归临时vector所有,而临时vector将在下一行(右花括号)隐式删除。假设类 C 的析构函数遵循 C++ 指南且不会抛出异常(否则将招致可怕的未定义行为),则整个函数就实现了异常安全。

这种习惯用法有时称为“复制与交换”,或许是最简单的方式来实现具有“提交或回滚”语义(即强异常安全保证)的操作。该习惯用法的关键在于能够以低成本,且不抛出异常的方式交换对象。

4.3.2 其他交换操作的惯用法

还有一些其他常用的、依赖于交换技术,尽管其重要性都不及交换在异常安全方面的应用。

这里,先从一个非常简单的方法开始,用于将容器或其他可交换对象重置为其默认构造的状态:

// Example 04
class C {
public:
  void swap(C& rhs) noexcept {
  ... 交换数据成员 ...
  }
};

C c = ....; // 包含数据的对象
{
  C tmp;
  c.swap(tmp); // c 和 tmp 交换内容,现在 c 变为默认构造状态
} // 原来的 c 的数据在此处销毁

这段代码显式地创建了一个默认构造(空)的对象,仅用于与之交换,并使用了一个作用域(一对花括号)来确保该对象能尽快销毁。

可以使用一个没有名字的临时对象来进行交换,从而做得更好:

C c = ....; // 包含数据的对象
C().swap(c); // 创建一个临时对象,与 c 交换内容,然后销毁临时对象

这里的临时对象在同一行代码中被创建和销毁,并将对象 c 的旧内容一并带走。交换顺序非常重要 —— swap() 成员函数在临时对象上调用。反过来操作将无法通过编译:

C c = ....; // 包含数据的对象
c.swap(C()); // 看起来合理但实际上无法通过编译

这是因为 swap() 成员函数的参数通过非 const 引用 C& 接收,而非常量引用不能绑定到临时对象(更一般地说,不能绑定到右值)。出于同样的原因,非成员的 swap() 函数也不能用于交换对象和临时对象。因此,如果类没有 swap() 成员函数,则必须创建一个显式命名的对象。

这种习惯用法的一种更通用的形式,用于对原始对象应用变换而不改变其在程序中的名称。假设程序中有一个vector,我们想对其应用前面的 transmogrify() 函数,但不想创建一个新vector。相反,希望在程序中继续使用原始的vector(或至少是其变量名),但其中包含新的数据。这种习惯用法是一种优雅地实现期望结果的方式:

// Example 05
std::vector<C> vec;
... // 向 vector 中写入数据
{
  std::vector<C> tmp;
  transmogrify(vec, tmp); // 对 vec 进行某种操作,结果写入 tmp
  swap(vec, tmp); // 交换 vec 和 tmp 的内容,现在 vec 拥有新数据!
} // 原来的 vec 数据(现在在 tmp 中)自动销毁
... // 继续使用包含新数据的 vec

如果 transmogrify() 可能抛出异常,必须将包含 swap 在内的整个作用域作为一个 try 块来处理,以实现异常安全:

std::vector<C> vec;
... // 向 vector 中写入数据
try {
  std::vector<C> tmp;
  transmogrify(vec, tmp); // 此操作可能抛出异常
  swap(vec, tmp); // 仅当 transmogrify 成功时,才交换内容
} catch (...) {} // vec 的内容未修改,保持原样
... // 继续使用 vec,仍包含原始数据

这种模式可以根据需要重复多次,无需在程序中引入新的名称,即可替换对象的内容。将其与不使用 swap 的更传统的 C 风格方法进行对比:

std::vector<C> vec;
... // 向 vector 中写入数据
std::vector<C> vec1;
transmogrify(vec, vec1); // 转换完成,现在必须使用 vec1!
std::vector<C> vec2;
transmogrify(vec1, vec2); // 再次转换,现在必须使用 vec2!

计算出新数据后,旧的名称 vec 和 vec1 仍然可以访问。很容易在后续代码中错误地使用 vec 而不是应该使用的 vec1。而使用前面演示的 swap 技术,程序不会被新变量名称所污染,始终保持原始名称的清晰性和一致性。

4.3.3 如何正确实现和使用交换操作

我们已经了解了标准库是如何实现交换(swap)功能的,以及对一个交换实现的期望是什么。现在,来看看如何为自己的类型正确地支持交换功能。

4.3.4 实现交换操作

所有 STL 容器以及许多其他标准库类型(例如 std::thread),都提供了一个 swap() 成员函数。虽然这不是强制要求,但这是实现需要访问类私有数据的交换操作的最简单方法,也是唯一能够将对象与同类型的临时对象进行交换的方式。正确声明 swap() 成员函数的方式如下:

class C {
public:
  void swap(C& rhs) noexcept;
}

只有在确实能够提供不抛出异常的保证时,才应包含 noexcept 说明;某些情况下,可能需要根据其他类型的属性进行条件化。如果合适,该函数也可以声明为 constexpr。

应该如何实现交换?有几种方法。对于许多类,可以简单地逐个交换其数据成员。这将交换对象的问题委托给了其所包含的类型,如果这些类型遵循了相应的模式,最终将归结为交换构成一切的内置类型。如果知道数据成员具有 swap() 成员函数,则可以直接调用它。否则,必须调用非成员的 swap。这很可能会调用 std::swap() 模板的一个实例化,但出于下一节将解释的原因,不应该通过该名称来调用它。

相反,应该将名称引入到包含的作用域中,并调用不带 std:: 限定符的 swap():

//Example 06a
#include <utility> // C++11之前是<algorithm>
...
class C {
public:
  void swap(C& rhs) noexcept {
    using std::swap; // 将 std::swap 引入当前作用域
    v_.swap(rhs.v_);
    swap(i_, rhs.i_); // 调用 std::swap
  }
  ...
private:
  std::vector<int> v_;
  int i_;
};

一种特别适合交换操作的实现习惯用法是所谓的 pimpl 习惯用法(pimpl idiom),也被称为句柄-主体惯用法。主要用于最小化编译依赖,并避免在头文件中暴露类的实现细节。这种惯用法中,类在头文件中的整个声明仅包含所有必要的公有成员函数,以及一个指向实际实现的单一指针。实现细节和成员函数的主体都位于 .cpp 文件中。指向实现的数据成员指针通常称为 p_impl 或 pimpl,因此得名。交换一个采用 pimpl 实现的类就像交换两个指针一样简单:

// Example 06b
// 在头文件 C.h 中:
class C_impl; // 前置声明

class C {
public:
  void swap(C& rhs) noexcept {
    swap(pimpl_, rhs.pimpl_);
  }
  void f(...); // 仅声名
  ...
private:
  C_impl* pimpl_;
};

// 在 C 源文件中:
class C_impl {
... 实际实现你内容 ...
};

void C::f(...) {
  pimpl_->f(...); // 对 C::f() 进行实现
}

这解决了成员函数 swap() 的实现问题。如果有人对我们的自定义类型调用非成员的 swap() 函数呢?按照目前的写法,如果 std::swap() 是可见的(例如,由于存在 using std::swap; 声明),该调用将调用 std::swap() 的默认实现,即使用复制或移动操作的那个版本:

// Example 07a
class C {
public:
  void swap(C& rhs) noexcept;
};
...

C c1(...), c2(...);
swap(c1, c2); // 要么无法编译,要么调用 std::swap

尽管已经有了 swap() 成员函数,但 std::swap 并不会使用它。显然,这里也必须提供一个非成员的 swap() 函数,可以在类声明之后轻松地声明一个。然而,我们还应该考虑该类不是在全局作用域中声明,而是在某个命名空间内声明时会发生什么:

// Example07b
namespace N {
  class C {
  public:
    void swap(C& rhs) noexcept;
  };

  void swap(C& lhs, C& rhs) noexcept { lhs.swap(rhs); }
}

...
N::C c1(...), c2(...);
swap(c1, c2); // 调用非成员函数 N::swap()

对 swap() 的非限定性调用会调用 N 命名空间内的非成员函数 swap(),而该非成员函数又会调用其中一个参数的成员函数 swap()(标准库采用的惯例是调用 lhs.swap())。但并没有直接调用 N::swap(),而只是调用了 swap()。在 N 命名空间之外且没有 using namespace N; 说明的情况下,非限定调用通常不会解析到命名空间内的函数。本例中,由于标准中的一个特性 —— 参数依赖查找(Argument-Dependent Lookup, ADL),也称为 Koenig 查找。ADL 会将函数参数类型所声明的作用域中的所有函数,都加入到重载解析的考虑范围内。

例子中,编译器看到 swap(...) 函数的 c1 和 c2 参数,并识别出它们的类型为 N::C,在确定 swap 名称所指代的具体函数之前就完成了这一识别。由于参数位于 N 命名空间中,该命名空间内声明的所有函数都会被加入到重载解析中,从而使 N::swap 函数变得可见。

如果类型具有 swap() 成员函数,那么实现非成员 swap() 函数最简单的方法就是调用该成员函数。然而,这种成员函数并非必需;如果决定不提供 swap() 成员函数,则非成员 swap() 函数必须能够访问类的私有数据。此时,需要声明为友元函数:

// Example 07c
class C {
  friend void swap(C& rhs) noexcept;
};

void swap(C& lhs, C& rhs) noexcept {
  ... 交换 C 的数据成员 ...
}

也可以将 swap() 函数的实现内联定义,而无需单独的定义:

// Example 07d
class C {
  friend void swap(C& lhs, C& rhs) noexcept {
    ... 交换 C 的数据成员 ...
  }
};

当处理类模板而非单个类时,这种模式尤其方便。我们将在第 11 章中会更详细地讨论此模式。

一个常被遗忘的实现细节是自交换 —— 即 swap(x, x),或在成员函数调用的情况下,x.swap(x)。这是否定义良好?它会做什么?在 C++03 和 C++11(及以后版本)中,自交换都是或应该定义良好,但最终不会产生效果,不会改变对象(尽管成本不一定为零)。用户定义的 swap 实现应该要么对自交换隐式安全,要么显式地进行检查。如果 swap 通过复制或移动赋值来实现,需要标准要求复制赋值必须对自赋值安全,而移动赋值可能会改变对象,但必须将其置于一个有效的状态,称为“已移动状态”(在此状态下,仍然可以向该对象赋值其他内容)。

还应注意,名称相似的 STL 函数 std::iter_swap 和 std::swap_ranges 实际上是算法,可使用 swap()(可能是 std::swap)来交换由迭代器指向的值,或由整个迭代器范围指向的值。也是如何正确调用 swap() 函数的范例,不仅仅适用于 STL。

4.3.4 正确使用交换操作

目前为止,我们在调用 swap() 成员函数、swap() 非成员函数,以及显式限定的 std::swap() 操作之间来回切换,却没有规律或理由。现在,应该为这个问题带来一些规范。

首先,只要确定 swap() 成员函数存在,直接调用始终安全且合适。后一种限定条件通常出现在编写模板代码时 —— 在处理具体类型时,通常清楚它们提供了什么接口。这就留下了一个问题:调用 swap() 非成员函数时,是否应该使用 std:: 前缀?

考虑一下如果使用 std:: 前缀会发生什么:

// Example 08
namespace N {
  class C {
  public:
    void swap(C& rhs) noexcept;
  };

  void swap(C& lhs, C& rhs) noexcept { lhs.swap(rhs); }
}

...
N::C c1(...), c2(...);
std::swap(c1, c2); // 调用 std::swap()
swap(c1, c2); // 调用 N::swap()

参数依赖查找(ADL)不适用于限定名称,这就是为什么对 std::swap() 的调用仍然会调用来自 STL 的 <utility> 头文件中的模板 swap 的实例化。因此,建议永远不要显式调用 std::swap(),而是通过 using 声明将该重载引入当前作用域,然后调用非限定的 swap:

using std::swap; // 使 std::swap() 在当前作用域可用
swap(c1, c2); // 如果提供了 N::swap(),则调用它
              // 否则,调用 std::swap()

这正是 STL 算法的做法。例如,std::iter_swap 通常这样实现:

template <typename Iter1, typename Iter2>
void iter_swap(Iter1 a, ITer2 b) {
  using std::swap;
  swap(*a, *b);
}

不幸的是,许多程序中仍然常见对 std::swap() 的完全限定调用。为了保护代码免受此类调用的影响,并确保无论何种调用方式都能使用自定义的 swap 实现,可以为自己的类型显式实例化 std::swap() 模板:

// Example 09
namespace std {
  void swap(N::C& lhs, N::C& rhs) noexcept {
    lhs.swap(rhs);
  }
}

通常,标准不允许用户在保留的 std:: 命名空间中声明自己的函数或类,但标准对某些模板函数的显式特化做了例外规定,std::swap() 正是其中之一。通过提供这样的特化,对 std::swap() 的调用将调用该特化版本,而该版本会转发到自定义的 swap 实现。

仅仅对 std::swap() 模板进行显式实例化明显不够,这样的实例化不会参与参数依赖查找(ADL)。如果未提供另一个非成员 swap 函数,就会遇到相反的问题:

using std::swap; // 使 std::swap 可在当前作用域使用
std::swap(c1, c2); // 明确调用 std::swap - 这不会调用用户定义的重载
swap(c1, c2); //  可能调用命名空间中的特化版本,或 std::swap

现在,非限定的调用最终会调用默认 std::swap() 操作的实例化版本 —— 即使用移动构造函数和赋值的那个版本。为了确保每一次对 swap 的调用都能正确处理,应当同时实现一个非成员的 swap() 函数和一个显式的 std::swap() 特化(当然,可以转发到同一个实现)。最后,标准允许通过模板实例化来扩展 std:: 命名空间,但不允许添加模板重载。如果面对的不是一个单一类型,而是一个类模板,就不能为其特化 std::swap;这样的代码很可能能够编译,但标准并不能保证所需的重载会选中(从技术上讲,标准将其视为未定义行为,且完全不提供保证)。仅出于这个原因,也应避免直接调用 std::swap。