提问



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


虽然在很多地方已经提到过,但我们没有任何单一的问题和答案是什么,所以在这里。以下是前面提到过的地方的部分列表:



  • 你最喜欢的C ++编码风格习语是什么:Copy-swap

  • 在C ++中复制构造函数和=运算符重载:这是一个常见的函数吗?

  • 什么是复制省略以及如何优化复制和交换习惯用法

  • C ++:动态分配对象数组?


最佳参考


概述



为什么我们需要复制和交换习惯用法?



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


复制和交换习语是解决方案,优雅地帮助赋值运算符实现两件事:避免代码重复,并提供强大的异常保证。[107] [108]


它是如何工作的?



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


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


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


(不仅如此,对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)



  1. 首先是自我指派测试。这个检查有两个目的:它是一种简单的方法来阻止我们在自我分配上运行不必要的代码,并且它保护我们免受微妙的错误(例如删除数组只是为了尝试复制它)。但在所有其他情况下它只是用来减慢程序速度,并在代码中起到噪声的作用;很少发生自我分配,因此大多数情况下这种检查都是浪费。如果没有它,操作员可以正常工作会更好。

  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. 代码扩展了!这引出了第三个问题:代码重复。我们的赋值运算符有效地复制了我们已经在其他地方写过的所有代码,这是一件非常糟糕的事情。



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


(有人可能会想:如果需要这么多代码来正确管理一个资源,如果我的班级管理不止一个怎么办?虽然这似乎是一个有效的问题,实际上它需要非平凡try/[[catch条款,这是一个非问题。那是因为一个班级应该只管理一个资源!)[110]


成功的解决方案



如上所述,复制和交换习惯用法将解决所有这些问题。但是现在,除了一个以外,我们有所有要求:swap函数。虽然The Rule of Three成功地要求我们的复制构造函数,赋值运算符和析构函数的存在,但它应该被称为三巨头半:任何时候你的类管理一个资源,它也是有意义的提供[[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);
    }

    // ...
};


(这里是为什么public friend swap的解释。)现在我们不仅可以交换dumb_array,但通常交换可以更有效;它只是交换指针和大小,而不是分配和复制整个除了功能和效率方面的这一奖励之外,我们现在已准备好实施复制和交换习惯用法。


不用多说,我们的赋值运算符是:


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

    return *this;
}


就是这样!一举三得,这三个问题都得到了优雅的解决。


为什么会起作用?



我们首先注意到一个重要的选择:参数参数是 by-value 。虽然人们可以轻松地执行以下操作(实际上,许多简单的习惯实现):


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

    return *this;
}


我们失去了重要的优化机会。不仅如此,这种选择在C ++ 11中至关重要,后面将对此进行讨论。 (一般来说,一个非常有用的指导如下:如果您要在函数中复制某些内容,请让编译器在参数列表中执行。‡)[112]


无论哪种方式,这种获取资源的方法是消除代码重复的关键:我们可以使用copy-constructor中的代码来制作副本,而不需要重复它。现在副本已经完成,我们已准备好进行交换。


注意,在进入该功能时,已经分配,​​复制并准备好使用所有新数据。这就是免费提供强有力的例外保证:如果复制的构造失败,我们甚至不会进入函数,因此无法改变*this的状态。 (我们之前手动完成了强有力的异常保证,编译器现在为我们做了;怎么样。)


在这一点上,我们没有家,因为swap是非投掷的。我们将当前数据与复制的数据交换,安全地改变我们的状态,并将旧数据放入临时数据中。然后在函数返回时释放旧数据。 (在参数的范围结束并调用其析构函数的位置。)


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


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


C ++ 11怎么样?



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


幸运的是,这很容易:


class dumb_array
{
public:
    // ...

    // move constructor
    dumb_array(dumb_array&& other)
        : 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让我们通过使用参数by-value重用我们的复制构造函数,C ++ 11将自动选择适当的移动构造函数。 (当然,正如先前链接的文章中所提到的,复制/移动值可能完全被省略。)


因此,复制和交换习语得出结论。





脚注



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


†还有其他声称我们应该专门std::swap为我们的类型,提供一个类swap旁边的自由函数swap,等等。但这是不必要的:任何正确使用swap将通过无条件调用,我们的功能将通过ADL找到。一个功能就可以。[114]


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

其它参考1


分配的核心是两个步骤: 拆除对象的旧状态 将其新状态构建为副本 某些其他对象的状态。


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


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


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

其它参考2


已经有一些好的答案了。我将主要主要关注我认为他们缺乏的东西 - 用复制和交换习语解释缺点......



  什么是复制和交换习语?



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


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


基本思想是:



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

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

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




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




  • 如果您希望被分配的对象不受引发异常的赋值的影响,假设您已经或可以编写具有强异常保证的swap,并且理想情况下不会失败/throw]] ..†


  • 当你需要一个干净,易于理解,强大的方法来根据(更简单的)复制构造函数,swap和析构函数来定义赋值运算符。



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


  • 如果在分配期间通过拥有额外的临时对象而导致的任何性能损失或暂时更高的资源使用对您的应用程序并不重要。 ⁂






swap抛出:通常可以可靠地交换对象通过指针跟踪的数据成员,但非指针数据成员不进行无抛出交换,或者必须实现交换因为X tmp = lhs; lhs = rhs; rhs = tmp;和复制构造或赋值可能会抛出,仍然有可能失败,一些数据成员交换而另一些则没有。这个潜力甚至适用于C ++ 03 std::string,因为詹姆斯评论另一个答案:



  @wilhelmtell:在C ++ 03中,没有提到std :: string :: swap(由std :: swap调用)可能抛出的异常。在C ++ 0x中,std :: string :: swap是noexcept,不能抛出异常。 - 詹姆斯麦克尼利斯12月22日10点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_);
    }
    ...
};


在自我赋值时,上面的代码删除s 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相同的服务器(如果有用,可能会发送重置代码),而复制 - 和 - swap方法将调用copy-constructor,它可能被写入以打开一个不同的套接字连接然后关闭原始套接字连接。这不仅意味着远程网络交互而不是简单的进程内变量复制,它可能会对套接字资源或连接的客户端或服务器限制产生影响。 (当然这个类有一个非常可怕的界面,但那是另一回事;-P)。

其它参考3


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


在某些版本的Visual Studio(以及可能的其他编译器)中,有一个非常烦人且没有意义的错误。所以如果你声明/定义你的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成为具有一个swap的成员函数同样有意义other对象作为参数。


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

其它参考4


当您处理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()的值,否则它不会,fm]]继续使用其原始分配器。在这种情况下,由于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中的一个非常重要的操作。这是一个有点高级用例,但并非完全不可能,因为一旦你的班级管理资源,移动优化通常只会变得有趣,而内存是最受欢迎的资源之一。