C++ 0-3-5 规则与 Copy-Swap 原语
The Rule of 3
当一个类需要管理资源时,它至少需要提供这三个函数
- 复制构造函数
- 复制赋值函数
- 析构函数 才能保证该类符合值语义(指对象当作值处理,许多操作都将复制一份拷贝),在传递过程中可以正确处理。例子:
template <typename T>
class MyArray {
public:
// default ctor
MyArray() : len_(0), data_(nullptr) {}
// copy ctor
MyArray(const MyArray& other)
: len_(other.len_), data_(len_ ? new T[len_] : nullptr) {
std::copy(other.data_, other.data_ + len_, data_);
}
// dtor
~MyArray() { delete[] data_; }
// copy assignment operator
// TODO: the hard part
MyArray& operator=(const MyArray& rhs);
private:
size_t len_;
T* data_;
};
上述例子已经将复制构造和析构正常实现,但是对于复制赋值,情况变得有些复杂。先来看一个错误的例子:
// a failed solution
template <typename T>
MyArray<T>& MyArray<T>::operator=(const MyArray& rhs) {
delete[] data_;
len_ = rhs.len_;
data_ = len_ ? new T[len_] : nullptr;
std::copy(rhs.data_, rhs.data_ + len_, data_);
return *this;
}
其实我自己乍一看,就会写出这种东西(我真的在写期末作业的时候干过这种事情,然后调试到崩溃😭)。这份代码有几个不易察觉的问题。
首先是自赋值问题。如果赋值来源于自身,上面的代码的结果就会有问题:它直接从 new
出来的未初始化内存中 copy 到原地了,旧数据没了。一般来说,虽然没有人会直接写 x=x
这种癫狂的代码,但是如果对象放在容器中,遍历赋值等等操作,还是有可能写出自赋值的。所以,讲究点的人会这样处理:
// still a failed solution
template <typename T>
MyArray<T>& MyArray<T>::operator=(const MyArray& rhs) {
if (this != &rhs) {
delete[] data_;
len_ = rhs.len_;
data_ = len_ ? new T[len_] : nullptr;
std::copy(rhs.data_, rhs.data_ + len_, data_);
}
return *this;
}
写成这样,加了一个 if
判断,解决了自赋值的问题。但是额外的分支指令,会使该段代码性能略微下降。如果有个不分支的方法同样也能解决自赋值,这会更好。不过,即使加上 if
,上面的代码依然还是有错。考虑在上述函数执行过程中,某行抛出异常。例如 new
操作因内存不足而失败(在更复杂的代码中,还可能有更多的异常产生)。如果异常发生,而旧的 data_
已经被删除了,这意味着新数据没有成功写入,但是旧数据也已经没有了。整个程序进入了一种反常状态,即使上层存在 try
-catch
进行保护,也回天乏术。因此,我们需要先创建额外缓冲区存入数据,再修改当前数据状态:
template <typename T>
MyArray<T>& MyArray<T>::operator=(const MyArray& rhs) {
if (this != &rhs) {
// ready new data
size_t len = rhs.len_;
auto data = len ? new T[len] : nullptr;
std::copy(rhs.data_, rhs.data_ + len, data);
// replace old data
delete[] data_;
len_ = len;
data_ = data;
}
return *this;
}
这样一来,所有潜在会发生错误的代码都先执行,它们都成功之后再执行修改 this
状态的代码,就规避掉了异常引起的问题。
从上面的简单例子可以看出,复制赋值操作内含很多坑,一不小心就踩进去!而为了实现复制赋值,每个类都需要像上面一样写出大量相似的代码。实际上,仔细观察上面的代码,会发现,第一阶段就是把 rhs
复制一次,那我们何不直接利用复制构造函数呢?第二阶段修改数据的操作,正好就是标准的 swap
操作!Copy-Swap 原语,就是这样的操作。
Copy and Swap
首先,提供一个公开的 swap
函数,它应当是不抛出任何异常的。如果有 C++11,最好直接标明 noexcept
。这里使用 using std::swap
而不是直接调用 std::swap(...)
,是考虑到如果被交换的类也实现了特别的 swap
,那么使用它会比 std::swap
更好,而如果没有特别的 swap
实现,那么我们依然可以使用 std::swap
。这利用了 C++ 的 ADL(Argument Dependent Lookup) 特性。
template <typename T>
class MyArray {
// ...
public:
friend void swap(MyArray& lhs, MyArray& rhs) /* noexcept */ {
// use `noexcept` specifier if you have c++11
using std::swap;
swap(lhs.len_, rhs.len_);
swap(lhs.data_, rhs.data_);
}
// ...
};
有了这个 swap
函数,复制赋值可以写成这样:
template <typename T>
MyArray<T>& MyArray<T>::operator=(const MyArray& rhs) {
MyArray tmp(rhs);
swap(*this,tmp);
return *this;
}
这样一来,代码就简化了很多。更进一步地,甚至函数参数都不必写成 const MyArray&
,而直接写 MyArray
,在函数传参阶段就执行复制:
template <typename T>
MyArray<T>& MyArray<T>::operator=(MyArray rhs) {
swap(*this, rhs);
return *this;
}
如果是 C++11 以后的代码,按值传递的函数参数同时也可以匹配右值引用,也就是上述一个函数就能同时实现移动赋值和复制赋值。因为右值匹配到传值的函数时,复制会被编译器消除掉,变成 move 构造。相比于直接针对交换,这种实现额外多了一次 move 构造,理论上讲有非常低的额外性能开销。
The Rule of 5
C++11 引入了右值引用与移动语义。因此,为了保证正确性,还需要额外实现这两个函数
- 移动构造
- 移动赋值
借助 Copy-Swap 原语,移动构造可以直接利用默认构造来解决。需要注意的是,移动构造函数最好实现成 noexcept
的,否则 std::vector
之类的容器在动态扩容时还是会匹配复制构造函数来避免出错 (std::move_if_noexcept)。
template <typename T>
class MyArray {
public:
// ...
// move ctor
MyArray(MyArray&& rhs) noexcept : MyArray() { swap(*this, rhs); }
// ...
};
至于赋值,如上面所说,可以写成函数传值的版本,一个函数同时解决复制赋值和移动赋值。实际上,这种实现的效率已经很高了。如果实在有洁癖,非要解决那一点点构造的开销,可以采取如下的方法。
可以单独实现一个移动赋值。这和函数传值的唯一区别就是从“移动构造-交换”两步走变成了“直接交换”。(但是其实移动构造本身的开销就很低呀 😗)
template <typename T>
class MyArray {
public:
// ...
// move assignment operator
MyArray& operator=(MyArray&& rhs) noexcept {
swap(*this, rhs);
return *this;
}
// ...
};
这里不用担心 swap
把 this
的状态转给 rhs
之后的析构问题,因为在 C++ 的世界里,编译器不会分析你究竟有没有 move。在 move 之后,析构函数依然会被调用的,也就不用担心内存泄漏。实际上,把 this
交换给 rhs
让它析构,反而才不会发生内存泄漏。(但 Rust 就不像这样,Rust 的设计使得编译器能静态分析 move/borrow,因此 move 发生之后直接就不允许用对应的变量了。)
在上述拆分开的写法下,复制赋值和移动复制非常非常相似:
template <typename T>
class MyArray {
public:
// ...
// copy assignment operator
MyArray& operator=(const MyArray& rhs) {
MyArray tmp(rhs);
swap(*this, tmp);
return *this;
}
// move assignment operator
MyArray& operator=(MyArray&& rhs) noexcept {
swap(*this, rhs);
return *this;
}
// ...
};
熟悉 C++ 的人可能一下子就想到使用完美转发,把两个函数合体成一个。然而,就算用了完美转发,还需要根据类型额外处理是否复制,noexcept 标识符如何处理等等问题。此外,标准规定了,赋值算符必须是非模板的,完美转发的办法,并不适用。举个例子,来说明一下不能用模板函数来提供赋值算符。下面的例子会调用默认复制赋值,而非我们提供的模板函数。
#include <iostream>
#include <utility>
struct X {
template <typename T>
X& operator=(T&& rhs) {
std::cout << "Using template operator=" << std::endl;
return *this;
}
};
int main() {
X u;
// const is the magic
const X v;
u = v;
return 0;
}
The Rule of 0
Rule of 0 说的是,如果一个类的成员全部都是正确实现了 The Rule of 5 的类(如 STL 容器,智能指针等,而没有裸指针之类需要手动管理的对象),编译器可以默认生成这 5 个函数,而无需手动实现了。这意味着,只要一个小的类实现了 The Rule of 5,往上累积木或者配合容器使用,都可以不用任何额外操作,也无需担心生命周期问题。