什么是复制交换惯用法?

什么是复制交换惯用法?

概述

为何需要复制交换惯用法?

任何管理资源的类(如智能指针这类包装器)都需要实现“三大法则”。虽然拷贝构造函数和析构函数的目标和实现较为直接,但拷贝赋值运算符可以说是最微妙和困难的。它应该如何实现?需要避免哪些陷阱?

“复制交换惯用法”就是解决方案,它能优雅地帮助赋值运算符实现两件事:避免代码重复,并提供强异常保证。

它是如何工作的?

从概念上讲,它利用拷贝构造函数的功能创建数据的本地副本,然后使用 swap 函数交换旧数据和新数据。临时副本随后析构,带走旧数据,最终留下新数据的副本。

要使用复制交换惯用法,我们需要三样东西:一个有效的拷贝构造函数、一个有效的析构函数(这两者是任何包装器的基础,通常应该已经实现),以及一个 swap 函数。

swap 函数是一个不抛出异常的函数,用于逐个成员地交换类的两个对象。我们可能会想使用 std::swap 而不是自己提供,但这是不可能的;std::swap 在其实现中使用了拷贝构造函数和拷贝赋值运算符,最终我们会陷入用赋值运算符来定义自身的困境!

(不仅如此,对 swap 的非限定调用将使用我们自定义的交换运算符,从而跳过 std::swap 会带来的不必要的类的构造和析构。)

深入解释

目标

让我们考虑一个具体的例子。我们要在一个看似无用的类中管理一个动态数组。我们从一个有效的构造函数、拷贝构造函数和析构函数开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <algorithm> // std::copy
#include <cstddef> // std::size_t

class dumb_array
{
public:
// (默认)构造函数
dumb_array(std::size_t size = 0)
: mSize(size),
mArray(mSize ? new int[mSize]() : nullptr)
{
}

// 拷贝构造函数
dumb_array(const dumb_array& other)
: mSize(other.mSize),
mArray(mSize ? new int[mSize] : nullptr)
{
// 注意,由于使用的数据类型,这不会抛出异常;
// 然而,在更一般的情况下,需要更注意异常处理
std::copy(other.mArray, other.mArray + mSize, mArray);
}

// 析构函数
~dumb_array()
{
delete [] mArray;
}

private:
std::size_t mSize;
int* mArray;
};

这个类几乎成功地管理了数组,但它需要 operator= 才能正确工作。

失败的解决方案

以下是一个简单实现可能的样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 难点部分
dumb_array& operator=(const dumb_array& other)
{
if (this != &other) // (1)
{
// 丢弃旧数据...
delete [] mArray; // (2)
mArray = nullptr; // (2) *(见脚注说明原因)

// ...并放入新数据
mSize = other.mSize; // (3)
mArray = mSize ? new int[mSize] : nullptr; // (3)
std::copy(other.mArray, other.mArray + mSize, mArray); // (3)
}

return *this;
}

我们认为这样就完成了;现在它可以管理数组而不会有内存泄漏。然而,它存在三个问题,在代码中依次标记为 (n)

  1. 第一个问题是自我赋值检查:这个检查有两个目的:它是一种防止在自我赋值时运行不必要代码的简单方法,并且可以保护我们避免一些微妙的错误(例如删除数组后再尝试复制它)。但在其他所有情况下,它只会减慢程序的速度,并且在代码中显得多余;自我赋值很少发生,所以大多数时候这个检查是浪费的。如果运算符可以在没有它的情况下正常工作就更好了。
  2. 第二个问题是它只提供了基本的异常保证:如果 new int[mSize] 失败,*this 将会被修改。(具体来说,大小错误且数据丢失!)为了提供强异常保证,它需要类似于以下代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
dumb_array& operator=(const dumb_array& other)
{
if (this != &other) // (1)
{
// 在替换旧数据之前准备好新数据
std::size_t newSize = other.mSize;
int* newArray = newSize ? new int[newSize]() : nullptr; // (3)
std::copy(other.mArray, other.mArray + newSize, newArray); // (3)

// 替换旧数据(所有操作都不会抛出异常)
delete [] mArray;
mSize = newSize;
mArray = newArray;
}

return *this;
}
  1. 代码膨胀:这导致了第三个问题:代码重复。我们的赋值运算符实际上重复了我们在其他地方已经编写的所有代码,这是非常糟糕的。在我们的例子中,核心部分只有两行(分配和复制),但对于更复杂的资源,这种代码膨胀可能会很麻烦。我们应该努力避免重复自己。

成功的解决方案

如前所述,复制交换惯用法将解决所有这些问题。但目前,我们除了一个 swap 函数外,其他要求都已满足。虽然“三大法则”成功地涵盖了拷贝构造函数、赋值运算符和析构函数的存在,但实际上应该称为“三大法则加半个”:任何时候你的类管理资源时,提供一个 swap 函数也是有意义的。

我们需要为我们的类添加交换功能,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class dumb_array
{
public:
// ...

friend void swap(dumb_array& first, dumb_array& second) // 不抛出异常
{
// 启用 ADL(在我们的例子中不是必需的,但这是良好的实践)
using std::swap;

// 通过交换两个对象的成员,
// 两个对象实际上被交换了
swap(first.mSize, second.mSize);
swap(first.mArray, second.mArray);
}

// ...
};

现在,我们不仅可以交换 dumb_array 对象,而且一般的交换操作可以更高效;它只是交换指针和大小,而不是分配和复制整个数组。除了功能和效率上的这个额外好处外,我们现在准备实现复制交换惯用法。

我们的赋值运算符如下:

1
2
3
4
5
6
dumb_array& operator=(dumb_array other) // (1)
{
swap(*this, other); // (2)

return *this;
}

就是这样!一举解决了所有三个问题。

为什么它能工作?

我们首先注意到一个重要的选择:参数是按值传递的。虽然人们也可以很容易地这样做(实际上,许多简单的惯用法实现就是这样做的):

1
2
3
4
5
6
7
dumb_array& operator=(const dumb_array& other)
{
dumb_array temp(other);
swap(*this, temp);

return *this;
}

但我们会失去一个重要的优化机会。不仅如此,这个选择在 C++11 中至关重要,后面会讨论。(一般来说,一个非常有用的准则是:如果你要在函数中复制某个东西,让编译器在参数列表中完成它。)

无论哪种方式,这种获取资源的方法是消除代码重复的关键:我们可以使用拷贝构造函数的代码来进行复制,而无需重复任何代码。现在复制完成后,我们准备进行交换。

注意,进入函数时,所有新数据已经分配、复制并准备好使用。这就是我们免费获得强异常保证的原因:如果复制构造失败,我们甚至不会进入函数,因此不可能改变 *this 的状态。(我们之前为了强异常保证手动做的事情,现在编译器为我们做了;多么贴心。)

此时我们就没问题了,因为 swap 不会抛出异常。我们将当前数据与复制的数据交换,安全地改变我们的状态,旧数据被放入临时对象中。当函数返回时,旧数据被释放。(此时参数的作用域结束,其析构函数被调用。)

因为惯用法不重复代码,我们不会在运算符中引入错误。注意,这意味着我们不再需要自我赋值检查,允许 operator= 有一个统一的实现。(此外,我们在非自我赋值时也不再有性能损失。)

这就是复制交换惯用法。

C++11 中的情况

C++ 的下一个版本 C++11 对我们管理资源的方式做了一个非常重要的改变:“三大法则”现在变成了“四大法则”(加半个)。为什么呢?因为我们不仅需要能够拷贝构造我们的资源,还需要能够移动构造它。

幸运的是,这很容易:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class dumb_array
{
public:
// ...

// 移动构造函数
dumb_array(dumb_array&& other) noexcept
: dumb_array() // 通过默认构造函数初始化,仅 C++11 支持
{
swap(*this, other);
}

// ...
};

这里发生了什么?回想一下移动构造的目标:从类的另一个实例中获取资源,使其处于保证可赋值和可析构的状态。

所以我们所做的很简单:通过默认构造函数初始化(C++11 特性),然后与 other 交换;我们知道类的默认构造实例可以安全地赋值和析构,所以我们知道交换后 other 也能做到这一点。

为什么这样行得通?

这是我们对类所做的唯一更改,为什么它能工作呢?记住我们做出的将参数设为值而不是引用的重要决定:

1
dumb_array& operator=(dumb_array other); // (1)

现在,如果 other 用右值初始化,它将被移动构造。完美。就像 C++03 让我们通过按值传递参数来重用拷贝构造函数的功能一样,C++11 也会在合适的时候自动选择移动构造函数。

最佳实践

  • 使用按值传递参数:在赋值运算符中按值传递参数,利用编译器的优化和 C++11 的移动语义。
  • 实现非抛出的 swap 函数:确保 swap 函数不抛出异常,以提供强异常保证。
  • 遵循单一职责原则:一个类只管理一个资源,简化资源管理和异常处理。

常见问题

自我赋值检查是否必要?

在复制交换惯用法中,自我赋值检查不是必需的,因为该惯用法本身可以处理自我赋值,并且避免了不必要的性能开销。

swap 函数抛出异常会怎样?

如果 swap 函数抛出异常,可能会导致对象状态不一致。因此,swap 函数应该设计为不抛出异常。

C++11 中使用复制交换惯用法有什么变化?

C++11 引入了移动语义,需要实现移动构造函数。按值传递参数的赋值运算符会自动利用移动构造函数,提高性能。


什么是复制交换惯用法?
https://119291.xyz/posts/2025-05-15.what-is-the-copy-and-swap-idiom/
作者
ww
发布于
2025年5月15日
许可协议