这个成语是什么,什么时候应该使用?它解决了哪些问题?使用 C++11 时习语会改变吗?
虽然在很多地方都提到过,但我们没有任何单一的“它是什么”的问题和答案,所以就在这里。这是之前提到的地方的部分列表:
最佳答案
概述
为什么我们需要 copy-and-swap 习语?
任何管理资源的类(包装器,如智能指针)都需要实现 The Big Three 。虽然复制构造函数和析构函数的目标和实现很简单,但复制赋值运算符可以说是最微妙和最困难的。应该怎么做?需要避免哪些陷阱?
copy-and-swap 习语是解决方案,它优雅地协助赋值运算符实现两件事:避免 code duplication 和提供 strong exception guarantee 。
它是如何工作的?
Conceptually ,它通过使用复制构造函数的功能来创建数据的本地拷贝,然后使用 swap
函数获取复制的数据,将旧数据与新数据交换。然后临时拷贝销毁,同时带走旧数据。我们留下了新数据的拷贝。
为了使用 copy-and-swap 习语,我们需要三样东西:一个有效的复制构造函数、一个有效的析构函数(两者都是任何包装器的基础,所以无论如何都应该是完整的)和一个 swap
函数。
交换函数是一个非抛出函数,它交换一个类的两个对象,成员对成员。我们可能会尝试使用 std::swap
而不是提供我们自己的,但这是不可能的; std::swap
在其实现中使用了复制构造函数和复制赋值运算符,我们最终会尝试根据自身定义赋值运算符!
(不仅如此,对 swap
的不合格调用将使用我们的自定义交换运算符,跳过 std::swap
将需要的不必要的类构造和销毁。)
深入的解释
目标
让我们考虑一个具体的案例。我们想在一个其他无用的类中管理一个动态数组。我们从一个有效的构造函数、复制构造函数和析构函数开始:
#include <algorithm> // std::copy
#include <cstddef> // std::size_t
class dumb_array
{
public:
// (default) constructor
dumb_array(std::size_t size = 0)
: mSize(size),
mArray(mSize ? new int[mSize]() : nullptr)
{
}
// copy-constructor
dumb_array(const dumb_array& other)
: mSize(other.mSize),
mArray(mSize ? new int[mSize] : nullptr)
{
// note that this is non-throwing, because of the data
// types being used; more attention to detail with regards
// to exceptions must be given in a more general case, however
std::copy(other.mArray, other.mArray + mSize, mArray);
}
// destructor
~dumb_array()
{
delete [] mArray;
}
private:
std::size_t mSize;
int* mArray;
};
这个类几乎成功地管理了数组,但它需要 operator=
才能正常工作。失败的解决方案
下面是一个简单的实现的样子:
// the hard part
dumb_array& operator=(const dumb_array& other)
{
if (this != &other) // (1)
{
// get rid of the old data...
delete [] mArray; // (2)
mArray = nullptr; // (2) *(see footnote for rationale)
// ...and put in the new
mSize = other.mSize; // (3)
mArray = mSize ? new int[mSize] : nullptr; // (3)
std::copy(other.mArray, other.mArray + mSize, mArray); // (3)
}
return *this;
}
我们说我们完成了;这现在管理一个数组,没有泄漏。但是,它存在三个问题,在代码中按顺序标记为 (n)
。这个检查有两个目的:它是一种防止我们在自赋值时运行不必要代码的简单方法,它保护我们免受细微的错误(例如删除数组只是为了尝试和复制它)。但在所有其他情况下,它只会减慢程序速度,并在代码中充当噪音;自赋值很少发生,所以大部分时间这个检查都是浪费。
如果运算符(operator)没有它也能正常工作就更好了。
new int[mSize]
失败,则 *this
将被修改。 (即大小不对,数据没了!)对于强大的异常保证,它需要类似于:
dumb_array& operator=(const dumb_array& other)
{
if (this != &other) // (1)
{
// get the new data ready before we replace the old
std::size_t newSize = other.mSize;
int* newArray = newSize ? new int[newSize]() : nullptr; // (3)
std::copy(other.mArray, other.mArray + newSize, newArray); // (3)
// replace the old data (all are non-throwing)
delete [] mArray;
mSize = newSize;
mArray = newArray;
}
return *this;
}
我们的赋值运算符有效地复制了我们已经在别处编写的所有代码,这是一件可怕的事情。
在我们的例子中,它的核心只有两行(分配和复制),但是对于更复杂的资源,这个代码膨胀可能会很麻烦。我们应该努力不再重蹈覆辙。
(有人可能会想:如果正确管理一个资源需要这么多代码,如果我的类管理多个资源怎么办?
虽然这似乎是一个有效的问题,实际上它需要非平凡的
try
/catch
子句,但这不是问题。那是因为一个类应该管理 one resource only !)
一个成功的解决方案
如前所述, copy-and-swap 习语将解决所有这些问题。但是现在,除了一个
swap
函数之外,我们有所有的要求。虽然三法则成功地包含了我们的复制构造函数、赋值运算符和析构函数,但它确实应该被称为“三大半”:任何时候你的类管理一个资源,提供一个 swap
也是有意义的功能。我们需要为我们的类添加交换功能,我们这样做如下†:
class dumb_array
{
public:
// ...
friend void swap(dumb_array& first, dumb_array& second) // nothrow
{
// enable ADL (not necessary in our case, but good practice)
using std::swap;
// by swapping the members of two objects,
// the two objects are effectively swapped
swap(first.mSize, second.mSize);
swap(first.mArray, second.mArray);
}
// ...
};
( Here 是为什么 public friend swap
的解释。)现在我们不仅可以交换我们的 dumb_array
,而且交换通常可以更有效;它只是交换指针和大小,而不是分配和复制整个数组。除了功能和效率方面的这一优势外,我们现在准备实现 copy-and-swap 习语。闲话少说,我们的赋值运算符是:
dumb_array& operator=(dumb_array other) // (1)
{
swap(*this, other); // (2)
return *this;
}
就是这样!一举一动,所有三个问题都得到了优雅的解决。为什么有效?
我们首先注意到一个重要的选择:参数参数是按值获取的。虽然人们可以很容易地执行以下操作(实际上,该习语的许多幼稚实现都是这样做的):
dumb_array& operator=(const dumb_array& other)
{
dumb_array temp(other);
swap(*this, temp);
return *this;
}
我们丢失了一个 important optimization opportunity 。不仅如此,这个选择在 C++11 中也很关键,后面会讨论。 (一般来说,一个非常有用的准则如下:如果您要在函数中复制某些内容,请让编译器在参数列表中进行复制。‡)无论哪种方式,这种获取资源的方法都是消除代码重复的关键:我们可以使用复制构造函数中的代码进行复制,而无需重复任何部分。现在拷贝已经制作完成,我们准备好交换了。
观察到,在进入函数时,所有新数据都已分配、复制并准备好使用。这就是免费为我们提供强大异常保证的原因:如果复制的构造失败,我们甚至不会进入该函数,因此不可能更改
*this
的状态。 (我们之前手动为强大的异常保证所做的,编译器现在正在为我们做;怎么样。)此时我们无家可归,因为
swap
是非 throw 的。我们用复制的数据交换当前数据,安全地改变我们的状态,旧数据被放入临时数据。然后在函数返回时释放旧数据。 (在参数的范围结束并调用其析构函数时。)因为习语没有重复代码,所以我们不能在操作符中引入错误。请注意,这意味着我们不再需要自赋值检查,从而允许
operator=
的单一统一实现。 (此外,我们不再对非自我分配有性能损失。)这就是 copy-and-swap 习语。
C++11 怎么样?
C++ 的下一个版本,C++11,对我们管理资源的方式做了一个非常重要的改变:三法则现在是 四法则 (半)。为什么?因为我们不仅需要能够复制构造我们的资源 we need to move-construct it as well 。
幸运的是,这很容易:
class dumb_array
{
public:
// ...
// move constructor
dumb_array(dumb_array&& other) noexcept ††
: dumb_array() // initialize via default constructor, C++11 only
{
swap(*this, other);
}
// ...
};
这里发生了什么?回想一下移动构造的目标:从类的另一个实例中获取资源,使其处于保证可分配和可破坏的状态。所以我们所做的很简单:通过默认构造函数(C++11 特性)初始化,然后与
other
交换;我们知道我们类的默认构造实例可以安全地分配和销毁,因此我们知道 other
将能够在交换后执行相同的操作。(请注意,有些编译器不支持构造函数委托(delegate);在这种情况下,我们必须手动默认构造类。这是一项不幸但幸运的微不足道的任务。)
为什么这样做?
这是我们需要对类进行的唯一更改,那么它为什么会起作用呢?记住我们做出的让参数成为值而不是引用的重要决定:
dumb_array& operator=(dumb_array other); // (1)
现在,如果 other
用右值初始化,它将是移动构造的。完美的。与 C++03 让我们通过按值获取参数来重用我们的复制构造函数功能一样,C++11 也会在适当的时候自动选择移动构造函数。 (当然,正如之前链接的文章中提到的,值的复制/移动可能会被完全省略。)copy-and-swap 习语到此结束。
脚注
*为什么我们将
mArray
设置为 null?因为如果运算符中的任何其他代码抛出,则可能会调用 dumb_array
的析构函数;如果发生这种情况而未将其设置为 null,我们将尝试删除已删除的内存!我们通过将其设置为 null 来避免这种情况,因为删除 null 是一种无操作。†还有其他说法,我们应该为我们的类型专门化
std::swap
,提供类内 swap
以及自由函数 swap
等。但这都是不必要的:任何正确使用 swap
都将通过不合格的调用我们的函数将通过 ADL 找到。一个功能就行。‡原因很简单:一旦您拥有自己的资源,您就可以将其交换和/或移动 (C++11) 到任何需要的地方。通过在参数列表中进行复制,您可以最大限度地优化。
††移动构造函数通常应为
noexcept
,否则即使移动有意义,某些代码(例如 std::vector
调整大小逻辑)也会使用复制构造函数。当然,只有在里面的代码没有抛出异常的情况下才标记为noexcept。
关于c++ - 什么是 copy-and-swap 习语?,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/3279543/