什么是复制和交换习语?


1997

这个习语是什么,什么时候应该使用?它可以解决哪些问题?使用C ++ 11时,习惯用法会改变吗?

尽管在很多地方都提到过它,但是我们没有任何奇异的“这是什么”的问题和答案,所以就在这里。这是先前提到的地方的部分列表:



2
太棒了,我将这个问题与答案联系起来以移动语义
fredoverflow

4
对这个习语进行全面的解释是一个好主意,它是如此普遍,每个人都应该知道。
Matthieu M.

16
警告:复制/交换习惯用法的使用频率远远超过其有用的程度。当副本分配不需要强大的异常安全保证时,这通常对性能有害。并且,当需要非常强的异常安全性来进行副本分配时,除了快速得多的副本分配运算符之外,还可以通过简短的泛型函数轻松地提供此安全性。请参阅slideshare.net/ripplelabs/howard-hinnant-accu2014幻灯片43-53。摘要:复制/交换是工具箱中的有用工具。但是它已经被过度营销,随后经常被滥用。
Howard Hinnant

2
@HowardHinnant:是的,+ 1。我写这篇文章的时候几乎每个C ++问题都是“帮助我的类在复制时崩溃”,这就是我的回答。当您只想使用复制/移动语义或其他东西以便继续进行其他工作时,这是合适的,但这并不是最佳选择。如果您认为这会有所帮助,请随时在我的回答中放一个免责声明。
GManNickG '16

Answers:


2182

总览

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

任何管理资源的类(包装器,如智能指针)都需要实现“三巨头”。尽管复制构造函数和析构函数的目标和实现很简单,但是复制分配运算符无疑是最细微和最困难的。应该怎么做?需要避免什么陷阱?

复制和交换成语是解决方案,并协助典雅赋值运算符在实现两件事情:避免重复代码,并提供了一个强大的异常保证

它是如何工作的?

从概念上讲,它通过使用复制构造函数的功能来创建数据的本地副本,然后将复制的数据与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子句,但这是非必需的。-issue。这是因为类只能管理一个资源!)

成功的解决方案

如前所述,复制和交换惯用语将解决所有这些问题。但是现在,除了一个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);
    }

    // ...
};

是为什么的解释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中也至关重要,稍后将进行讨论。(一般来说,非常有用的准则如下:如果要在函数中复制某些内容,请让编译器在参数列表中进行。‡)

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

观察到进入该功能后,所有新数据都已被分配,复制并准备使用。这就是给我们免费的强大异常保证的原因:如果副本的构造失败,我们甚至都不会输入该函数,因此无法更改的状态*this。(我们以前为确保强烈的异常保证而手动进行的工作,现在编译器正在为我们做;这是多么友好。)

在这一点上,我们是无家可归的,因为swap没有投掷。我们将当前数据与复制的数据交换,安全地更改我们的状态,并且旧数据被放入临时数据中。函数返回时,将释放旧数据。(在该参数的作用域终止并调用其析构函数的位置。)

由于该惯用语不重复任何代码,因此我们无法在运算符内引入错误。请注意,这意味着我们无需进行自我分配检查,从而可以统一实现operator=。(此外,我们不再对非自我分配给予绩效惩罚。)

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

C ++ 11呢?

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

对我们来说幸运的是,这很容易:

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在交换之后将能够执行相同的操作。

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

为什么行得通?

这是我们需要对班级进行的唯一更改,那么为什么它起作用?记住我们做出的使参数成为值而不是引用的重要决定:

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

现在,如果other要使用右值初始化,它将被移动构造。完善。以相同的方式,C ++ 03让我们通过按值获取参数来重用我们的复制构造函数,C ++ 11将自动在适当时选择move-constructor。(并且,当然,如先前链接的文章中所述,可以简单地完全省略复制/移动值。)

这样就得出了“复制和交换”的成语。


脚注

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

†还有其他主张,我们应该专门std::swap针对我们的类型,在类中提供swap自由函数swap,等等。但这都是不必要的:对的任何正确使用swap将通过不合格的调用进行,而我们的函数将通过ADL找到。一种功能将起作用。

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

††move构造函数通常应为noexcept,否则std::vector即使在有意义的情况下,某些代码(例如,调整大小的逻辑)也将使用副本构造函数。当然,如果内部代码未引发异常,则仅将其标记为noexcept。


17
@GMan:我认为一次管理多个资源的类注定会失败(例外安全性成为噩梦),我强烈建议一个类管理一个资源,或者它具有业务功能和使用管理器。
Matthieu M.

22
我不明白为什么在这里将swap方法声明为好友?
szx

9
@asd:允许通过ADL找到它。
GManNickG 2011年

8
@neuviemeporte:用括号将数组元素默认初始化。没有它们,它们是未初始化的。由于在复制构造函数中无论如何我们都将覆盖这些值,因此我们可以跳过初始化。
GManNickG

10
@neuviemeporte:swap如果希望ADL在您将遇到的大多数通用代码(例如boost::swap其他交换实例)中工作,则需要在ADL中找到您。交换是C ++中一个棘手的问题,通常我们都同意单点访问是最好的(为了保持一致性),并且这样做的唯一方法通常是自由函数(int不能有交换成员,例如)。请参阅我的问题的背景知识。
GManNickG

274

分配从本质上讲分两个步骤:拆除对象的旧状态和将其新状态构建为其他对象状态的副本

基本上,这就是析构函数复制构造函数的工作,因此第一个想法是将工作委托给他们。但是,由于销毁一定不会失败,而在构建可能会失败的情况下,我们实际上希望以另一种方式进行首先执行建设性部分,如果成功,则进行破坏性部分。复制和交换的习惯是一种做到这一点的方法:它首先调用类的复制构造函数以创建一个临时对象,然后将其数据与该临时对象交换,然后让该临时对象的析构函数销毁旧状态。
以来swap()应该永远不会失败,唯一可能失败的部分就是复制构造。首先执行该操作,如果失败,则目标对象将保持不变。

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

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

1
我认为提及pimpl与提及副本,交换和破坏一样重要。交换并不是魔术般的异常安全。它是异常安全的,因为交换指针是异常安全的。你不具备使用PIMPL,但如果你不这样做,那么你必须确保成员的每个交换是异常安全。当这些成员可以更改时,这可能是一场噩梦,而当他们隐藏在皮普尔后面时,这是微不足道的。然后,是pimpl的成本。这导致我们得出这样的结论,即例外安全通常会在性能上付出代价。
wilhelmtell

7
std::swap(this_string, that)不提供无掷保证。它提供了强大的异常安全性,但没有不丢球的保证。
wilhelmtell

11
@wilhelmtell:在C ++ 03中,没有提到由引发的异常std::string::swap(由调用std::swap)。在C ++ 0x中,std::string::swapis noexcept并且一定不能抛出异常。
James McNellis 2010年

2
@sbi @JamesMcNellis好的,但是重点仍然存在:如果您有类类型的成员,则必须确保交换它们是没有问题的。如果您只有一个成员是指针,那么这很简单。否则不是。
wilhelmtell

2
@wilhelmtell:我认为这是交换的重点:它永远不会抛出,并且始终为O(1)(是的,我知道,std::array...)
2010年

44

已经有一些好的答案。我会专注主要集中在我认为它们缺少的内容上-对“复制和交换”习语的“缺点”进行解释。

什么是复制和交换习语?

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

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;复制构建或赋值的非指针数据成员可能会抛出,仍然有可能失败,导​​致某些数据成员被交换而另一些数据成员没有交换。std::string当詹姆斯评论另一个答案时,这种潜力甚至适用于C ++ 03 。

@wilhelmtell:在C ++ 03中,没有提到std :: string :: swap(由std :: swap调用)可能引发的异常。在C ++ 0x中,std :: string :: swap为noexcept且不得引发异常。– James McNellis 10年12月22日在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_);
    }
    ...
};

在自赋值时,上述代码delete的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=可能会检查是否*this已连接到同一服务器rhs(如果有用,也许会发送“重置”代码),而复制和交换方法将调用复制构造函数,该复制构造函数可能被编写为打开一个独特的套接字连接,然后关闭原始的套接字连接。这不仅意味着远程网络交互,而不是简单的进程中变量副本,还可能违反客户端或服务器对套接字资源或连接的限制。(当然,此类有一个非常恐怖的界面,但这是另一回事;-P)。


4
也就是说,套接字连接只是一个示例-相同的原理适用于任何潜在的昂贵初始化,例如硬件探测/初始化/校准,生成线程或随机数池,某些加密任务,高速缓存,文件系统扫描,数据库连接等。
托尼德尔罗伊

还有一个(大量)骗局。从当前的技术规格来看,该对象将没有移动分配运算符!如果以后用作类的成员,则新类将不会自动生成move-ctor!资料来源:youtu.be/mYrbivnruYw?
t=43m14s

3
的副本分配运算符的主要问题Client是禁止禁止分配。
2015年

在客户端示例中,该类应设为不可复制。
John Z. Li,

25

该答案更像是对以上答案的添加和稍作修改。

在某些版本的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一个具有一个other对象作为参数的成员函数同样有意义。

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


1
@GManNickG dropbox.com/s/o1mitwcpxmawcot/example.cpp dropbox.com/s/jrjrn5dh1zez5vy/Untitled.jpg。这是简化版本。每次使用friend调用函数时,似乎都会发生错误。*this参数
Oleksiy 2013年

1
正如我所说的,@ GManNickG,这是一个错误,可能对其他人有用。我只是想帮助一些可能和我有同样问题的人。我在Visual Studio 2012 Express和2013 Preview中都尝试过,唯一使它消失的是我的修改
Oleksiy

8
@GManNickG它不适合所有图像和代码示例的注释。如果人们反对,那是可以的,我敢肯定有人那里也有同样的错误。这篇文章中的信息可能正是他们所需要的。
Oleksiy 2013年

14
请注意,这只是IDE代码突出显示(IntelliSense)中的一个错误...它将编译得很好,没有警告/错误。
Amro,

3
如果您尚未这样做(并且尚未修复),请在此处报告VS错误。connect.microsoft.com/VisualStudio
马特(Matt

15

在处理C ++ 11样式的可识别分配器的容器时,我想加个警告。交换和赋值具有微妙的语义。

具体来说,让我们考虑一个container 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);
}

这两种功能的目的fs,并fm为给a该国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(),否则不a分配,并继续使用其原始分配器。在这种情况下,数据元素需要被单独地交换,因为的存储ab不兼容。

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

  • 如果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中不平凡的操作。这在某种程度上是“高级用例”,但并非完全不可能,因为一旦类管理资源,移动优化通常才变得有趣,而内存是最受欢迎的资源之一。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.