这篇笔记主要写一下对象被复制、移动、销毁时发生了什么,以及运算符重载。这些是 C++ 里最容易写出"能跑但有问题"的代码的地方。
编译器替你生成的那些函数
对于任何一个类,编译器在需要时会自动生成六个特殊成员函数:
C++class Widget {
Widget(); // 默认构造
~Widget(); // 析构
Widget(const Widget&); // 拷贝构造
Widget& operator=(const Widget&); // 拷贝赋值
Widget(Widget&&) noexcept; // 移动构造
Widget& operator=(Widget&&) noexcept; // 移动赋值
};
生成出来的版本做的都是逐成员操作:逐成员拷贝、逐成员移动。对于只持有 int、std::string、std::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 代替裸指针,然后六个函数全部交给编译器。上面的 Buffer 用 std::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; }
几条经验规则:
- 先写复合赋值(
+=),再用它实现二元版本(+)。 逻辑只有一份。 - 二元算术运算符写成非成员函数。 成员版本的左操作数不参与隐式转换,
2 * vec会编译不过,但vec * 2可以——这种不对称很别扭。 - 保留运算符原本的语义。
+应该是可交换的且不修改操作数,==应该是等价关系。违反读者预期的重载比不重载更糟。<<用于输出流是个约定俗成的例外。 - 比较运算符在 C++20 里可以用
auto operator<=>(const Vec2&) const = default;一行搞定,编译器会生成全部六个比较运算符。
关于 C++11 引入的移动语义、右值引用和完美转发的更多内容,见《C++ #9 C++11 新特性》。