在C++中,有个著名的"The Big Three",即:
如果要显式声明析构函数、拷贝构造函数和拷贝赋值操作符,那么需要显式声明所有的这三者。
从C++ 11引入移动语义以后,变成了"The Big Five",即要全部显式声明五个函数,比如:
class person
{
std::string name;
int age;
public:
person(const std::string& name, int age); // Ctor
person(const person &) = default; // 1/5: Copy Ctor
person(person &&) noexcept = default; // 4/5: Move Ctor
person& operator=(const person &) = default; // 2/5: Copy Assignment
person& operator=(person &&) noexcept = default; // 5/5: Move Assignment
~person() noexcept = default; // 3/5: Dtor
};
上面这几个函数中,拷贝构造函数和析构函数实现起来比较容易,但是拷贝赋值操作符则要复杂很多。因为与拷贝构造不同,拷贝赋值需要先把当前的资源释放,然后重新构造。这与之前单例模式中的某些场景一样,要保证操作的某种“原子性”:要么全部成功,要么恢复到之前的状态。
copy-and-swap就是完美的解决方案。它可以很好地帮助拷贝赋值操作符达到两个目标:避免代码重复、提供强烈的异常安全保证。它的基本思想就是:
先做出一个你要改变的对象的copy,然后在这个copy上做出全部所需的改变。如果改变过程中的某些操作抛出了异常,最初的对象保持不变。在所有的改变完全成功之后,将被改变的对象和最初的对象在一个不会抛出异常的操作中进行swap。
原理
拷贝赋值操作在原理上来说,它包含两个步骤:移除对象旧的状态,然后从其它对象拷贝出一份新的状态出来。这两个步骤就是析构函数和拷贝构造函数所做的工作,所以很容易想到在拷贝赋值中把这两个步骤委托给这两个函数来实现。copy-and-swap就是利用拷贝构造函数生成一个临时拷贝,然后使用swap函数将此拷贝对象与旧数据交换,最后临时对象被析构,旧数据消失。我们就拥有了新数据的拷贝。因为swap函数被认为不会失败,唯一可能失败的部分就是拷贝构造,因为它是操作的第一步,所以即使失败,也不会破坏对象的状态。所以拷贝赋值操作简化为了:
T& operator=(T tmp)
{
this->swap(tmp);
return *this;
}
swap函数用来交换某个类的两个对象,按成员进行交换。可以尝试使用std::swap,但目前是不可行的,因为std::swap使用自己的拷贝构造函数和拷贝赋值操作符,而我们的目的就是定义自己的拷贝赋值操作符。
例子
现在用一个具体的例子来说明copy-and-swap的惯用法。
目标
现在假设要实现一个管理int数组的类:
#include <iostream>
using namespace std;
class MyArray
{
public:
MyArray(size_t size=0) : msize(size), mArray(size ? new int[size] : nullptr)
{
}
// 带左值引用参数的拷贝构造函数
MyArray(const MyArray &other) : msize(other.msize), mArray(other.msize ? new int[other.size] : nullptr)
{
std::copy(other.mArray, other.mArray + msize, mArray);
}
~MyArray()
{
delete[] mArray;
msize = 0;
}
private:
int *mArray;
size_t msize;
};
现在,需要添加一个拷贝赋值操作符。
不好的做法
通常的做法如下:
MyArray &operator=(const MyArray &other)
{
if (this != &other)
{
// 删除旧数据
delete[] mArray;
mArray = nullptr;
// 构造新数据
msize = other.msize;
mArray = msize ? new int[msize] : nullptr;
std::copy(other.mArray, other.mArray + msize, mArray);
}
return *this;
}
这个看似正确的实现,存在以下三个问题:
- 首先是自赋值的判断,这个判断有两个目的,首先是消除不必要的自赋值,其次是防止显现bug,比如如果this==other,就会出现自我毁灭的bug。另外,额外的判断也会使得程序变慢。这个判断在大多数情况下是多余的,如果没有这个判断程序也能够正常运行就更好了。
- 其次,这个方法只提供了基本的异常安全保证。如果在构造新数据这一步骤(new int[msize])失败了,*this对象也会被修改,因为此时对象已经被删除,并且msize被修改为了目标大小。所以需要调整这两个步骤的先后顺序:
MyArray &operator=(const MyArray &other) { if (this != &other) { // 构造新数据 int tmpSize = other.msize; int *tmpArray = tmpSize ? new int[tmpSize] : nullptr; std::copy(other.mArray, other.mArray + msize, tmpSize); // 替换旧数据 delete[] mArray; msize = tmpSize; mArray = tmpArray; } return *this; }
- 第三个问题就是代码膨胀,核心部分其实就只有空间分配和拷贝。这本来可以直接委托给构造函数和析构函数,但上面的实现等于是把构造函数和析构函数都又实现了一遍。这违反了DRY原则。
解决方法
解决以上三个问题的方法就是copy-and-swap。这里面需要一个额外的swap函数。规则“The Big Three”指明了拷贝构造函数、赋值操作符以及析构函数的存在。其实它应该被称作是“The Big Three and A Half”:任何时候你的类要管理一个资源,提供swap函数是有必要的。
class MyArray
{
public:
// ........
friend void swap(MyArray &lhs, MyArray &rhs)
{
//开启ADL
using std::swap;
swap(lhs.msize, rhs.msize);
swap(lhs.mArray, rhs.mArray);
}
};
为什么要定义成public friend swap,这篇回答有解释。现在不仅可以交换MyArray,而且交换的效率很高:它只是交换指针和数组的大小,而不是重新分配空间和拷贝整个数组。现在,拷贝赋值构造函数可以实现成如下:
MyArray &operator=(MyArray other)
{
swap(*this, other);
return *this;
}
现在,上述三个问题都得到了有效解决。
原理
在上面的拷贝赋值函数中的实现中,可以看到参数是按值传递的,这里面按值传递可以让编译器帮我们把形参通过调用构造函数来实例化形参。它是实参的拷贝,并不会修改形参,所以其效果与const MyArray&按引用传递时一样的。我们只是把拷贝步骤让编译器帮我们完成了。
某些人可能会像下面这样实现:
MyArray &operator=(const MyArray &other)
{
MyArray temp(other);
swap(*this, temp);
return *this;
}
这样的话,就失去了一个重要的性能优化的机会。通常,最好遵循的规则是,不要去手动拷贝函数参数,直接按值传递参数,让编译器来完成拷贝工作。
总结
copy-and-swap完美解决了代码冗余的问题,它使用拷贝构造函数来获取对象的副本,并且对拷贝构造函数的调用是通过函数按值传递时,编译器帮我们调用的。我们没有显示调用拷贝构造函数,也没有手动去重复实现拷贝逻辑。
在上面的实现中,一旦进入函数体,所有新数据都已经被分配、拷贝,并可以被使用了。这提供了强烈的异常安全保证:如果拷贝失败,就不会进入到函数体内,那么this指针所指向的内容也不会被改变。(在前面我们为了实施强烈保证所做的事情,现在编译器为我们做了)。
swap函数也不会抛出异常。在将当前的数据与拷贝的临时对象交换之后,当前旧的数据被交换为了临时数据,当函数退出时,旧数据会被自动释放。
copy-and-swap,没有冗余的代码,不会引入bug,并且也避免了自我赋值的检测。
C++ 11的实现
在C++ 11引入了移动语义,所以"The Big Three"变成了"The Big Five",除了需要提供拷贝构造之外,还需要提供移动构造函数(包括移动拷贝和移动赋值)。很容易提供一个移动拷贝构造函数:
MyArray(MyArray &&other) noexcept: MyArray()
{
swap(*this, other);
}
移动拷贝构造函数的本意是从一个临时对象构造一个新对象,然后将临时对象的指针置为空。这里的移动拷贝构造的逻辑也很简单,首先通过默认的构造函数初始化一个对象,然后将这个对象与临时对象进行swap。swap后,other变成了默认的构造函数产生的对象(msize=0,marray=nullptr),这就是之前移动拷贝函数需要做的事情。
现在再次回到拷贝赋值运算符:
MyArray &operator=(MyArray other)
{
swap(*this, other);
return *this;
}
上面是按值传递的参数,
- 如果形参是一个左值,那边编译器会调用拷贝构造函数从实参构造一个到形参的拷贝。
- 如果形参是一个右值的临时量,那么编译器就会调用移动拷贝构造来从实参移动到形参。
我们并不需定义下面这两个函数了:
MyArray &operator=(const MyArray &other);
MyArray &operator=(MyArray &&other);
相当完美。
参考:
- https://stackoverflow.com/questions/3279543/what-is-the-copy-and-swap-idiom/3279550#3279550
- https://stackoverflow.com/questions/5695548/public-friend-swap-member-function
- https://www.cnblogs.com/youxin/p/4300662.html
- https://blog.csdn.net/xiajun07061225/article/details/7926722
- https://blog.csdn.net/leizhengshenglzs/article/details/144095900