什么是复制和交换习语?

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

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

答案

总览

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

任何管理资源的类( 包装器 ,如智能指针)都需要实现“三巨头” 。尽管复制构造函数和析构函数的目标和实现很简单,但是复制分配运算符无疑是最细微和最困难的。应该怎么做?需要避免什么陷阱?

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

它是如何工作的?

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

为了使用复制和交换的习惯用法,我们需要三件事:一个有效的复制构造函数,一个有效的析构函数(两者都是任何包装程序的基础,因此无论如何都应完整)以及swap功能。

交换函数是一种非抛出函数,它交换一个类的两个对象,一个成员一个成员。我们可能很想使用std::swap而不是提供我们自己的方法,但这是不可能的。 std::swap使用了 copy-constructor 和 copy-assignment 运算符,我们最终将尝试根据自身定义赋值运算符!

(不仅如此,对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将被修改。 (即,大小错误,数据不见了!)要获得强大的异常保证,它必须类似于以下内容:

    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. 代码已扩展!这导致我们遇到第三个问题:代码重复。我们的赋值运算符有效地复制了我们已经在其他地方编写的所有代码,这是很糟糕的事情。

在我们的案例中,它的核心只有两行(分配和复制),但是由于资源更加复杂,此代码膨胀可能很麻烦。我们应该努力避免重复自己。

(一个人可能会怀疑:如果需要大量的代码来正确地管理一种资源,那么如果我的类可以管理多个资源呢?这似乎是一个有效的问题,并且确实需要非平凡的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;
}

就是这样!一口气,所有三个问题都可以轻松解决。

为什么行得通?

我们首先注意到一个重要的选择:参数自变量采用by-value 。尽管可以很容易地完成以下操作(实际上,许多成语的幼稚实现都可以做到):

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)
        : 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 也会在适当时自动选择 move-constructor。 (并且,当然,如先前链接的文章中所述,可以简单地完全省略复制 / 移动值。)

这样就得出了 “复制和交换” 的成语。


脚注

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

†还有其他要求,我们应该专门std::swap我们的类型,提供一流swap沿一侧具有自由功能swap ,等等。但是这一切不必要的:任何正确使用的swap将通过不合格调用,我们的功能将通过ADL找到。一种功能将起作用。

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

分配从本质上讲分两个步骤: 拆除对象的旧状态和将其新状态构建为其他对象状态的副本

基本上,这就是析构函数复制构造函数的工作 ,因此第一个想法是将工作委托给他们。但是,由于销毁一定不会失败,而在构建可能会失败的情况下, 我们实际上希望以另一种方式进行首先执行建设性部分 ,如果成功, 则进行破坏性部分 。复制和交换的习惯是一种做到这一点的方式:它首先调用类的复制构造函数以创建一个临时对象,然后将其数据与该临时对象的数据交换,然后让该临时对象的析构函数销毁旧状态。
由于swap()应该永远不会失败,因此唯一可能失败的部分就是复制构造。首先执行该操作,如果失败,则目标对象将保持不变。

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

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

已经有一些好的答案。我将主要集中于我认为它们缺少的内容 - 对 “复制和交换” 习惯用法的 “缺点” 的解释。

什么是复制和交换习语?

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

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

基本思想是:

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

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

  • 交换本地副本rhs*this的状态通常相对容易,并且没有潜在的失败 / 异常,因为之后本地副本不需要任何特定的状态(只需要适合析构函数运行的状态即可,就像对象从 > = C ++ 11 中移出

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

  • 当您希望被分配的对象不受抛出异常的赋值的影响时,假设您具有或可以编写具有强烈异常保证的swap ,并且理想情况下,该swap不能失败 / throw ..†

  • 如果您想要一种简洁,易于理解,健壮的方式来根据(更简单的)复制构造函数, swap和析构函数定义赋值运算符。

    • 通过复制和交换进行自我分配可以避免经常被忽视的边缘情况。‡

  • 如果在分配过程中有一个额外的临时对象而导致性能下降或暂时占用较高资源,这对您的应用程序并不重要。 ⁂

swap抛出:通常可以可靠地交换对象通过指针跟踪的数据成员,但是没有无抛出交换或必须将交换实现为X tmp = lhs; lhs = rhs; rhs = tmp;非指针数据成员X tmp = lhs; lhs = rhs; rhs = tmp;并且复制构造或分配可能会抛出,仍然有可能失败,导致某些数据成员被交换而另一些数据成员没有被交换。当詹姆斯评论另一个答案时,这种潜力甚至适用于 C ++ 03 std::string

@wilhelmtell:在 C ++ 03 中,没有提到 std :: string :: swap(由 std :: swap 调用)可能引发的异常。在 C ++ 0x 中,std :: string :: swap 为 noexcept 且不得引发异常。 – James McNellis 2010 年 12 月 22 日 15:24


• 当从一个不同的对象进行分配时,分配运算符实现似乎很理智,很容易因自赋值而失败。尽管客户端代码甚至尝试自我分配似乎是不可想象的,但在容器上进行算法操作时,它相对容易发生, x = f(x);其中f是(也许仅用于某些#ifdef分支)宏 ala #define f(x) x或返回对x的引用的x ,或者甚至是x = c1 ? x * 2 : c2 ? x / 2 : 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” 进行自我分配!


copy 复制和交换习惯可能会由于使用额外的临时文件而导致效率低下或受到限制(当操作员的参数是复制结构时):

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)。

该答案更像是对以上答案的添加和稍作修改。

在某些版本的 Visual Studio(可能还有其他编译器)中,存在一个确实令人讨厌且没有道理的错误。因此,如果您这样声明 / 定义swap函数:

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

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

}

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

在此处输入图片说明

这与调用一个friend函数以及this对象作为参数传递有关。


一种解决方法是不使用friend关键字并重新定义swap功能:

void swap(A& other) {

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

}

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

在此处输入图片说明


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

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

在处理 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使用b.get_allocator()的值重新分配a的分配器,否则它将不分配,并且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 中不平凡的操作。这在某种程度上是 “高级用例”,但并非完全不可能,因为一旦类管理资源,移动优化通常才变得有趣,而内存是最受欢迎的资源之一。