题 什么是复制和交换习语?


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

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


1671
2017-07-19 08:42


起源


gotw.ca/gotw/059.htm 来自Herb Sutter - DumbCoder
太棒了,我把这个问题与我联系起来 回答移动语义。 - fredoverflow
对于这个成语有一个完整的解释,这是很常见的,每个人都应该知道它。 - Matthieu M.
警告:复制/交换习惯用法的使用频率远高于它的用途。当从复制分配中不需要强大的异常安全保证时,它通常对性能有害。当复制分配需要强大的异常安全性时,除了更快的复制赋值操作符之外,它还可以通过简短的通用函数轻松提供。看到 slideshare.net/ripplelabs/howard-hinnant-accu2014 幻灯片43 - 53.摘要:复制/交换是工具箱中的一个有用工具。但它已被过度销售,后来经常被滥用。 - Howard Hinnant
@HowardHinnant:是的,+1。我写这篇文章的时候,几乎每个C ++问题都是“帮助我的课程在复制时崩溃”,这就是我的回答。当你只想要工作的复制/移动语义或任何其他东西时,这是合适的,这样你就可以转向其他东西了,但这并不是最佳选择。如果您认为这有帮助,请随意在我的答案的顶部放置免责声明。 - GManNickG


答案:


概观

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

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

复制和交换习语 是解决方案,并优雅地协助分配操作员实现两件事:避免 代码重复并提供一个 强烈的例外保证

它是如何工作的?

概念,它通过使用复制构造函数的功能来创建数据的本地副本,然后使用a获取复制的数据 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 条款,这是一个非问题。那是因为一个班级应该管理 仅限一个资源!)

成功的解决方案

如上所述,复制和交换习惯用法将解决所有这些问题。但是现在,除了一个以外我们有所有要求:a 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;
}

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

它为什么有效?

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

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

    return *this;
}

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

无论哪种方式,这种获取资源的方法是消除代码重复的关键:我们可以使用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 用rvalue初始化, 它将是移动构造的。完善。以同样的方式,C ++ 03让我们通过参数by-value重用我们的拷贝构造函数,C ++ 11将 自动 在适当的时候选择move-constructor。 (当然,正如先前链接的文章中所提到的,复制/移动值可能完全被省略。)

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


脚注

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

†还有其他声称我们应该专攻 std::swap 对于我们的类型,提供一个课堂 swap 与自由功能并驾齐驱 swap等等。但这都是不必要的:任何正确使用 swap 将通过一个不合格的电话,我们的功能将通过 ADL。一个功能就可以了。

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


1841
2017-07-19 08:43



@GMan:我认为一个管理多个资源的类注定会失败(异常安全变得噩梦),我强烈建议一个类管理一个资源,或者它具有业务功能并使用管理器。 - Matthieu M.
@FrEEzE:“这是编译器特定的处理列表的顺序。”不,这不对。它按照它们在类定义中出现的顺序进行处理。不接受的编译器 std::copy 那种方式被破坏了,我不是为破碎的编译器编码。而且我不确定我理解你的上一条评论。 - GManNickG
@Freeze:此外,这个答案的重点是谈论一个C ++习语。如果您需要破解您的程序以使用不合规的编译器,那很好,但不要试图表现得像我的责任或者是“不好的做法”,请不要。 - GManNickG
我不明白为什么交换方法在这里被宣布为朋友? - szx
@neuviemeporte:你需要你的 swap 如果你想让它在你会遇到的大多数通用代码中工作,就可以在ADL期间找到它们 boost::swap 和其他各种交换实例。交换在C ++中是一个棘手的问题,通常我们都同意单点访问是最好的(为了一致性),一般来说,唯一的方法就是自由函数(int 例如,不能有交换成员。看到 我的问题 对于某些背景。 - GManNickG


作业的核心是两个步骤: 撕毁对象的旧状态 和 建立一个新的国家作为副本 一些其他对象的状态。

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

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

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

227
2017-07-19 08:55



我认为提及pimpl与提及副本,交换和破坏一样重要。交换不是神奇的异常安全。它是异常安全的,因为交换指针是异常安全的。你没有 有 使用pimpl,但如果不这样做,则必须确保成员的每次交换都是异常安全的。当这些成员可以改变时,这可能是一场噩梦,当他们隐藏在一个pimpl背后时,这是微不足道的。然后,然后是pimpl的成本。这使我们得出的结论是,异常安全通常会带来性能成本。 - wilhelmtell
std::swap(this_string, that) 不提供罚球保证。它提供强大的异常安全性,但不是无投掷保证。 - wilhelmtell
@wilhelmtell:在C ++ 03中,没有提到可能抛出的异常 std::string::swap (被称为 std::swap)。在C ++ 0x中, std::string::swap 是 noexcept 并且不得抛出异常。 - James McNellis
@sbi @JamesMcNellis确定,但问题仍然存在:如果你有类型成员,你必须确保交换它们是一个没有投掷。如果你有一个指针的成员,那么这是微不足道的。否则就不是。 - wilhelmtell
@wilhelmtell:我认为那是交换点:它永远不会抛出,它总是O(1)(是的,我知道, std::array...) - sbi


已经有一些好的答案了。我会专注 主要 我认为他们缺乏什么 - 用复制和交换习语解释“缺点”......

什么是复制和交换习语?

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

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

基本思想是:

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

  • 可以尝试获取 之前 修改对象的当前状态(即 *this)如果制作了新值的副本,这就是原因 rhs 被接受了 按价值 (即复制)而不是 引用

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

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

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

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

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

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

swap throw:通常可以通过指针可靠地交换对象跟踪的数据成员,但是没有无抛出交换的非指针数据成员,或者必须实现交换的非指针数据成员 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,不能抛出异常。 - 詹姆斯麦克尼利斯2010年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_);
    }
    ...
};

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


32
2018-03-06 14:51



也就是说,套接字只是一个例子 - 同样的原则适用于任何可能很昂贵的初始化,例如硬件探测/初始化/校准,生成线程池或随机数,某些加密任务,高速缓存,文件系统扫描,数据库连接等.. - Tony Delroy
还有一个(大规模)骗局。截至目前的规格 技术上 对象会 没有移动赋值运算符! 如果以后用作类的成员,则为新类 不会有自动生成的move-ctor! 资源: youtu.be/mYrbivnruYw?t=43m14s - user362515
复制赋值运算符的主要问题 Client 是不允许任务。 - sbi


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

在某些版本的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

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


解决这个问题的方法是不使用 friend 关键字并重新定义 swap 功能:

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 对象,因此将其作为参数传递在技术上是多余的。


19
2017-09-04 04:50



你能分享你重现错误的例子吗? - GManNickG
@GManNickG dropbox.com/s/o1mitwcpxmawcot/example.cpp  dropbox.com/s/jrjrn5dh1zez5vy/Untitled.jpg。这是一个简化版本。每次出现错误似乎都会发生 friend 调用函数 *this 参数 - Oleksiy
@GManNickG它不适合所有图像和代码示例的注释。如果人们投票,那就没关系,我确信那里有人会得到同样的错误;这篇文章中的信息可能正是他们所需要的。 - Oleksiy
请注意,这只是IDE代码突出显示(IntelliSense)中的一个错误...它将编译得很好,没有警告/错误。 - Amro
如果您还没有这样做,请报告VS错误(如果尚未修复) connect.microsoft.com/VisualStudio - Matt


当您处理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);
}

这两个功能的目的 fs 和 fm 是给 a 那个州 b 最初。但是,有一个隐藏的问题:如果发生了什么 a.get_allocator() != b.get_allocator()?答案是:这取决于。让我们写 AT = std::allocator_traits<A>

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

  • 如果 AT::propagate_on_container_swap 是 std::true_type, 然后 fs 以预期的方式交换数据和分配器。

  • 如果 AT::propagate_on_container_swap 是 std::false_type,那么我们需要动态检查。

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

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


10
2018-06-24 08:16