关于C++:什么是复制和交换习惯用法?

What is the copy-and-swap idiom?

这个成语是什么?什么时候用?它能解决哪些问题?当使用C++ 11时,成语会发生变化吗?

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

  • 你最喜欢的C++编码风格成语:拷贝交换
  • C++中复制构造函数和=运算符重载:可能的公共函数吗?
  • 什么是复制省略以及它如何优化复制和交换习惯用法
  • C++:动态分配对象数组?


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

任何管理资源的类(包装器,如智能指针)都需要实现三大类。尽管复制构造函数和析构函数的目标和实现很简单,但复制分配操作符可以说是最细微和最困难的。怎么做?需要避免哪些陷阱?好的。

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

从概念上讲,它通过使用复制构造函数的功能来创建数据的本地副本,然后使用swap函数获取复制的数据,用新数据交换旧数据。然后,临时副本将销毁,并带走旧数据。我们只剩下一份新数据的副本。好的。

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

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

(不仅如此,对swap的无条件调用将使用我们的自定义交换运算符,跳过std::swap将导致的不必要的类构造和破坏。)好的。深入的解释目标

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include  // 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=才能正常工作。好的。失败的解决方案

下面是一个幼稚的实现的样子:好的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 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)。好的。

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

  • 第二,它只提供基本的例外保证。如果new int[mSize]失败,*this将被修改。(即大小错误,数据丢失!)为了获得强有力的例外保证,它需要类似于:好的。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    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;
    }
  • 代码已扩展!这就导致了第三个问题:代码复制。我们的赋值操作符有效地复制了我们已经在别处编写的所有代码,这是一件可怕的事情。好的。

  • 在我们的例子中,它的核心仅仅是两行(分配和复制),但是对于更复杂的资源,这种代码膨胀会非常麻烦。我们应该努力不让自己重蹈覆辙。好的。

    (有人可能会想:如果正确管理一个资源需要这么多代码,那么如果我的类管理多个资源呢?虽然这似乎是一个值得关注的问题,而且确实需要非常重要的trycatch条款,但这不是问题。这是因为一个类应该只管理一个资源!)好的。一个成功的解决方案

    如前所述,复制和交换习语将解决所有这些问题。但是现在,除了一个:swap函数之外,我们还有所有的需求。虽然"三"规则成功地要求存在我们的复制构造函数、赋值运算符和析构函数,但它实际上应该被称为"三大半":每当您的类管理资源时,提供swap函数也是有意义的。好的。

    我们需要将交换功能添加到我们的类中,并按如下所示进行操作?:好的。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    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,而且交换通常更有效;它只是交换指针和大小,而不是分配和复制整个数组。除了在功能和效率上的额外好处,我们现在已经准备好实现复制和交换习惯用法。好的。

    无需进一步说明,我们的赋值运算符是:好的。

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

        return *this;
    }

    就这样!一举解决了这三个问题。好的。为什么有效?

    我们首先注意到一个重要的选择:参数参数是按值取的。虽然一个人可以很容易地做到以下几点(实际上,许多幼稚的成语实现都是这样做的):好的。

    1
    2
    3
    4
    5
    6
    7
    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对我们如何管理资源做出了一个非常重要的改变:三的规则现在是四(半)的规则。为什么?因为我们不仅需要能够复制构造我们的资源,还需要移动构造它。好的。

    幸运的是,这很容易:好的。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    class dumb_array
    {
    public:
        // ...

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

        // ...
    };

    这是怎么回事?回想一下move construction的目标:从类的另一个实例中获取资源,使其处于保证可分配和可销毁的状态。好的。

    因此,我们所做的很简单:通过默认构造函数(C++ 11特性)初始化,然后与EDCOX1(7)进行交换;我们知道我们的类的默认构造实例可以被安全地分配和销毁,因此我们知道EDCOX1×7将在交换之后也能这样做。好的。

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

    这是我们班上唯一需要做的改变,那为什么它起作用呢?请记住,我们做出的重要决定是将参数设为一个值,而不是一个引用:好的。

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

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

    复制和交换习语到此结束。好的。脚注

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

    ?还有其他的说法,我们应该为我们的类型专门开发std::swap,在类内提供swap边上提供一个自由函数swap等,但这都是不必要的:任何正确使用swap都将通过一个不合格的调用,我们的功能将通过adl找到。一个功能就可以了。好的。

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


    任务的核心是两个步骤:分解对象的旧状态,并将其新状态构建为其他对象状态的副本。

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

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

    1
    2
    3
    4
    5
    T& operator=(T tmp)
    {
        this->swap(tmp);
        return *this;
    }


    已经有了一些很好的答案。我将主要集中在我认为他们缺少的东西上——用复制和交换习语解释"缺点"……

    What is the copy-and-swap idiom?

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

    1
    2
    3
    4
    5
    X& operator=(X rhs)
    {
        swap(rhs);
        return *this;
    }

    基本理念是:

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

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

    • 如果本地副本不需要任何特定的状态(只需要析构函数的状态适合运行,就如同从In=C++=11中移动的对象)一样,交换本地副本EDCOX1、1和EDCOX1(0)通常是相对容易的,没有潜在的故障/异常。

    When should it be used? (Which problems does it solve [/create]?)

    • 如果您希望被分配给不受引发异常的分配影响的对象,假设您已经或能够编写一个具有强异常保证的swap,并且理想情况下是一个不能失败的/throw…?

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

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

    • 如果在分配期间拥有额外的临时对象而造成的任何性能损失或暂时更高的资源使用率对应用程序不重要。?

    swap抛出:通常可以可靠地交换对象按指针跟踪的数据成员,但非指针数据成员不具有可抛出交换,或者交换必须作为X tmp = lhs; lhs = rhs; rhs = tmp;实现,复制构造或分配可能抛出,仍然有可能导致某些数据成员交换失败。艾德和其他人没有。这一潜力甚至适用于C++ 03 EDCOX1,9,杰姆斯评论另一个答案:

    @wilhelmtell: In C++03, there is no mention of exceptions potentially thrown by std::string::swap (which is called by std::swap). In C++0x, std::string::swap is noexcept and must not throw exceptions. – James McNellis Dec 22 '10 at 15:24

    ?从不同对象分配时看起来正常的分配运算符实现很容易因自分配而失败。虽然客户机代码甚至尝试自我分配似乎是不可想象的,但在容器上的algo操作过程中,这种情况比较容易发生,在x = f(x);代码中,f是一个宏ala #define f(x) x或返回对x的引用的函数,甚至(likely低效但简洁)代码,如x = c1 ? x * 2 : c2 ? x / 2 : x;。例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    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'进行自分配!

    ?"复制和交换"习惯用法可能会由于使用了额外的临时参数(当运算符的参数是"复制构造"时)而导致效率低下或限制:

    1
    2
    3
    4
    5
    6
    7
    8
    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)。


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

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

    1
    2
    3
    4
    5
    6
    friend void swap(A& first, A& second) {

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

    }

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

    enter image description here

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

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

    1
    2
    3
    4
    5
    6
    void swap(A& other) {

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

    }

    这一次,您只需调用swap并传入other,就可以让编译器满意:

    enter image description here

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

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


    当你处理C++ 11风格的分配器知道容器时,我想添加一个警告词。交换和分配有着微妙的不同语义。

    为了具体起见,让我们考虑一个容器std::vector,其中A是某种状态分配器类型,我们将比较以下函数:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    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::propagate_on_container_move_assignmentstd::true_type,那么fmb.get_allocator()的值重新分配A的分配器,否则不重新分配,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中的一个非平凡操作。这是一个有点"高级用例",但并非完全不可能,因为移动优化通常只有在类管理资源时才会变得有趣,而内存是最流行的资源之一。