我们先从异常安全(exception safety)谈起,要想写健壮的C++代码,异常安全是十分重要的。具有异常安全的函数提供了三种安全等级:
- 基本保证:如果异常被抛出,对象内的任何成员仍然能保持有效状态(不一定是调用前的状态,但至少保证符合对象正常的要求),没有数据的破坏及资源泄漏。
- 强烈保证:如果异常被抛出,对象的状态保持不变。即如果调用成功,则完全成功;如果调用失败,则对象依然是调用前的状态。
- 不抛异常保证:函数承诺不会抛出任何异常。一般内置类型的所有操作都有不抛异常的保证。
如果一个函数不能提供上述保证之一,则不具备异常安全性。下面,我们通过一个例子,讲解如何通过 copy-and-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)
{
std::copy(other.mArray, other.mArray + mSize, mArray);
}
// destructor
~dumb_array()
{
delete[] mArray;
}
private:
std::size_t mSize;
int* mArray;
};
下面实现拷贝赋值函数(这是我们重点讨论的对象),我们在掌握 copy-and-swap 技巧之前,一般会这样实现:
dumb_array& operator=(const dumb_array& other)
{
if (this != &other) // Problem (1)
{
delete[] mArray; // Problem (2)
mArray = 0;
mSize = other.mSize;
mArray = mSize ? new int[mSize] : 0;
std::copy(other.mArray, other.mArray + mSize, mArray);
}
return *this;
}
这种实现方式存在以下缺陷:
- 需要进行自我赋值判别(Problem (1) 处)
为了防止出现bug(删除数组后接着又进行复制操作)和减少代码冗余,我们需要这个if判断。自我赋值毕竟是出现次数少的特殊情况,所以这个条件判断在大部分情况下是多余的。 - 只提供异常安全的基本保证(Problem (2) 处)
如果new int[mSize]失败,那么*this就被修改了(数组大小是错误的,数组也丢失了)。为了提供强烈保证,需要这样做:
dumb_array& operator=(const dumb_array& other)
{
if (this != &other)
{
// get the new data ready before we replace the old
std::size_t newSize = other.mSize;
int* newArray = newSize ? new int[newSize]() : 0; // Problem (3)
std::copy(other.mArray, other.mArray + newSize, newArray);
// replace the old data (all are non-throwing)
delete[] mArray;
mSize = newSize;
mArray = newArray;
}
return *this;
}
但这样就会导致代码膨胀,于是导致了另一个问题:
- 代码冗余
本例中代码冗余不是很明显,因为核心代码只有两行即分配空间和拷贝。如果要实现比较复杂的资源管理,那么代码的膨胀将会导致非常严重的问题。
就像前面所提到的,copy-and-swap 可以解决以上所有这些问题。但是现在,我们还需要完成另外一件事:swap函数。对,就是 copy-and-swap 中的那个swap。其实,任何时候你的类要管理一个资源,提供swap函数是有必要的。我们需要向我们的类添加swap函数,看以下代码:
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 classes,
// the two classes are effectively swapped
swap(first.mSize, second.mSize);
swap(first.mArray, second.mArray);
}
此swap函数只交换指针和数组大小,而不是重新分配空间和拷贝整个数组,故效率很高。
至此,我们可以使用 copy-and-swap 技巧实现拷贝赋值操作符了:
dumb_array& operator=(dumb_array other)
{
swap(*this, other);
return *this;
}
是的,你没有看错,就是这么简洁优雅。下面让我们仔细看看为什么它能更好地完成任务:
- 首先,参数是按值传递的。按值传递参数,是编译器来完成拷贝工作。所以,如果拷贝失败,我们不会进入到函数体内,那么this指针所指向的内容也不会被改变。(在前面我们为了实施强烈保证所做的事情,现在编译器为我们做了)。
- 其次,swap函数是 non-throwing 的。我们把旧数据和新数据交换,安全地改变我们的状态,旧数据被放进了临时对象里。这样当函数退出时候,旧数据被自动释放。
- 最后,没有代码冗余,我们不会在这个而操作符里面引入bug,也避免了自我赋值检测。
在C++11引入move语义后,根据三五法则,我们一般要实现移动构造函数和移动赋值运算符。copy-and-swap 技巧 也可以用到 move 语义上,变成 move-and-swap 技巧,完成对移动赋值运算符的改进。
实际上,我们比那个不需要多写代码,copy-and-swap 技巧 和 move-and-swap 技巧是共享同一个函数的。对,还是这个:
dumb_array& operator=(dumb_array other)
{
swap(*this, other);
return *this;
}
对于C++ 11,编译器会依据参数是左值还是右值在拷贝构造函数和移动构造函数间进行选择:
- 如果是
a = b
,这样就会调用拷贝构造函数来初始化other(因为b是左值),赋值操作符会与新创建的对象交换数据,深度拷贝。这就是copy and swap 惯用法的定义:构造一个副本,与副本交换数据,并让副本在作用域内自动销毁。 - 如果是
a = x + y
,这样就会调用移动构造函数来初始化other(因为x + y
是右值),所以这里没有深度拷贝,只有高效的数据转移。
我们也可以称呼它为“统一赋值操作符”,因为它合“拷贝赋值”、“移动赋值”为一体了。
参考文献
- 书籍:C++ Primer(第五版)
- 文章:Copy-and-swap idiom详解和实现安全自我赋值
网友评论