一尘不染

什么是复制和交换成语?

c++

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

尽管在很多地方都提到过,但我们没有任何单数的“它是什么”问题和答案,所以在这里。


阅读 319

收藏
2022-02-09

共1个答案

一尘不染

概述

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

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

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

它是如何工作的?

从概念上讲,它通过使用复制构造函数的功能来创建数据的本地副本,然后使用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).

  1. 首先是自我分配测试。
    这个检查有两个目的:它是一种简单的方法,可以防止我们在自赋值时运行不必要的代码,它可以保护我们免受细微的错误(例如删除数组只是为了尝试复制它)。但在所有其他情况下,它只会降低程序的速度,并在代码中充当噪音;自分配很少发生,所以大多数时候这种检查是浪费。
    如果操作员可以在没有它的情况下正常工作会更好。

  2. 二是它只提供了一个基本的异常保证。如果new int[mSize]失败,*this将被修改。(即,大小错误,数据丢失!)
    为了获得强大的异常保证,它需要类似于:

```cpp
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 intnewSize : 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;
}

```

  1. 代码扩展了!这就引出了第三个问题:代码重复。

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

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

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

一个成功的解决方案

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

就是这样!一口气,所有三个问题都被优雅地解决了。

为什么它有效?

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

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

    return *this;
}

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

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

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

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

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

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

那么 C++11 呢?

C 的下一个版本 C11 对我们管理资源的方式做出了一个非常重要的改变:三法则现在是四法则(半)。为什么?

幸运的是,这很容易:

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使用右值初始化,它将是 move-constructed。完美的。与 C03 允许我们通过按值获取参数来重用我们的复制构造函数功能相同,C11 也会在适当的时候自动选择移动构造函数。(当然,正如之前链接的文章中提到的,值的复制/移动可能会被完全省略。)

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


脚注

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

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

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

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

2022-02-09