T.TAO
返回博客
/8 min read/Programming

C++ #7 拷贝控制与运算符重载

#C++#Programming#ComputerSystems

这篇笔记主要写一下对象被复制、移动、销毁时发生了什么,以及运算符重载。这些是 C++ 里最容易写出"能跑但有问题"的代码的地方。

编译器替你生成的那些函数

对于任何一个类,编译器在需要时会自动生成六个特殊成员函数:

C++class Widget {
    Widget();                                // 默认构造
    ~Widget();                               // 析构
    Widget(const Widget&);                   // 拷贝构造
    Widget& operator=(const Widget&);        // 拷贝赋值
    Widget(Widget&&) noexcept;               // 移动构造
    Widget& operator=(Widget&&) noexcept;    // 移动赋值
};

生成出来的版本做的都是逐成员操作:逐成员拷贝、逐成员移动。对于只持有 intstd::stringstd::vector 这类成员的类,这完全正确,什么都不用写。

问题出在类持有裸资源的时候。

浅拷贝的经典事故

C++class Buffer {
public:
    Buffer(size_t n) : size_(n), data_(new char[n]) {}
    ~Buffer() { delete[] data_; }
private:
    char*  data_;
    size_t size_;
};

Buffer a(1024);
Buffer b = a;       // 编译器生成的拷贝构造:只拷贝了指针值

现在 a.data_b.data_ 指向同一块内存。作用域结束时两个析构函数各 delete[] 一次——double free,程序崩溃。就算没崩,改 a 也会影响 b

这就是浅拷贝(shallow copy)与深拷贝(deep copy)的区别:前者拷贝指针,后者拷贝指针指向的内容。

三法则与五法则

三法则(Rule of Three):如果你需要自己写析构函数、拷贝构造函数、拷贝赋值运算符中的任何一个,那么你几乎肯定需要写全三个。

原因很直接:你之所以需要写析构函数,是因为类管理着某种资源;既然管理资源,那么默认的逐成员拷贝就一定是错的。

C++11 加入移动语义后变成五法则,再加上移动构造和移动赋值。

而实践中最该记住的其实是 Rule of Zero尽量设计成一个都不用写的类。用 std::vector 代替 new[],用 std::unique_ptr 代替裸指针,然后六个函数全部交给编译器。上面的 Bufferstd::vector<char> 重写,所有问题自动消失,代码也短得多。

需要手写这些函数的,应该只有资源管理类本身——而这类东西,标准库通常已经替你写好了。

拷贝赋值运算符与 copy-and-swap

手写拷贝赋值时,有三件事很容易漏:自赋值、异常安全、返回引用。

C++// 有问题的写法
Buffer& operator=(const Buffer& other) {
    delete[] data_;                       // 如果 &other == this,这里就毁了
    data_ = new char[other.size_];        // 如果这里抛异常,对象处于半损坏状态
    std::memcpy(data_, other.data_, other.size_);
    size_ = other.size_;
    return *this;
}

copy-and-swap 惯用法一次解决全部三个问题:

C++Buffer& operator=(Buffer other) {   // 按值传参:拷贝在这里发生
    swap(*this, other);             // 交换内容
    return *this;                   // other 析构时带走旧数据
}

friend void swap(Buffer& a, Buffer& b) noexcept {
    using std::swap;
    swap(a.data_, b.data_);
    swap(a.size_, b.size_);
}

分析一下为什么它是对的:

  • 自赋值安全:参数是按值传的副本,和 *this 无关。
  • 异常安全:唯一可能抛异常的地方是构造参数副本,那时 *this 还没被碰过。这叫强异常保证——要么成功,要么对象保持原状。
  • 代码复用:拷贝逻辑只写在拷贝构造函数里一处。
  • 附带好处:这一个函数同时处理了拷贝赋值和移动赋值,因为传入的实参是右值时,副本会由移动构造生成。

代价是多一次可能的移动,绝大多数情况下这个代价不值一提。

移动语义

移动的思想是:如果源对象马上就要销毁,那就不必复制它的资源,直接偷过来

C++Buffer(Buffer&& other) noexcept
    : data_(other.data_), size_(other.size_)
{
    other.data_ = nullptr;   // 关键:让源对象处于可安全析构的状态
    other.size_ = 0;
}

有两点特别值得注意。

移动构造要标 noexcept std::vector 在扩容时需要把元素搬到新内存。如果元素的移动构造可能抛异常,vector 就无法保证强异常安全,于是它会退而求其次用拷贝。一个漏写 noexcept 的移动构造函数,会让整个 vector 的扩容从 O(n) 次移动变成 O(n) 次拷贝——性能差别可能是几十倍,而且完全无声。

被移动后的对象必须处于"有效但未指定"的状态。 唯一的硬性要求是它能被安全地析构和赋值。不要在移动之后还去读它的值。

std::move 不移动任何东西

std::move 只是一个类型转换,把左值转成右值引用,告诉重载解析"可以选移动版本了"。它自己不产生任何代码:

C++std::string a = "hello";
std::string b = std::move(a);   // 这里才真正发生移动(在移动构造函数里)

运算符重载

运算符重载本质上就是函数,名字叫 operator+ 之类。

C++class Vec2 {
public:
    Vec2& operator+=(const Vec2& r) { x += r.x; y += r.y; return *this; }
    float x, y;
};

// 对称的二元运算符用非成员函数,这样左操作数也能参与隐式转换
inline Vec2 operator+(Vec2 l, const Vec2& r) { l += r; return l; }

几条经验规则:

  1. 先写复合赋值(+=),再用它实现二元版本(+)。 逻辑只有一份。
  2. 二元算术运算符写成非成员函数。 成员版本的左操作数不参与隐式转换,2 * vec 会编译不过,但 vec * 2 可以——这种不对称很别扭。
  3. 保留运算符原本的语义。 + 应该是可交换的且不修改操作数,== 应该是等价关系。违反读者预期的重载比不重载更糟。<< 用于输出流是个约定俗成的例外。
  4. 比较运算符在 C++20 里可以用 auto operator<=>(const Vec2&) const = default; 一行搞定,编译器会生成全部六个比较运算符。

关于 C++11 引入的移动语义、右值引用和完美转发的更多内容,见《C++ #9 C++11 新特性》

本系列文章

C++ 程序设计
  1. 01C++ #1 数据与内存
  2. 02C++ #2 Struct 和 Union
  3. 03C++ #3 指针与数组
  4. 04C++ #4 函数
  5. 05C++ #5 对象与类
  6. 06C++ #6 继承
  7. 07C++ #7 拷贝控制与运算符重载
  8. 08C++ #8 多态
  9. 09C++ #9 C++11 新特性
  10. 10C++ #10 C++14 新特性
  11. 11C++ #11 C++17 新特性