概览

为什么我们需要复制和交换习语?

任何管理资源(包装器,比如智能指针)的类都需要实现三巨头。虽然复制构造函数和析构函数的目标和实现很简单,但复制赋值操作符可以说是最细致入微和最困难的。应该如何完成?需要避免哪些陷阱?

复制交换习语是解决方案,优雅地帮助赋值操作符实现两件事:避免代码重复,并提供强异常保证

它是如何工作的?

概念上,它通过使用复制构造函数的功能创建数据的本地副本,然后使用swap函数获取复制的数据,将旧数据与新数据交换。然后临时副本破坏,带走旧数据。我们留下了新数据的副本。

为了使用复制和交换习惯用法,我们需要三样东西:一个工作的复制构造函数,一个工作的析构函数(两者都是任何包装器的基础,所以无论如何都应该是完整的),和一个swap函数。

交换函数是非投掷函数,它交换类的两个对象,成员为成员。我们可能会尝试使用std::swap而不是提供我们自己的,但这是不可能的;std::swap在其实现中使用复制构造函数和复制赋值操作符,我们最终会尝试根据本身定义赋值操作符!

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


有深度的解释

的目标

让我们考虑一个具体的案例。我们想在一个无用的类中管理一个动态数组。我们从一个工作构造函数、复制构造函数和析构函数开始:

#include <algorithm> // std::copy#include <cstddef> // std::size_t
class dumb_array{public:// (default) constructordumb_array(std::size_t size = 0): mSize(size),mArray(mSize ? new int[mSize]() : nullptr){}
// copy-constructordumb_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, howeverstd::copy(other.mArray, other.mArray + mSize, mArray);}
// destructor~dumb_array(){delete [] mArray;}
private:std::size_t mSize;int* mArray;};

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

失败的解决方案

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

// the hard partdumb_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 newmSize = other.mSize; // (3)mArray = mSize ? new int[mSize] : nullptr; // (3)std::copy(other.mArray, other.mArray + mSize, mArray); // (3)}
return *this;}

我们说我们完成了;这现在管理一个数组,没有泄漏。然而,它存在三个问题,在代码中依次标记为(n)

  1. 第一个是自赋值测试。
    这个检查有两个目的:一是防止我们在自赋值时运行不必要的代码,二是保护我们免受细微的bug(比如删除数组只是为了复制它)。但在其他情况下,它只是让程序慢下来,在代码中起到噪音的作用;自赋值很少发生,所以大多数时候这个检查是一种浪费。
    如果没有它,操作员可以正常工作会更好。

  2. 第二个是它只提供了一个基本的异常保证,如果new int[mSize]失败了,那么*this就已经被修改了。(即大小错误,数据不见了!)
    对于强异常保证,它需要类似于:

     dumb_array& operator=(const dumb_array& other){if (this != &other) // (1){// get the new data ready before we replace the oldstd::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;}
  3. 代码已经扩展了!这将我们带到了第三个问题:代码重复。

我们的赋值操作符有效地复制了我们已经在其他地方编写的所有代码,这是一件可怕的事情。

在我们的例子中,它的核心只有两行(分配和复制),但是对于更复杂的资源,这个代码膨胀可能会很麻烦。我们应该努力永远不要重复自己。

(有人可能想知道:如果正确管理一个资源需要这么多代码,如果我的类管理多个资源怎么办?
虽然这似乎是一个有效的问题,并且确实需要重要的try/catch子句,但这不是问题。这是因为一个类应该管理只有一个资源!)

一个成功的解决方案

如前所述,复制交换习惯用法将解决所有这些问题。但是现在,我们拥有所有要求,除了一个: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 swappedswap(first.mSize, second.mSize);swap(first.mArray, second.mArray);}
// ...};

这里是为什么public friend swap的解释。)现在我们不仅可以交换dumb_array,而且交换通常可以更有效;它只是交换指针和大小,而不是分配和复制整个数组。除了功能和效率上的好处之外,我们现在准备实现复制和交换习语。

事不宜迟,我们的赋值操作符是:

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;}

我们失去了一个重要的优化机会。不仅如此,这个选择在C++11中至关重要,后面会讨论。(一般来说,一个非常有用的指导方针如下:如果你要复制函数中的某些东西,让编译器在参数列表中执行。)

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

观察到在进入函数时,所有的新数据都已经分配、复制并准备好使用。这就是我们免费提供强大异常保证的原因:如果复制构造失败,我们甚至不会进入函数,因此不可能改变*this的状态。(我们之前手动做的强大异常保证,编译器现在正在为我们做;多么善良。)

此时我们是自由的,因为swap是非抛出的。我们将当前数据与复制的数据交换,安全地改变我们的状态,旧数据被放入临时数据中。然后当函数返回时释放旧数据。(在参数的范围结束并调用其析构函数的地方。)

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

这就是复制和交换成语。

11C++怎么样?

C++的下一个版本,C++11,对我们管理资源的方式做了一个非常重要的改变:三规则现在是四的法则(半)。为什么?因为我们不仅需要能够复制构造我们的资源,我们也需要移动-建造它

幸运的是,这很容易:

class dumb_array{public:// ...
// move constructordumb_array(dumb_array&& other) noexcept ††: dumb_array() // initialize via default constructor, C++11 only{swap(*this, other);}
// ...};

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

所以我们所做的很简单:通过默认构造函数(C++11特性)初始化,然后用other交换;我们知道我们类的默认构造实例可以安全地分配和销毁,所以我们知道other在交换后也能做同样的事情。

(请注意,一些编译器不支持构造函数委托;在这种情况下,我们必须手动默认构造类。这是一个不幸但幸运的琐碎任务。)

那为什么管用?

这是我们需要对类进行的唯一更改,那么为什么它有效呢?请记住我们做出的将参数设为值而不是引用的重要决定:

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

现在,如果other被初始化为右值,它将被移动建造。完美。以同样的方式C++03让我们通过获取参数的值来重用我们的复制构造函数功能,C++11也会在适当的时候选择移动构造函数。(当然,正如前面链接的文章中提到的,值的复制/移动可能会被完全省略。)

复制和交换成语就这样结束了。


脚注

*为什么我们将mArray设置为null?因为如果运算符中的任何进一步代码抛出,dumb_array的析构函数可能会被调用;如果发生这种情况而不将其设置为null,我们会尝试删除已经删除的内存!我们通过将其设置为null来避免这种情况,因为删除null是一个无操作。

还有其他说法,我们应该为我们的类型专门化std::swap,提供一个类内swap以及一个自由函数swap,等等。但这都是不必要的:任何对swap的正确使用都将通过非限定调用,我们的函数将通过ADL找到。一个函数就可以了。

原因很简单:一旦你拥有了资源,你就可以将其交换和/或移动(C++11)到任何需要的地方。通过在参数列表中复制,你可以最大化优化。

``移动构造函数通常应该是noexcept,否则某些代码(例如std::vector调整大小的逻辑)即使移动有意义也会使用复制构造函数。当然,除非里面的代码不抛出异常,否则只将其标记为no。

赋值,在其核心,是两个步骤:摧毁物体的旧状态以副本的形式构建其新状态的其他对象的状态。

基本上,这就是析构函数复制构造函数所做的,所以第一个想法是将工作委托给他们。然而,由于销毁不能失败,而构造可能会,我们其实想反过来做首先执行建设性部分,如果成功了,然后做破坏性的部分。复制交换习惯用法就是这样做的:它首先调用类的复制构造函数来创建一个临时对象,然后将其数据与临时对象交换,然后让临时对象的析构函数销毁旧状态。
由于swap()应该永远不会失败,唯一可能失败的部分是复制构造。这是首先执行的,如果它失败了,目标对象中的任何内容都不会改变。

在其精炼形式中,复制和交换是通过初始化赋值操作符的(非引用)参数来执行复制来实现的:

T& operator=(T tmp){this->swap(tmp);return *this;}

这个答案更像是对上述答案的补充和轻微修改。

在某些版本的Visual Studio(可能还有其他编译器)中,有一个bug非常烦人且没有意义。因此,如果您像这样声明/定义swap函数:

friend void swap(A& first, A& second) {
std::swap(first.size, second.size);std::swap(first.arr, second.arr);
}

…当你调用swap函数时,编译器会对你大喊大叫:

输入图片描述

这与调用friend函数和this对象作为参数传递有关。


解决这个问题的方法是不使用friend关键字并重新定义swap函数:

void swap(A& other) {
std::swap(size, other.size);std::swap(arr, other.arr);
}

这一次,您可以调用swap并传入other,从而使编译器感到高兴:

输入图片描述


毕竟,你不会需要使用friend函数来交换2个对象。让swap成为一个有一个other对象作为参数的成员函数同样有意义。

您已经可以访问this对象,因此将其作为参数传递在技术上是多余的。

已经有一些很好的答案了。我将重点主要放在我认为它们缺乏的地方-用复制和交换习语解释“缺点”……

复制和交换成语是什么?

一种用交换函数实现赋值操作符的方法:

X& operator=(X rhs){swap(rhs);return *this;}

其基本思想是:

  • 分配给对象最容易出错的部分是确保获取新状态所需的任何资源(例如内存、描述符)

  • 如果创建了新值的副本,则可以在修改对象的当前状态(即*this)之前尝试获取,这就是为什么rhs被值(即复制)接受而不是通过引用接受的原因

  • 交换本地副本rhs*this的状态通常相对容易,没有潜在的失败/异常,因为本地副本之后不需要任何特定的状态(只需要适合析构函数运行的状态,就像从in>=C++11移动的对象一样)

什么时候应该使用它?(它解决了哪些问题[/创建链接]?)

  • 当你希望赋值对象不受抛出异常的赋值的影响时,假设你有或可以写一个具有强异常保证的swap,理想情况下是一个不能失败的throw

  • 当你想要一个干净,易于理解,健壮的方式来定义赋值操作符(简单)复制构造函数,swap和析构函数。

    • 作为复制和交换完成的自赋值避免了经常被忽视的边缘情况。

  • 如果在分配期间有一个额外的临时对象所产生的任何性能损失或暂时更高的资源使用率对您的应用程序并不重要。

swap抛出:通常可以可靠地交换对象通过指针跟踪的数据成员,但是没有无抛出交换的非指针数据成员,或者交换必须实现为X tmp = lhs; lhs = rhs; rhs = tmp;并且复制构造或赋值可能会抛出,仍然有可能失败,留下一些数据成员被交换而另一些没有。这种潜力甚至适用于C++03std::string,正如James在另一个答案上评论的那样:

@wilhelmtal:在C++03中,没有提到std::string::交换(由std::交换调用)可能抛出的异常。在C++0x中,std::string::交换是no的,不能抛出异常。-James McNellis Dec 22'10 at 15:24


赋值操作符从不同对象赋值时看起来正常的实现很容易因自赋值而失败。虽然客户端代码甚至尝试自赋值似乎是不可思议的,但在容器上的算法操作期间,它可以相对容易地发生,x = f(x);代码中f是(可能仅适用于一些#ifdef分支)宏ala#define f(x) x或返回对x的引用的函数,甚至(可能效率低下但简洁)像x = c1 ? x * 2 : c2 ? x / 2 : x;这样的代码)。例如:

struct X{T* p_;size_t size_;X& operator=(const X& rhs){delete[] p_;  // OUCH!p_ = new T[size_ = rhs.size_];std::copy(p_, rhs.p_, rhs.p_ + rhs.size_);}...};

在自赋值时,上面的代码删除x.p_;,将p_指向新分配的堆区域,然后尝试读取其中的未初始化数据(未定义行为),如果这不会做任何太奇怪的事情,copy尝试对每个刚刚销毁的“T”进行自赋值!


复制和交换习惯用法可能会因为使用额外的临时(当操作符的参数是复制构造的时)而导致效率低下或限制:

struct Client{IP_Address ip_address_;int socket_;X(const X& rhs): ip_address_(rhs.ip_address_), socket_(connect(rhs.ip_address_)){ }};

在这里,手写的Client::operator=可能会检查*this是否已经连接到与rhs相同的服务器(如果有用,可能会发送“重置”代码),而复制和交换方法将调用复制构造函数,该函数可能会被编写以打开一个不同的套接字连接,然后关闭原始连接。这不仅意味着远程网络交互而不是简单的进程内变量复制,它可能会违反客户端或服务器对套接字资源或连接的限制。(当然这个类有一个非常糟糕的接口,但那是另一回事;-P)。

当您处理C++11风格的分配器感知容器时,我想补充一点警告。交换和赋值有微妙的不同语义学。

为了具体,让我们考虑一个容器std::vector<T, A>,其中A是一些有状态的分配器类型,我们将比较以下函数:

void fs(std::vector<T, A> & a, std::vector<T, A> & b){a.swap(b);b.clear(); // not important what you do with b}
void fm(std::vector<T, A> & a, std::vector<T, A> & b){a = std::move(b);}

函数fsfm的目的是给a提供b最初的状态。然而,有一个隐藏的问题:如果a.get_allocator() != b.get_allocator()会发生什么?答案是:这取决于。让我们写AT = std::allocator_traits<A>

  • 如果AT::propagate_on_container_move_assignmentstd::true_type,则fma的分配器重新分配为b.get_allocator()的值,否则不会,a继续使用其原始分配器。在这种情况下,数据元素需要单独交换,因为ab的存储不兼容。

  • 如果AT::propagate_on_container_swapstd::true_type,则fs以预期的方式交换数据和分配器。

  • 如果AT::propagate_on_container_swapstd::false_type,那么我们需要动态检查。

    • 如果a.get_allocator() == b.get_allocator(),则两个容器使用兼容的存储,并且交换以通常的方式进行。
    • 但是,如果a.get_allocator() != b.get_allocator(),则程序具有未定义行为(参见[container.requirements.general/8]。

结果是,一旦你的容器开始支持有状态分配器,交换在C++11中就变成了一个重要的操作。这是一个有点“高级用例”,但也不是完全不可能,因为移动优化通常只有在你的类管理资源时才会变得有趣,而内存是最受欢迎的资源之一。