复制和交换习惯用法是什么?何时使用?它解决了什么问题?对于C++11它会改变吗?

相关:

你最喜欢的C++编码风格习惯用法是什么:复制交换复制C++中的构造函数和=运算符重载:是否可以使用公共函数?什么是复制省略以及它如何优化复制和交换习惯用法C++:动态分配对象数组?


当前回答

在处理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_allocater()!=b.get_allocater()?答案是:视情况而定。让我们写AT=std::allocator_traits<A>。

如果AT::propagate_on_container_move_assignment为std::true_type,则fm使用b.get_allocater()的值重新分配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_allorator(),则两个容器使用兼容的存储,并且交换以通常的方式进行。但是,如果a.get_allocater()!=b.get_allocater(),程序具有未定义的行为(参见[container.requirements.general/8])。

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

其他回答

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

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

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

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

}

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

这与被调用的友元函数以及作为参数传递的对象有关。


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

void swap(A& other) {

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

}

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


毕竟,您不需要使用朋友函数来交换两个对象。将swap设置为一个成员函数,该函数将一个其他对象作为参数,这同样有意义。

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

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

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

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

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

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

什么是复制和交换习语?

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

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

其基本思想是:

分配给对象最容易出错的部分是确保获取新状态所需的任何资源(例如内存、描述符)如果复制了新值,则可以在修改对象的当前状态(即*this)之前尝试获取,这就是rhs被值(即复制)而不是被引用接受的原因交换本地副本rhs和*的状态通常相对容易,没有潜在的故障/异常,因为本地副本之后不需要任何特定的状态(只需要适合析构函数运行的状态,就像在>=C++11中移动的对象一样)

何时使用?(它解决了[/产生]哪些问题?)

当您希望被赋值对象不受引发异常的赋值的影响时,假设您拥有或可以编写具有强异常保证的交换,并且理想情况下不会失败/抛出。†当您需要一种干净、易于理解、健壮的方法来根据(更简单的)复制构造函数、交换函数和析构函数来定义赋值运算符时。作为复制和交换完成的自我分配避免了经常被忽视的边缘情况。‡当在分配过程中有一个额外的临时对象而造成的任何性能损失或暂时更高的资源使用量对您的应用程序并不重要时。▼


†交换抛出:通常可以可靠地交换对象按指针跟踪的数据成员,但不具有无抛出交换的非指针数据成员,或者交换必须实现为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


‡从不同对象赋值时看似正常的赋值运算符实现很容易因自赋值而失败。虽然客户端代码甚至会尝试自分配似乎是不可想象的,但在容器上的algo操作期间,这种情况相对容易发生,x=f(x);其中f是(可能仅针对某些ifdef分支)宏ala定义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_,然后尝试读取其中未初始化的数据(未定义的行为),如果这没有做任何太奇怪的事情,则复制尝试对每个刚刚销毁的“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=可能会检查*这是否已经连接到与rhs相同的服务器(如果有用的话,可能会发送一个“重置”代码),而复制和交换方法将调用复制构造函数,该构造函数很可能被编写为打开一个不同的套接字连接,然后关闭原始套接字连接。这不仅意味着远程网络交互,而不是简单的进程内变量复制,还可能违反客户端或服务器对套接字资源或连接的限制。(当然,这个类有一个非常可怕的接口,但这是另一回事;-P)。

概述

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

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

复制和交换习惯用法就是解决方案,它巧妙地帮助赋值运算符实现两件事:避免代码重复,并提供强大的异常保证。

它是如何工作的?

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

为了使用复制和交换习惯用法,我们需要三样东西:一个工作的复制构造函数、一个工作析构函数(两者都是任何包装器的基础,因此无论如何都应该是完整的)和一个交换函数。

交换函数是一个非抛出函数,它将类的两个对象(成员对成员)交换。我们可能会尝试使用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;
};

这个类几乎成功地管理了数组,但它需要运算符=才能正常工作。

失败的解决方案

下面是一个幼稚的实现的外观:

// 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]失败,*这将被修改。(即,大小错误,数据丢失!)对于一个强有力的例外保证,它需要类似于:dumb_array&operator=(常量dumb_aArray&other){如果(this!=&other)//(1){//在替换旧数据之前准备好新数据std::size_t newSize=other.mSize;int*newArray=newSize?new int[newSize]():nullptr;//(3)std::copy(other.mArray,other.mArray+newSize,newArray);//(3)//替换旧数据(所有数据都是非抛出的)删除[]mArray;mSize=newSize;mArray=newArray;}返回*this;}代码已扩展!这就引出了第三个问题:代码重复。

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

在我们的例子中,它的核心只有两行(分配和复制),但对于更复杂的资源,代码膨胀可能会非常麻烦。我们应该努力避免重蹈覆辙。

(人们可能会想:如果正确管理一个资源需要这么多代码,那么如果我的类管理多个资源呢?虽然这似乎是一个合理的关注点,并且确实需要非平凡的try/catch子句,但这不是问题。这是因为一个类只能管理一个资源!)

成功的解决方案

如上所述,复制和交换习惯用法将解决所有这些问题。但现在,我们有了所有的需求,只有一个:交换函数。虽然“三法则”成功地实现了我们的复制构造函数、赋值运算符和析构函数的存在,但它真的应该被称为“三巨头半”:无论何时您的类管理资源,提供交换函数也是有意义的。

我们需要将交换功能添加到我们的类中,我们这样做†:

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

    // ...
};

(这是为什么公共朋友交换的原因。)现在,我们不仅可以交换我们的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的状态。(我们以前为保证异常的安全而手动执行的操作,现在编译器正在为我们做;多么好。)

在这一点上,我们是自由的,因为交换是非投掷的。我们用复制的数据交换当前的数据,安全地改变我们的状态,旧数据被放入临时数据中。当函数返回时,将释放旧数据。(其中,参数的作用域结束并调用其析构函数。)

因为这个习惯用法不重复代码,所以我们不能在操作符中引入错误。注意,这意味着我们不再需要自赋值检查,允许运算符=的单一统一实现。(此外,我们不再对非自我分配的绩效进行处罚。)

这就是复制和交换的习惯用法。

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

    // ...
};

这是怎么回事?回想一下move构造的目标:从类的另一个实例获取资源,使其处于保证可分配和可破坏的状态。

所以我们所做的很简单:通过默认构造函数(C++11特性)初始化,然后与其他构造函数交换;我们知道我们的类的默认构造实例可以安全地分配和销毁,所以我们知道在交换之后,其他类也可以这样做。

(注意,有些编译器不支持构造函数委托;在这种情况下,我们必须手动默认构造类。这是一项不幸但幸运的任务。)

为什么这样做有效?

这是我们需要对我们的班级做出的唯一改变,那么为什么它会起作用呢?请记住我们做出的一个非常重要的决定:将参数设置为值而不是引用:

dumb_array& operator=(dumb_array other); // (1)

现在,若另一个正在用右值初始化,那个么它将被移动构造。完美的以同样的方式,C++03让我们通过按值获取参数来重用复制构造函数功能,C++11也会在适当的时候自动选择移动构造函数。(当然,正如前面链接的文章中所提到的,复制/移动价值可能会被完全消除。)

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


脚注

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

†还有其他说法认为,我们应该将std::swap专用于我们的类型,提供类内交换和自由函数交换等。但这都是不必要的:交换的任何正确使用都将通过非限定调用,我们的函数将通过ADL找到。一个功能就可以了。

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

††移动构造函数通常应为noexcept,否则某些代码(例如std::vector resize logic)将使用复制构造函数,即使移动有意义。当然,只有当内部代码没有抛出异常时,才将其标记为noexcept。

在处理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_allocater()!=b.get_allocater()?答案是:视情况而定。让我们写AT=std::allocator_traits<A>。

如果AT::propagate_on_container_move_assignment为std::true_type,则fm使用b.get_allocater()的值重新分配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_allorator(),则两个容器使用兼容的存储,并且交换以通常的方式进行。但是,如果a.get_allocater()!=b.get_allocater(),程序具有未定义的行为(参见[container.requirements.general/8])。

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