ChatGPT解决这个技术问题 Extra ChatGPT

什么是复制和交换成语?

什么是复制和交换习语,什么时候应该使用它?它解决了哪些问题?对于 C++11,它会改变吗?

有关的:

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

C ++中的复制构造函数和=运算符重载:通用函数可能吗?

什么是复制省略以及它如何优化复制和交换习语

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

gotw.ca/gotw/059.htm 来自 Herb Sutter
太棒了,我从我的 answer to move semantics 链接了这个问题。
对这个成语有一个完整的解释是个好主意,它很常见,每个人都应该知道。
警告:复制/交换习语的使用频率远远超过它的用处。当副本分配不需要强大的异常安全保证时,通常会对性能有害。当复制赋值需要强大的异常安全性时,除了更快的复制赋值运算符外,还可以通过一个简短的泛型函数轻松提供。请参阅 slideshare.net/ripplelabs/howard-hinnant-accu2014 幻灯片 43 - 53。摘要:复制/交换是工具箱中的一个有用工具。但它被过度推销,随后经常被滥用。
@HowardHinnant:是的,+1。我写这篇文章的时候几乎每个 C++ 问题都是“帮助我的班级在复制时崩溃”,这是我的回应。当您只想要工作复制/移动语义或其他任何东西以便您可以继续其他事情时,它是合适的,但这并不是真正的最佳选择。如果您认为这会有所帮助,请随时在我的答案顶部放置免责声明。

J
Jack Lilhammers

概述

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

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

copy-and-swap idiom 是解决方案,它优雅地帮助赋值运算符实现两件事:避免 code duplication 和提供 strong exception guarantee

它是如何工作的?

Conceptually,它通过使用复制构造函数的功能来创建数据的本地副本,然后使用 swap 函数获取复制的数据,将旧数据与新数据交换。然后临时副本销毁,并带走旧数据。我们留下了新数据的副本。

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

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

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

首先是自我分配测试。这个检查有两个目的:它是一种防止我们在自赋值时运行不必要的代码的简单方法,它可以保护我们免受细微的错误(例如删除数组只是为了尝试复制它)。但在所有其他情况下,它只会降低程序的速度,并在代码中充当噪音;自分配很少发生,所以大多数时候这种检查是浪费。如果操作员可以在没有它的情况下正常工作会更好。二是它只提供了一个基本的异常保证。如果 new int[mSize] 失败,*this 将被修改。 (即,大小错误,数据丢失!)对于强异常保证,它需要类似于:dumb_array& operator=(constdumb_array& other) { if (this != &other) // (1) { // 在我们替换旧的 std::size_t newSize = other.mSize 之前准备好新数据; int* newArray = newSize ?新 int[newSize]() : nullptr; // (3) std::copy(other.mArray, other.mArray + newSize, newArray); // (3) // 替换旧数据(都是非抛出的) delete [] mArray; mSize = newSize; mArray = newArray; } 返回 *this;代码已扩展!这就引出了第三个问题:代码重复。

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

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

(有人可能想知道:如果需要这么多代码来正确管理一个资源,如果我的班级管理多个资源怎么办?
虽然这似乎是一个有效的问题,但确实需要不平凡的try/ catch 子句,这不是问题。
那是因为一个类应该管理 one resource only!)

一个成功的解决方案

如前所述,复制和交换习语将解决所有这些问题。但是现在,除了一个 swap 函数之外,我们拥有所有要求。虽然三法则成功地包含了我们的复制构造函数、赋值运算符和析构函数,但它确实应该被称为“三巨头半”:任何时候你的类管理资源时,提供一个 { 1}功能。

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

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);
    }

    // ...
};

Herepublic 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;
}

我们失去了一个 important optimization opportunity。不仅如此,这种选择在 C++11 中也很关键,后面会讨论。 (一般来说,一个非常有用的指导方针如下:如果您要在函数中复制某些内容,请让编译器在参数列表中完成。‡)

无论哪种方式,这种获取资源的方法都是消除代码重复的关键:我们可以使用来自复制构造函数的代码进行复制,而无需重复任何部分。既然副本已经制作好了,我们就可以进行交换了。

请注意,在进入函数时,所有新数据都已分配、复制并准备好使用。这就是免费为我们提供强大的异常保证的原因:如果副本构造失败,我们甚至不会进入函数,因此不可能更改 *this 的状态。 (我们之前手动为强异常保证所做的工作,现在编译器正在为我们做;怎么样。)

在这一点上,我们是无家可归的,因为 swap 是非投掷的。我们将当前数据与复制的数据交换,安全地更改我们的状态,并将旧数据放入临时数据中。当函数返回时,旧数据被释放。 (参数的作用域在哪里结束并调用它的析构函数。)

因为成语没有重复代码,所以我们不能在操作符中引入错误。请注意,这意味着我们不再需要自分配检查,从而允许 operator= 的单一统一实现。 (此外,我们不再对非自我分配进行性能惩罚。)

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

那么 C++11 呢?

C++ 的下一个版本 C++11 对我们管理资源的方式做出了一个非常重要的改变:三法则现在是四法则(半)。为什么?因为我们不仅需要能够复制构建我们的资源,we need to move-construct it as well

幸运的是,这很容易:

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 也会在适当的时候自动选择移动构造函数。 (当然,正如之前链接的文章中提到的,值的复制/移动可能会被完全省略。)

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

脚注

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

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

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

††移动构造函数通常应为 noexcept,否则即使移动有意义,某些代码(例如 std::vector 调整大小逻辑)也会使用复制构造函数。当然,只有在里面的代码没有抛出异常的情况下才标记为 noexcept 。


@GMan:我认为一次管理多个资源的类注定要失败(异常安全变得像噩梦一样),我强烈建议一个类管理一个资源,或者它具有业务功能和使用管理器。
我不明白为什么交换方法在这里被声明为朋友?
@neuviemeporte:使用括号,数组元素默认初始化。没有,它们是未初始化的。因为在复制构造函数中我们无论如何都会覆盖这些值,所以我们可以跳过初始化。
@neuviemeporte:如果您希望 swap 在您会遇到的大多数通用代码中工作,您需要在 ADL 期间找到您的 swap,例如 boost::swap 和其他各种交换实例。交换是 C++ 中的一个棘手问题,通常我们都同意单点访问是最好的(为了保持一致性),而通常唯一的方法是使用自由函数(int 不能有例如交换成员)。有关背景信息,请参阅 my question
@BenHymers:是的。复制和交换习语仅旨在以一般方式简化新资源管理类的创建。对于每个特定的班级,几乎可以肯定有一条更有效的路线。这个成语只是行之有效,而且很难做错。
s
sbi

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

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

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

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

我认为提及 pimpl 与提及复制、交换和销毁一样重要。交换并不是神奇的异常安全。它是异常安全的,因为交换指针是异常安全的。您不必使用 pimpl,但如果不使用,则必须确保成员的每个交换都是异常安全的。当这些成员可以改变时,这可能是一场噩梦,而当他们隐藏在一个粉刺后面时,这将是微不足道的。然后,然后是粉刺的成本。这使我们得出结论,异常安全通常会带来性能成本。
std::swap(this_string, that) 不提供不投掷保证。它提供了强大的异常安全性,但不是不抛出保证。
@wilhelmtell:在 C++03 中,没有提到 std::string::swap(由 std::swap 调用)可能引发的异常。在 C++0x 中,std::string::swapnoexcept,并且不能抛出异常。
@sbi @JamesMcNellis 好的,但重点仍然存在:如果您有 class-type 的成员,则必须确保交换它们是禁止的。如果您有一个作为指针的成员,那么这很简单。否则不是。
@wilhelmtell:我认为这就是交换的重点:它从不抛出并且总是 O(1)(是的,我知道,std::array...)
T
Tony Delroy

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

什么是复制和交换成语?

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

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

基本思想是:

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

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

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

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

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

当您想要一种干净、易于理解、健壮的方式来根据(更简单的)复制构造函数、交换和析构函数定义赋值运算符时。作为复制和交换完成的自我分配避免了经常被忽视的边缘情况。‡

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

当在分配期间有一个额外的临时对象造成的任何性能损失或暂时更高的资源使用对您的应用程序不重要时。 ⁂

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

@wilhelmtell:在 C++03 中,没有提及 std::string::swap (由 std::swap 调用)可能引发的异常。在 C++0x 中,std::string::swap 是 noexcept 并且不能抛出异常。 – 詹姆斯麦克内利斯 2010 年 12 月 22 日 15:24

‡ 当从一个不同的对象进行赋值时看起来很正常的赋值运算符实现很容易因自赋值而失败。虽然客户端代码甚至会尝试自分配似乎是不可想象的,但在容器上的算法操作期间它可以相对容易地发生,其中 f 是(可能仅适用于某些 #ifdef 分支)宏 ala { 的 x = f(x); 代码4} 或返回对 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 相同的服务器(如果有用,可能会发送“重置”代码),而复制和交换方法将调用复制- 构造函数,可能会被编写为打开一个不同的套接字连接,然后关闭原始套接字。这不仅意味着远程网络交互而不是简单的进程内变量复制,它还可能违反客户端或服务器对套接字资源或连接的限制。 (当然这个类有一个非常可怕的界面,但那是另一回事;-P)。


也就是说,套接字连接只是一个例子——同样的原理适用于任何可能昂贵的初始化,例如硬件探测/初始化/校准、生成线程池或随机数、某些加密任务、缓存、文件系统扫描、数据库连接等。
还有一个(大量)骗局。按照当前规范技术上,该对象将没有移动赋值运算符!如果以后用作类的成员,新类将没有移动- ctor 自动生成! 来源:youtu.be/mYrbivnruYw?t=43m14s
Client 的复制赋值运算符的主要问题是不禁止赋值。
在客户端示例中,应使该类不可复制。
O
Oleksiy

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

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

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

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

}

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

https://i.stack.imgur.com/OwEKE.jpg

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

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

void swap(A& other) {

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

}

这一次,您只需调用 swap 并传入 other,从而使编译器满意:

https://i.stack.imgur.com/7XdNa.jpg

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

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


@GManNickG dropbox.com/s/o1mitwcpxmawcot/example.cpp dropbox.com/s/jrjrn5dh1zez5vy/Untitled.jpg。这是一个简化版本。每次使用 *this 参数调用 friend 函数时似乎都会发生错误
@GManNickG 它不适合包含所有图像和代码示例的评论。如果人们投反对票也没关系,我敢肯定有人遇到了同样的错误;这篇文章中的信息可能正是他们所需要的。
请注意,这只是 IDE 代码突出显示 (IntelliSense) 中的一个错误...它将编译得很好,没有警告/错误。
如果您尚未报告 VS 错误(如果尚未修复),请在此处报告 VS 错误connect.microsoft.com/VisualStudio
K
Kerrek SB

当您处理 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_assignment 是 std::true_type,则 fm 用 b.get_allocator() 的值重新分配 a 的分配器,否则不分配,并且 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]。

如果 a.get_allocator() == b.get_allocator(),那么这两个容器使用兼容的存储,并且交换以通常的方式进行。

但是,如果 a.get_allocator() != b.get_allocator(),则程序具有未定义的行为(参见 [container.requirements.general/8]。

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