什么是copy-and-swap惯用语?

2369
什么是copy-and-swap惯用语法,什么时候应该使用它?它解决了哪些问题?在C++11中有什么变化吗?
相关内容:
- [你最喜欢的C++编码风格惯用语法:Copy-swap](link1) - [C++中的拷贝构造函数和=运算符重载:是否可能有一个通用函数?](link2) - [什么是拷贝省略,它如何优化copy-and-swap惯用语法?](link3) - [C++:动态分配对象数组?](link4)

9
从Herb Sutter的 http://gotw.ca/gotw/059.htm 返回已翻译的文本。 - DumbCoder
4
太棒了,我从我的有关移动语义的回答中链接了这个问题。 - fredoverflow
4
有一个完整的解释对于这个成语来说是个好主意,因为它非常普遍,每个人都应该知道它。 - Matthieu M.
30
警告:复制/交换习惯用法比它实际有用的情况要多得多。当从复制赋值中不需要强异常安全保证时,它通常会对性能造成负面影响。而当需要从复制赋值获得强异常安全时,通过一个简短的通用函数很容易提供此功能,同时也比复制赋值运算符更快。请见http://www.slideshare.net/ripplelabs/howard-hinnant-accu2014幻灯片43-53。总结:复制/交换是工具箱中的一个有用工具。但它已经被过度营销,因此经常被滥用。 - Howard Hinnant
3
@HowardHinnant:是的,赞同这一点。我写这篇文章的时候,几乎每个C++问题都是“当我复制它时我的类会崩溃,请帮忙”,而这就是我的回应。当你只想要可用的复制/移动语义或其他东西,以便可以转向其他事情时,这很合适,但并不是最优选择。如果你认为加上免责声明会有所帮助,那就随意在我的回答顶部添加。 - GManNickG
1
感谢@GManNickG。现在这些幻灯片的视频演示在这里可用:http://www.youtube.com/watch?v=vLinb2fgkHk&t=35m30s - Howard Hinnant
5个回答

2559

概述

为什么需要复制并交换惯用语?

任何管理资源(像智能指针这样的wrapper)的类都需要实现三大函数。虽然复制构造函数和析构函数的目标和实现很简单,但是复制赋值运算符可能是最微妙和最困难的。应该如何执行它?需要避免哪些陷阱?

复制并交换惯用语就是解决方案,并优雅地帮助赋值运算符实现两个目标:避免代码重复和提供强异常保证

它是如何工作的?

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

为了使用复制并交换惯用语,我们需要三件事:一个可以正常工作的复制构造函数,一个可以正常工作的析构函数(两者都是任何包装器的基础,因此应该完整),以及一个swap函数。

一个交换函数是一个不抛出异常的函数,它按成员逐个交换两个类的对象。我们可能会想使用std::swap而不是提供我们自己的函数,但这是不可能的;std::swap在其实现中使用复制构造函数和复制赋值运算符,最终我们将试图根据自身定义赋值运算符!(不仅如此,而且对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. The first is the self-assignment test.
    This check serves two purposes: it's an easy way to prevent us from running needless code on self-assignment, and it protects us from subtle bugs (such as deleting the array only to try and copy it). But in all other cases it merely serves to slow the program down, and act as noise in the code; self-assignment rarely occurs, so most of the time this check is a waste.
    It would be better if the operator could work properly without it.

  2. The second is that it only provides a basic exception guarantee. If new int[mSize] fails, *this will have been modified. (Namely, the size is wrong and the data is gone!)
    For a strong exception guarantee, it would need to be something akin to:

     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. The code has expanded! Which leads us to the third problem: code duplication.

我们的赋值运算符有效地复制了我们已经在其他地方编写的所有代码,这是一件可怕的事情。
在我们的情况下,它的核心只有两行(分配和复制),但对于更复杂的资源,这种代码膨胀可能会带来很多麻烦。我们应该努力避免重复自己。
(有人可能会想:如果管理一个资源需要这么多代码,那么如果我的类管理多个资源呢? 虽然这似乎是一个合理的关注点,并且确实需要非平凡的try/catch子句,但这不是问题。 那是因为一个类应该只管理一个资源!)
成功的解决方案
如上所述,复制并交换惯用语将解决所有这些问题。但现在,我们除了一个swap函数之外,已经具备了所有要求。尽管三法则成功地包含了我们的复制构造函数、赋值运算符和析构函数的存在,但它真的应该被称为“Three and A Half”:每当你的类管理一个资源时,提供一个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++的下一个版本,C++11,对我们如何管理资源做了一个非常重要的改变:三大法则现在成为四个半法则了。为什么?因为我们不仅需要能够复制构造我们的资源,我们还需要移动构造它
幸运的是,这很容易:
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正在使用rvalue进行初始化,它将会被move-constructed(移动构造)。太好了。与C++03以通过按值传递参数来重复使用我们的copy-constructor功能方式一样,C++11也会在适当的时候自动选择move-constructor。(当然,正如先前链接的文章中所提到的,值的复制/移动也可能完全不被执行。)
因此,拷贝并交换惯用法就这样结束了。

脚注

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

†还有其他说法认为我们应该专门针对我们的类型进行std::swap的特化,提供一个类内的swap和一个自由函数swap等等。但这些都是不必要的:任何正确使用swap的方式都将通过未限定的调用进行,而我们的函数将通过ADL被找到。只需要一个函数。

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

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


21
@GMan:我认为管理多个资源的类注定会失败(异常安全性变得非常困难),我强烈建议要么一个类管理一个资源,要么它具有业务功能并使用管理器。 - Matthieu M.
29
为什么这里要将swap方法声明为友元? - szx
10
有括号时,数组元素将被默认初始化;没有括号,它们将不被初始化。由于在复制构造函数中我们将覆盖这些值,因此可以跳过初始化。 - GManNickG
11
如果你希望在大多数通用代码中使用它(例如boost::swap和其他各种交换实例),那么需要在ADL期间找到您的swap函数。在C++中,交换是一个棘手的问题,通常我们都认为单个访问点最好(以确保一致性),而通常情况下实现这一点的唯一方法是使用自由函数(例如,int无法拥有交换成员)。请参阅我的问题了解一些背景信息。 - GManNickG
8
@BenHymers:是的。拷贝并交换惯用语只旨在以一般方式简化新资源管理类的创建。对于每个特定的类,几乎肯定有更有效的方法。这个惯用语只是可行且难以出错的东西。 - GManNickG
显示剩余64条评论

335
在其核心,赋值操作包括两个步骤:销毁对象的旧状态并将其新状态作为另一个对象状态的副本构建

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

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

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

1
我认为提到pimpl与提到copy、swap和destruction一样重要。swap并不是神奇的异常安全,它之所以是异常安全的,是因为交换指针是异常安全的。你不必使用pimpl,但如果不使用,那么你必须确保每个成员的交换都是异常安全的。当这些成员可以改变时,这可能会成为噩梦,而当它们被隐藏在pimpl后面时,这就是微不足道的。然后,就会出现pimpl的成本。这导致我们得出结论:异常安全通常会带来性能上的代价。 - wilhelmtell
7
std::swap(this_string, that) 不能提供无抛出保证,它提供了强异常安全性,但并不保证不抛出异常。 - wilhelmtell
12
在C++03中,没有提到std::swap调用的std::string::swap可能抛出异常。在C++0x中,std::string::swap被声明为noexcept且不能抛出异常。 - James McNellis
2
@sbi @JamesMcNellis 好的,但问题仍然存在:如果你有类类型的成员变量,你必须确保它们可以安全地进行交换。如果你只有一个指针成员变量那就很容易了,否则就不是这样。 - wilhelmtell
2
@wilhelmtell:我认为这就是交换的目的:它从不抛出异常,并且始终是O(1)(是的,我知道,std::array...) - sbi
显示剩余15条评论

54

已经有一些好的回答了,我主要关注它们缺少的内容- "cons"与复制并交换惯用语的解释...

什么是复制并交换惯用语?

这是一种通过交换函数实现赋值运算符的方法:

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

基本思想是:
- 分配给对象的最容易出错的部分是确保新状态需要的任何资源(例如内存、描述符)被获取。 - 如果创建新值的副本,那么在修改对象的当前状态(即`*this`)之前可以尝试获取该资源。这就是为什么通过值(即复制)而不是引用接受`rhs`的原因。 - 通常情况下,交换本地副本`rhs`和`*this`的状态很容易做到没有潜在的失败/异常,因为本地副本在之后不需要任何特定的状态(只需要适合析构函数运行的状态,就像在C++11中移动对象一样)。
什么时候应该使用它?(它解决了哪些问题?)
- 当你希望分配给对象不受抛出异常的赋值影响时,假设你有或可以编写具有强异常保证的`swap`,最好是一个无法失败/抛出异常的`swap`。 - 当您想要以(更简单的)复制构造函数、`swap`和析构函数来定义赋值运算符的干净、易于理解、健壮的方式时,自我赋值作为复制并交换避免了经常被忽视的边缘情况。 - 当在赋值期间有额外的临时对象不重要或资源使用短暂增加时,对于您的应用程序不重要。
`swap`抛出异常:通常可以可靠地交换对象跟踪的数据成员,但是没有无抛出异常的交换或者必须实现为`X tmp = lhs; lhs = rhs; rhs = tmp;`并且复制构造或分配可能会抛出异常的非指针数据成员仍有潜在的失败可能,导致一些数据成员被交换而另一些未被交换。即使在C++03中,`std::string`也存在这种潜在问题,正如James在另一个答案中所述。

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

4
话虽如此,套接字连接只是一个例子 - 相同的原则适用于任何可能昂贵的初始化操作,例如硬件探测/初始化/校准、生成线程池或随机数、特定的加密任务、缓存、文件系统扫描、数据库连接等等。 - Tony Delroy
还有一个(巨大的)缺点。根据当前规格,技术上该对象将没有移动赋值运算符!如果稍后用作类的成员,则新类将不会自动生成移动构造函数!来源:http://youtu.be/mYrbivnruYw?t=43m14s - user362515
3
Client的复制赋值运算符的主要问题是它没有被禁止赋值。 - sbi
在客户端示例中,应该将类设为不可拷贝。 - John Z. Li

28
这个回答更像是对上面回答的补充和微小修改。
在某些版本的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

这可能与调用了一个 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函数来交换两个对象。把swap变成一个成员函数并将另一个对象作为参数也是同样有道理的。

由于你已经可以访问到this对象,所以把它作为参数传递在技术上是多余的。


9
@GManNickG,这篇文章加上所有的图片和代码示例无法放在评论中。如果有人给它负评也没关系,我相信肯定还有其他人也遇到了同样的问题;这篇文章中的信息可能正是他们所需要的。 - Oleksiy
14
请注意,这只是IDE代码高亮(IntelliSense)中的一个错误。它会在没有警告/错误的情况下编译通过。 - Amro
3
如果您尚未报告过这个VS(Visual Studio)的错误,请在此处报告(如果它还没有被修复):https://connect.microsoft.com/VisualStudio - Matt
2
我理解这种方法的动机可能只是为了绕过IDE,但你提出了一个合理的关于定义friend函数中冗余的论点。为什么这不是默认的实现方法?这只是C++哲学的问题还是friend成为最常见的方法只是偶然?除了类本身之外,其他人调用swap的情况常见吗? - villasv
2
@VillasV 请查看https://dev59.com/NW025IYBdhLWcg3w960W - Mark Ransom
显示剩余4条评论

21

当与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_assignmentstd::true_type,则 fm 重新分配了 a 的分配器,值为 b.get_allocator(),否则不重新分配,并且 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 中的一个非常重要的操作。这是一个相当“高级的用例”,但并不完全不可能发生,因为移动优化通常只有在类管理资源且内存是最流行的资源时才变得有趣。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接