什么是复制交换成语?

这个成语是什么,什么时候使用? 它解决了哪些问题? 当使用C ++ 11时,习语是否改变?

虽然在很多地方都有提到,但我们并没有什么特别的问题和答案,所以就是这样。 这是以前提到的地方的部分列表:

  • 什么是你最喜欢的C + +编码风格的习惯用法:复制交换
  • 在C ++中复制构造函数和=运算符重载:是一个常用函数吗?
  • 什么是复制elision和它如何优化复制和交换习惯用法
  • C ++:dynamic分配一个对象数组?

概观

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

任何pipe理资源的类( 包装器 ,像智能指针)都需要实现三大要素 。 尽pipe复制构造器和析构器的目标和实现是简单的,但是复制赋值运算符可以说是最细微和困难的。 应该怎么办? 需要避免哪些陷阱?

复制交换方式是解决scheme,优雅地帮助赋值运算符实现两件事情:避免代码重复 ,并提供强大的exception保证 。

它是如何工作的?

从概念上讲 ,它通过使用复制构造函数来创build数据的本地副本,然后使用swapfunction获取复制的数据,将旧数据与新数据交换。 临时副本然后破坏,拿走旧数据。 我们剩下一份新的数据。

为了使用copy-and-swap成语,我们需要三件事情:一个工作拷贝构造函数,一个工作析构函数(都是任何包装的基础,所以应该是完整的)和一个swap函数。

交换函数是一个非抛出函数,可以交换类成员中的两个对象。 我们可能会试图使用std::swap而不是提供自己的,但这是不可能的; std::swap在其实现中使用复制构造函数和复制赋值运算符,我们最终将尝试根据自身定义赋值运算符!

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


一个深入的解释

目标

我们来考虑一个具体的案例。 我们想在一个没用的类中pipe理一个dynamic数组。 我们从一个正在工作的构造函数,复制构造函数和析构函数开始:

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

这个类几乎可以成功地pipe理这个数组,但是它需要operator=正常工作。

失败的解决scheme

下面是一个天真的实现可能看起来如何:

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

我们说我们已经完成了 这现在pipe理一个数组,没有泄漏。 然而,它有三个问题,在代码中顺序标记为(n)

  1. 首先是自我分配testing。 这个检查有两个目的:这是一个简单的方法来防止我们在自我分配上运行不必要的代码,它保护我们免受微妙的错误(例如删除数组,以便尝试和复制它)。 但是在其他所有情况下,这只会使程序放慢速度,在代码中起到噪声的作用。 自我分配很less发生,所以大多数时候这个检查是浪费。 如果没有它,运营商可以正常工作会更好。

  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 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; } 
  3. 代码已经扩展了! 这导致我们遇到第三个问题:代码重复。 我们的赋值操作符有效地复制了我们已经写在其他地方的所有代码,这是一件可怕的事情。

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

(有人可能会问:如果需要这么多的代码才能正确地pipe理一个资源,如果我的class级pipe理不止一个,那该怎么办呢?虽然这似乎是一个有效的考虑,而且它确实需要不平凡的try / catch子句,这是一个非问题,因为一个类只能pipe理一个资源 !)

成功的解决scheme

如前所述,复制交换成语将解决所有这些问题。 但现在,除了一个之外,我们有所有的要求:一个swapfunction。 虽然“三规则”成功地要求我们的复制构造函数,赋值运算符和析构函数的存在,但它应该被称为“三大半”:任何时候当你的类pipe理一个资源时,提供一个swapfunction。

我们需要添加交换function到我们的class级,我们这样做,如下所示†:

 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); } // ... }; 

( 下面是public friend swap的解释)现在,我们不仅可以交换我们的dumb_array ,而且总体而言,交换可以更有效; 它只是交换指针和大小,而不是分配和复制整个数组。 除了这个function和效率的好处之外,我们现在准备实施复制和交换的习惯用法。

闲话less说,我们的任务是:

 dumb_array& operator=(dumb_array other) // (1) { swap(*this, other); // (2) return *this; } 

而就是这样! 一举突破三个难题,

为什么它工作?

我们首先注意到一个重要的select:参数参数是按值进行的 。 虽然人们可以很容易做到以下几点(实际上,这个习语的许多天真的实现):

 dumb_array& operator=(const dumb_array& other) { dumb_array temp(other); swap(*this, temp); return *this; } 

我们失去了重要的优化机会 。 不仅如此,在C ++ 11中,这个select是关键的,稍后我们会讨论这个select。 (一般来说,一个非常有用的指导原则如下:如果要在函数中创build一个副本,让编译器在参数列表中进行操作。

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

注意到在input所有新数据已被分配,复制并准备使用的function时。 这是为我们提供了一个强大的免费例外保证:如果复制的构造失败,我们甚至不会进入函数,因此不可能改变*this的状态。 (我们之前手动做了一个强有力的例外保证,现在编译器正在为我们做些什么,怎么样)。

在这一点上,我们是免费的,因为swap是不扔。 我们将当前数据与复制的数据进行交换,安全地改变我们的状态,并将旧数据放入临时数据。 旧的数据然后在函数返回时被释放。 (在参数范围结束并且析构函数被调用的地方)

因为这个成语没有重复代码,所以我们不能在操作符中引入错误。 请注意,这意味着我们摆脱了自我分配检查的需要,允许一个统一的operator=实现。 (另外,我们不再对非自我分配有performance惩罚。)

这就是复制交换的习惯用法。

那么C ++ 11呢?

下一个版本的C ++,C ++ 11对我们如何pipe理资源做了一个非常重要的改变:现在三条法则是四条法则 (一半)。 为什么? 因为我们不仅需要能够复制构build我们的资源,还需要移动构build它 。

幸运的是,这很简单:

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

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

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

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

为什么这个工作?

这是我们需要对class级做出的唯一改变,那么为什么它会起作用呢? 请记住我们为使参数成为一个值而不是参考而做出的重要决定:

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

现在,如果other正在用右值初始化, 它将被移动构build 。 完善。 以同样的方式,C ++ 03让我们通过获取参数的值来重新使用我们的copy-constructorfunction,C ++ 11也会在适当的时候自动selectmove-constructor。 (当然,正如前面的链接文章中所提到的,价值的复制/移动可能完全被忽略。)

这样就完成了复制交换的习惯用法。


脚注

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

†还有其他的说法,我们应该为我们的types专门化std::swap ,提供一个在自由函数swap等方面的类内swap 。但是这是不必要的:任何正确使用swap将通过一个不合格调用,我们的function将通过ADLfind。 一个function就可以了。

‡原因很简单:一旦你拥有自己的资源,你可以在任何需要的地方交换和/或移动它(C ++ 11)。 通过在参数列表中进行复制,可以最大限度地优化。

作业的核心是两个步骤: 拆除物体的旧状态,build立新状态作为其他物体状态的副本

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

在其改进forms中,通过初始化赋值运算符的(非引用)参数来执行复制和交换:

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

已经有一些很好的答案了。 我将主要集中在我认为他们缺乏的东西上 – 用复制交换成语来解释“缺点”。

什么是复制交换成语?

根据交换function实现赋值运算符的一种方法:

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

其基本思想是:

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

  • 可以修改对象的当前状态(即*this之前尝试获取新值的副本,这就是为什么rhs (即被复制)接受而不是被引用

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

什么时候使用? (它解决了哪些问题[/创build] ?)

  • 当你想让赋值对象不受抛出exception的赋值的影响时,假设你有或者可以写一个具有强exception保证的swap ,理想的情况是一个不能失败/ throwswap 。†

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

    • 作为副本交换的自我分配避免了经常被忽视的边缘案例。
  • 如果在分配过程中有额外的临时对象造成的任何性能损失或暂时较高的资源使用情况对您的应用程序来说并不重要。 ⁂

swap投掷:通常可以通过指针可靠地交换对象跟踪的数据成员,但是非指针数据成员不具有无丢弃交换,或者交换必须被实现为X tmp = lhs; lhs = rhs; rhs = tmp; X tmp = lhs; lhs = rhs; rhs = tmp; 并且复制构build或者分配可能会丢失,仍然有可能失败,留下一些数据成员交换而其他数据成员不能交换。 这个潜力甚至适用于C ++ 03 std::string的詹姆斯评论另一个答案:

@wilhelmtell:在C ++ 03中,没有提到可能由std :: string :: swap(由std :: swap调用)引发的exception。 在C ++ 0x中,std :: string :: swap是noexcept,不能抛出exception。 – 詹姆斯McNellis 12年10月22日在15:24


‡赋值运算符的实现,当从一个不同的对象分配很容易失败的自我分配似乎是理智的。 尽pipe客户端代码甚至会尝试自我分配似乎是不可想象的,但在对容器进行algorithm运算时,相对容易发生,其中x = f(x); 代码其中f是(也许只对一些#ifdef分支)macrosala #define f(x) x或函数返回x的引用,甚至可能(如低效率,但简洁)的代码,如x = c1 ? x * 2 : c2 ? x / 2 : 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”!


copy因为使用了额外的临时(当操作员的参数是复制构造的),复制和交换成语会引入效率低下或限制:

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

在这里,一个手写的Client::operator=可能会检查是否已经连接到与rhs相同的服务器(如果有用的话,可能会发送一个“重置”代码),而copy-and-swap方法则会调用copy-构造函数,可能会被写入打开一个独特的套接字连接,然后closures原来的。 这不仅意味着一个远程networking交互,而是一个简单的进程内variables副本,它可能会违背客户端或服务器上的套接字资源或连接限制。 (当然这个类有一个非常可怕的界面,但这是另一回事; -P)。

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

在某些版本的Visual Studio中(可能还有其他编译器),有一个非常烦人的错误,没有任何意义。 所以如果你声明/定义你的swapfunction是这样的:

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

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

在这里输入图像描述

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


解决方法是不使用friend关键字并重新定义swapfunction:

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

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

在这里输入图像描述


毕竟,你不需要使用friendfunction来交换2个对象。 swap一个具有另外一个对象作为参数的成员函数也是非常有意义的。

您已经有权访问this对象,因此将它作为parameter passing在技术上是多余的。

在处理C ++ 11风格的分配器感知容器时,我想添加一个警告。 交换和分配具有细微差别的语义。

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

 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两个函数的目的是给出b最初的状态。 但是,有一个隐藏的问题:如果a.get_allocator() != b.get_allocator()会发生什么? 答案是:这取决于。 我们来写AT = std::allocator_traits<A>

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

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

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

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

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