首页 文章

什么是复制和交换习语?

提问于
浏览
1741

这个成语是什么?何时应该使用?它解决了哪些问题?当使用C 11时,成语是否会改变?

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

5 回答

  • 1907

    当您处理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 的目的是为 b 提供 b 最初的状态 . 但是,有一个隐藏的问题:如果 a.get_allocator() != b.get_allocator() 会发生什么?答案是:这取决于 . 我们来写 AT = std::allocator_traits<A> .

    • 如果 AT::propagate_on_container_move_assignmentstd::true_type ,则 fma 的分配器重新分配 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中的一个非常重要的操作 . 这是一个有点“高级用例”,但并非完全不可能,因为一旦您的类管理资源,移动优化通常只会变得有趣,而内存是最受欢迎的资源之一 .

  • 21

    概述

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

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

    复制和交换习语是解决方案,并且优雅地帮助赋值操作符实现两件事:避免code duplication,并提供strong exception guarantee .

    它是如何工作的?

    Conceptually,它通过使用复制构造函数的功能来创建数据的本地副本,然后使用 swap 函数获取复制的数据,使用新数据交换旧数据 . 然后临时拷贝破坏,用它来获取旧数据 . 我们留下了新数据的副本 .

    为了使用复制和交换习惯用法,我们需要三件事:一个工作的复制构造函数,一个工作的析构函数(两者都是任何包装器的基础,所以应该是完整的),以及一个 swap 函数 .

    交换函数是一种非抛出函数,它交换类的两个对象,成员的成员 . 我们可能会试图使用 std::swap 而不是提供我们自己的,但这是不可能的; std::swap 在其实现中使用了copy-constructor和copy-assignment运算符,我们最终会尝试根据自身定义赋值运算符!

    (不仅如此,但是对 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) .

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

    • 第二个是它只提供基本的异常保证 . 如果 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;
    }
    
    • 代码已扩展!这引出了第三个问题:代码重复 . 我们的赋值运算符有效地复制了我们已经在其他地方写过的所有代码,这是一件非常糟糕的事情 .

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

    (有人可能会想:如果需要这么多代码来正确管理一个资源,那么如果我的 class 管理不止一个怎么办?虽然这似乎是一个有效的问题,实际上它需要非常重要的 try / catch 条款,这是一个非问题 . 这是因为一个 class 应该管理one resource only!)

    一个成功的解决方案

    如上所述,复制和交换习惯用法将解决所有这些问题 . 但是现在,我们有除了一个以外的所有要求:一个 swap 函数 . 虽然The Rule of Three成功地要求存在我们的复制构造函数,赋值运算符和析构函数,但它应该被称为"The Big 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);
        }
    
        // ...
    };
    

    Here解释为什么 public friend swap . )现在我们不仅可以交换我们的 dumb_array ,而且通常交换可以更有效率;它只是交换指针和大小,而不是分配和复制整个数组 . 除了功能和效率方面的这一奖励外,我们现在已准备好实施复制和交换习惯用法 .

    不用多说,我们的赋值运算符是:

    dumb_array& operator=(dumb_array other) // (1)
    {
        swap(*this, other); // (2)
    
        return *this;
    }
    

    就是这样!一举一提,这三个问题都得到了优雅的解决 .

    为什么会有效?

    我们首先注意到一个重要的选择:参数参数取值为-value . 虽然人们可以轻松地执行以下操作(实际上,许多简单的习惯实现):

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

    我们失去了important optimization opportunity . 不仅如此,这种选择在C 11中至关重要,后面将对此进行讨论 . (一般来说,一个非常有用的指导如下:如果你要在函数中复制一些东西,让编译器在参数列表中进行 . ‡)

    无论哪种方式,这种获取资源的方法是消除代码重复的关键:我们可以使用copy-constructor中的代码来制作副本,而不需要重复任何一点 . 现在副本已经完成,我们已准备好进行交换 .

    注意,在进入该功能时,已经分配,复制并准备好使用所有新数据 . 这就是免费提供强有力的例外保证:我们赢了't even enter the function if construction of the copy fails, and it'因此无法改变 *this 的状态 . (我们之前手动完成了强有力的异常保证,编译器现在为我们做了;怎么样 . )

    此时我们没有家,因为 swap 是非投掷的 . 我们将当前数据与复制的数据交换,安全地改变我们的状态,并将旧数据放入临时数据中 . 然后在函数返回时释放旧数据 . (在参数的作用域结束并调用其析构函数的位置 . )

    因为习惯用法不重复代码,所以我们不能在运算符中引入错误 . 请注意,这意味着我们不需要进行自我分配检查,只允许统一实现 operator= . (此外,我们不再对非自我分配造成性能损失 . )

    这就是复制和交换的习惯用语 .

    C 11怎么样?

    C,C 11的下一个版本对我们管理资源的方式做了一个非常重要的改变:三个规则现在是 The Rule of Four (和一半) . 为什么?因为我们不仅需要能够复制 - 构建我们的资源,we need to move-construct it as well .

    幸运的是,这很容易:

    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 能够在交换后做同样的事情 .

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

    为什么会这样?

    这是我们需要对我们 class 做出的唯一改变,为什么它会起作用?请记住我们为使参数成为值而非参考而做出的非常重要的决定:

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

    现在,如果使用rvalue初始化 other ,它将被移动构造 . 完善 . 以同样的方式,C 03让我们通过使用参数by-value重用我们的拷贝构造函数,C 11也会在适当的时候自动选择move-constructor . (当然,正如先前链接的文章中提到的那样复制/移动值可能完全被省略 . )

    因此,复制和交换习语得出结论 .


    脚注

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

    †还有其他声称,我们应该专门为我们的类型 std::swap ,提供一个类的 swap 和一个自由函数 swap 等 . 但这都是不必要的:任何正确使用 swap 将通过一个不合格的电话,并且我们的功能将通过ADL找到 . 一个功能就可以了 .

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

  • 238

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

    在某些版本的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 函数来交换2个对象 . 使 swap 成为具有一个 other 对象作为参数的成员函数同样有意义 .

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

  • 34

    分配的核心是两个步骤: tearing down the object's old statebuilding its new state as a copy 某个其他对象的状态 .

    基本上,这就是 destructorcopy constructor 所做的,所以第一个想法就是将工作委托给他们 . 但是,由于破坏必定不会失败,而在施工时,我们实际上希望以相反的方式进行: first perform the constructive part 如果成功, then do the destructive part . 复制和交换习惯是一种方法:首先调用类' copy constructor to create a temporary, then swaps its data with the temporary',然后让临时的析构函数破坏旧状态 .
    由于 swap() 应该永远不会失败,唯一可能失败的部分是复制构造 . 首先执行此操作,如果失败,则目标对象中不会更改任何内容 .

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

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

    已经有一些好的答案了 . 我将主要关注我认为他们缺乏的东西 - 用复制和交换习语解释"cons" ....

    什么是复制和交换习惯用法?

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

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

    基本思想是:

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

    • 如果获得了新值的副本,则可以在修改对象的当前状态(即 *this )之前尝试获取,这就是 rhs 被值(即复制)而不是通过引用接受的原因

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

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

    • 当您希望被分配的对象不受引发异常的赋值影响时,假设您已经或可以编写具有强异常保证的 swap ,并且理想情况下是一个无法失败的 throw ..†

    • 当你想要一个干净,易于理解,健壮的方法来定义赋值运算符(简单)复制构造函数, swap 和析构函数 .

    • 作为复制和交换完成的自我分配避免了经常被忽视的边缘情况 . ‡

    • 如果在分配期间通过拥有额外的临时对象而导致的任何性能损失或暂时更高的资源使用对您的应用程序并不重要 . ⁂


    swap 抛出:它's generally possible to reliably swap data members that the objects track by pointer, but non-pointer data members that don' t有一个无抛出交换,或者为什么交换必须实现为 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,不能抛出异常 . - 詹姆斯麦克尼利斯2010年12月22日15:24


    ‡当从不同的对象分配时,分配运算符实现看起来很清晰,很容易失败以进行自我分配 . 虽然客户端代码甚至可能尝试自我分配似乎是不可想象的,但是在容器上的algo操作期间它可以相对容易地发生, x = f(x); 代码 f (可能只对某些 #ifdef 分支)宏ala #define f(x) x 或返回引用的函数to 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 相同的服务器(如果有用,可能会发送"reset"代码),而复制和交换方法会调用可能写入的复制构造函数打开一个独特的套接字连接,然后关闭原来的 . 这不仅意味着远程网络交互而不是简单的进程内变量复制,它可能会对套接字资源或连接的客户端或服务器限制产生影响 . (当然这个类有一个非常可怕的界面,但那是另一回事;-P) .

相关问题