什么是复制和交换成语?

What is the copy-and-swap idiom?

提问人:GManNickG 提问时间:7/19/2010 最后编辑:CerbrusGManNickG 更新时间:10/4/2023 访问量:487676

问:

什么是复制和交换成语,何时应该使用?它能解决什么问题?C++11 会改变吗?

相关:

C 构造函数 赋值运算符 C++-FAQ 复制和交换

评论

9赞 DumbCoder 7/19/2010
Herb Sutter 的 gotw.ca/gotw/059.htm
4赞 fredoverflow 7/19/2010
太棒了,我把这个问题从我的答案中链接起来,以移动语义
4赞 Matthieu M. 7/19/2010
对这个成语有一个全面的解释是个好主意,它很常见,每个人都应该知道它。
30赞 Howard Hinnant 3/16/2016
警告:复制/交换习语的使用频率远远高于其有用性。当副本分配不需要强大的异常安全保证时,这通常会对性能造成损害。当复制分配需要强大的异常安全性时,除了更快的复制分配运算符外,还可以通过简短的泛型函数轻松提供。见 slideshare.net/ripplelabs/howard-hinnant-accu2014 幻灯片 43 - 53。摘要:复制/交换是工具箱中一个有用的工具。但它被过度推销,随后经常被滥用。
3赞 GManNickG 3/16/2016
@HowardHinnant:是的,+1。我写这篇文章的时候,几乎每个 C++ 问题都是“帮助我的类在复制它时崩溃”,这是我的回答。当您只想要工作复制/移动语义或其他任何内容时,这是合适的,这样您就可以继续做其他事情,但这并不是真正的最佳选择。如果您认为这会有所帮助,请随时在我的答案顶部添加免责声明。

答:

2559赞 GManNickG 7/19/2010 #1

概述

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

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

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

它是如何工作的?

从概念上讲,它的工作原理是使用复制构造函数的功能创建数据的本地副本,然后使用函数获取复制的数据,将旧数据与新数据交换。然后,临时副本会进行销毁,并带走旧数据。我们只剩下新数据的副本。swap

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

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

(不仅如此,不合格的调用 to 将使用我们的自定义交换运算符,跳过不必要的构造和破坏我们的类。swapstd::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. 代码已扩展!这就引出了第三个问题:代码重复。

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

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

(有人可能会问:如果需要这么多代码来正确管理一个资源,如果我的类管理多个资源怎么办?
虽然这似乎是一个合理的担忧,而且它确实需要非平凡的 / 条款,但这不是一个问题。
那是因为一个类应该只管理一个资源
trycatch

成功的解决方案

如前所述,复制和交换习语将解决所有这些问题。但现在,我们满足了所有需求,除了一个功能。虽然三法则成功地要求我们的复制构造函数、赋值运算符和析构函数的存在,但它实际上应该被称为“三大半”:任何时候你的类管理资源时,提供一个函数也是有意义的。swapswap

我们需要将交换功能添加到我们的类中,我们这样做如下†:

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 swapdumb_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=

这就是复制和交换的成语。

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++ 功能)初始化,然后用 ;我们知道我们类的默认构造实例可以安全地被赋值和销毁,所以我们知道在交换后也能够做同样的事情。otherother

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

为什么会这样?

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

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

现在,如果使用 rvalue 进行初始化,它将被移动构造。完善。就像 C++03 让我们通过按值获取参数来重用我们的复制构造函数功能一样,C++11 也会在适当的时候自动选择移动构造函数。(当然,正如前面链接的文章中提到的,可以完全省略该值的复制/移动。other

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


脚注

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

†还有其他主张,我们应该专门针对我们的类型,提供类内自由功能等。但这一切都是不必要的:任何正确的使用都将通过一个不合格的调用,而我们的函数将通过 ADL 找到。一个功能就可以了。std::swapswapswapswap

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

††移动构造函数一般应该是 ,否则一些代码(例如 调整大小逻辑)将使用复制构造函数,即使移动是有意义的。当然,除非里面的代码没有抛出异常,否则只能将其标记为 no。noexceptstd::vector

评论

21赞 Matthieu M. 7/19/2010
@GMan:我认为一个类同时管理多个资源注定要失败(异常安全变得噩梦),我强烈建议一个类管理一个资源,或者它具有业务功能并使用管理器。
29赞 szx 12/13/2011
我不明白为什么交换方法在这里被声明为朋友?
10赞 GManNickG 7/19/2012
@neuviemeporte:使用括号表示数组元素默认初始化。如果没有,它们将未初始化。由于在复制构造函数中,无论如何我们都会覆盖值,因此我们可以跳过初始化。
11赞 GManNickG 7/20/2012
@neuviemeporte:如果你希望它在你会遇到的大多数通用代码(如和其他各种交换实例)中工作,你需要在 ADL 期间找到它。交换在 C++ 中是一个棘手的问题,通常我们都同意单点访问是最好的(为了一致性),通常这样做的唯一方法是自由函数(例如,不能有交换成员)。有关背景信息,请参阅我的问题swapboost::swapint
8赞 GManNickG 7/30/2013
@BenHymers:是的。复制和交换习惯用语仅用于以一般方式简化新资源管理类的创建。对于每个特定的类别,几乎可以肯定有一条更有效的路线。这个成语只是有效的东西,很难做错。
335赞 sbi 7/19/2010 #2

赋值的核心是两个步骤:拆除对象的旧状态,并将其新状态构建为其他对象状态的副本。

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

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

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

评论

1赞 wilhelmtell 12/22/2010
我认为提到疙瘩与提到复制、交换和销毁一样重要。交换并不是神奇的例外安全。它是异常安全的,因为交换指针是异常安全的。你不必使用疙瘩,但如果你不这样做,那么你必须确保成员的每次交换都是异常安全的。当这些成员可以改变时,这可能是一场噩梦,而当他们隐藏在疙瘩后面时,这可能是一场噩梦。然后,然后是粉刺的成本。这使我们得出结论,异常安全通常会带来性能成本。
7赞 wilhelmtell 12/22/2010
std::swap(this_string, that)不提供不抛出保证。它提供了强大的异常安全性,但不是无抛出保证。
12赞 James McNellis 12/22/2010
@wilhelmtell:在 C++03 中,没有提到可能引发的异常(称为 )。在 C++0x 中,是且不得抛出异常。std::string::swapstd::swapstd::string::swapnoexcept
2赞 wilhelmtell 12/22/2010
@sbi @JamesMcNellis没问题,但重点仍然成立:如果你有类类型的成员,你必须确保交换它们是不抛的。如果只有一个成员是指针,那么这是微不足道的。否则就不是了。
2赞 sbi 12/22/2010
@wilhelmtell:我以为这就是交换的意义所在:它从不抛出,它总是O(1)(是的,我知道,...std::array
28赞 Oleksiy 9/4/2013 #3

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

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

friend void swap(A& first, A& second) {

    std::swap(first.size, second.size);
    std::swap(first.arr, second.arr);

}

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

enter image description here

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


解决此问题的方法是不使用关键字并重新定义函数:friendswap

void swap(A& other) {

    std::swap(size, other.size);
    std::swap(arr, other.arr);

}

这一次,你可以直接调用并传入,从而使编译器满意:swapother

enter image description here


毕竟,您不需要使用函数来交换 2 个对象。创建一个将一个对象作为参数的成员函数也同样有意义。friendswapother

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

评论

9赞 Oleksiy 9/4/2013
@GManNickG它不适合包含所有图像和代码示例的评论。如果人们投反对票也没关系,我敢肯定有人会遇到同样的错误;这篇文章中的信息可能正是他们所需要的。
14赞 Amro 10/11/2013
请注意,这只是 IDE 代码突出显示 (IntelliSense) 中的一个错误...它将编译得很好,没有警告/错误。
3赞 Matt 5/14/2014
如果您还没有这样做(并且尚未修复),请在此处报告 VS 错误 connect.microsoft.com/VisualStudio
2赞 villasv 11/13/2015
我知道这种方法的动机可能只是为了解决 IDE,但您给出了关于定义函数时冗余的合理论据。为什么这不是默认的实现方法?这只是 C++ 哲学的问题,还是偶然成为最常见的哲学?除了类本身之外,其他人会调用的常见场景吗?friendfriendswap
2赞 Mark Ransom 11/22/2016
@VillasV看到 stackoverflow.com/questions/5695548/......
54赞 Tony Delroy 3/6/2014 #4

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

什么是复制和交换成语?

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

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

其基本思想是:

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

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

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

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

  • 当您希望被分配对象不受引发异常的赋值的影响时,假设您有或可以编写具有强异常的保证,理想情况下不会失败/..†swapthrow

  • 当您想要一种干净、易于理解、可靠的方法来根据(更简单的)复制构造函数和析构函数来定义赋值运算符时。swap

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

  • 当在分配期间使用额外的临时对象而造成的任何性能损失或暂时更高的资源使用率对应用程序来说并不重要时。⁂

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

@wilhelmtell:在 C++03 中,没有提到 std::string::swap(由 std::swap 调用)可能引发的异常。在 C++0x 中,std::string::swap 是 noexcept 并且不得抛出异常。– 詹姆斯·麦克内利斯 Dec 22 '10 at 15:24


‡ 从不同对象进行赋值时,赋值运算符实现看起来很合理,但自赋值很容易失败。虽然客户端代码甚至会尝试自赋值似乎难以想象,但在容器上的算法操作中,它可以相对容易地发生,其中代码(可能仅适用于某些分支)宏 ala 或函数返回对 的引用,甚至(可能效率低下但简洁)代码,例如 )。例如:x = f(x);f#ifdef#define f(x) xxx = 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 指向一个新分配的堆区域,然后尝试读取其中未初始化的数据(定义行为),如果这没有做任何太奇怪的事情,则尝试对每个刚刚被破坏的“T”进行自赋值!x.p_;p_copy


⁂ 复制和交换习惯用法可能会由于使用额外的临时函数而引入低效率或限制(当运算符的参数是复制构造的):

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

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

评论

4赞 Tony Delroy 10/21/2014
也就是说,套接字连接只是一个例子 - 同样的原则适用于任何可能昂贵的初始化,例如硬件探测/初始化/校准、生成线程池或随机数、某些加密任务、缓存、文件系统扫描、数据库连接等。
0赞 user362515 2/14/2015
还有一个(大规模)骗局。从技术上讲,根据当前规范,该对象将没有移动赋值运算符!如果以后用作类的成员,则新类将不会自动生成 move-ctor!来源:youtu.be/mYrbivnruYw?t=43m14s
3赞 sbi 7/22/2015
复制赋值运算符的主要问题是不禁止赋值。Client
0赞 John Z. Li 5/3/2019
在客户端示例中,该类应设置为不可复制。
21赞 Kerrek SB 6/24/2014 #5

当您处理 C++ 样式的分配器感知容器时,我想补充一句警告。交换和赋值具有细微不同的语义。

具体来说,让我们考虑一个容器,其中是一些有状态的分配器类型,我们将比较以下函数: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);
}

这两个函数的目的都是给出最初具有的状态。然而,有一个隐藏的问题:如果会发生什么?答案是:视情况而定。让我们写.fsfmaba.get_allocator() != b.get_allocator()AT = std::allocator_traits<A>

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

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

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

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

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