Contents

C++ 0-3-5 规则与 Copy-Swap 原语

The Rule of 3

当一个类需要管理资源时,它至少需要提供这三个函数

  1. 复制构造函数
  2. 复制赋值函数
  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 引入了右值引用与移动语义。因此,为了保证正确性,还需要额外实现这两个函数

  1. 移动构造
  2. 移动赋值

借助 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;
  }
  // ...
};

这里不用担心 swapthis 的状态转给 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,往上累积木或者配合容器使用,都可以不用任何额外操作,也无需担心生命周期问题。