c++ - 什么是 copy-and-swap 习语?

标签 c++ copy-constructor assignment-operator c++-faq copy-and-swap

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

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

  • What are your favorite C++ Coding Style idioms: Copy-swap
  • Copy constructor and = operator overload in C++: is a common function possible?
  • What is copy elision and how it optimizes copy-and-swap idiom
  • C++: dynamically allocating an array of objects?
  • 最佳答案

    概述
    为什么我们需要 copy-and-swap 习语?
    任何管理资源的类(包装器,如智能指针)都需要实现 The Big Three 。虽然复制构造函数和析构函数的目标和实现很简单,但复制赋值运算符可以说是最微妙和最困难的。应该怎么做?需要避免哪些陷阱?
    copy-and-swap 习语是解决方案,它优雅地协助赋值运算符实现两件事:避免 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)
  • 第一个是自赋值测试。
    这个检查有两个目的:它是一种防止我们在自赋值时运行不必要代码的简单方法,它保护我们免受细微的错误(例如删除数组只是为了尝试和复制它)。但在所有其他情况下,它只会减慢程序速度,并在代码中充当噪音;自赋值很少发生,所以大部分时间这个检查都是浪费。
    如果运算符(operator)没有它也能正常工作就更好了。
  • 二是它只提供基本的异常保证。如果 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;
     }
    
  • 代码已展开!这就引出了第三个问题:代码重复。

  • 我们的赋值运算符有效地复制了我们已经在别处编写的所有代码,这是一件可怕的事情。
    在我们的例子中,它的核心只有两行(分配和复制),但是对于更复杂的资源,这个代码膨胀可能会很麻烦。我们应该努力不再重蹈覆辙。
    (有人可能会想:如果正确管理一个资源需要这么多代码,如果我的类管理多个资源怎么办?
    虽然这似乎是一个有效的问题,实际上它需要非平凡的 try/catch 子句,但这不是问题。
    那是因为一个类应该管理 one resource only !)
    一个成功的解决方案
    如前所述, copy-and-swap 习语将解决所有这些问题。但是现在,除了一个 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);
        }
    
        // ...
    };
    
    ( Here 是为什么 public friend swap 的解释。)现在我们不仅可以交换我们的 dumb_array ,而且交换通常可以更有效;它只是交换指针和大小,而不是分配和复制整个数组。除了功能和效率方面的这一优势外,我们现在准备实现 copy-and-swap 习语。
    闲话少说,我们的赋值运算符是:
    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 是非 throw 的。我们用复制的数据交换当前数据,安全地改变我们的状态,旧数据被放入临时数据。然后在函数返回时释放旧数据。 (在参数的范围结束并调用其析构函数时。)
    因为习语没有重复代码,所以我们不能在操作符中引入错误。请注意,这意味着我们不再需要自赋值检查,从而允许 operator= 的单一统一实现。 (此外,我们不再对非自我分配有性能损失。)
    这就是 copy-and-swap 习语。
    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 将能够在交换后执行相同的操作。
    (请注意,有些编译器不支持构造函数委托(delegate);在这种情况下,我们必须手动默认构造类。这是一项不幸但幸运的微不足道的任务。)
    为什么这样做?
    这是我们需要对类进行的唯一更改,那么它为什么会起作用呢?记住我们做出的让参数成为值而不是引用的重要决定:
    dumb_array& operator=(dumb_array other); // (1)
    
    现在,如果 other 用右值初始化,它将是移动构造的。完美的。与 C++03 让我们通过按值获取参数来重用我们的复制构造函数功能一样,C++11 也会在适当的时候自动选择移动构造函数。 (当然,正如之前链接的文章中提到的,值的复制/移动可能会被完全省略。)
    copy-and-swap 习语到此结束。

    脚注
    *为什么我们将 mArray 设置为 null?因为如果运算符中的任何其他代码抛出,则可能会调用 dumb_array 的析构函数;如果发生这种情况而未将其设置为 null,我们将尝试删除已删除的内存!我们通过将其设置为 null 来避免这种情况,因为删除 null 是一种无操作。
    †还有其他说法,我们应该为我们的类型专门化 std::swap,提供类内 swap 以及自由函数 swap 等。但这都是不必要的:任何正确使用 swap 都将通过不合格的调用我们的函数将通过 ADL 找到。一个功能就行。
    ‡原因很简单:一旦您拥有自己的资源,您就可以将其交换和/或移动 (C++11) 到任何需要的地方。通过在参数列表中进行复制,您可以最大限度地优化。
    ††移动构造函数通常应为 noexcept ,否则即使移动有意义,某些代码(例如 std::vector 调整大小逻辑)也会使用复制构造函数。当然,只有在里面的代码没有抛出异常的情况下才标记为noexcept。

    关于c++ - 什么是 copy-and-swap 习语?,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/3279543/

    相关文章:

    C++ 类 : read value behave as different types

    c++ - 具有直接 QueryPerformanceCounter 值的时钟是否符合 C++ 标准?

    c++ - 使用匿名对象时,默认构造函数和复制构造函数都不会被调用

    C++编译器如何合成默认的拷贝构造函数

    c++ - 自定义复制赋值运算符使程序崩溃(C++)

    C++声明自定义类对象使用=赋值初始化的过程是怎样的?

    c++ - 在 C++ 中,从 int 到 object 的赋值怎么可能?

    c++ - 内存集不工作

    c++ - 使用 g++ 使用 NULL const char* 避免不正确的 std::string 初始化

    c++ - C++比较 vector 和列表