一、从 Rule of Three 到 Rule of Five
我记得刚学 C++ 那会儿,老工程师跟我说:「你写类的时候,只要涉及动态资源,就得记住 Rule of Three。」那时候我还不太理解,直到自己踩了坑才明白——析构函数、拷贝构造函数、拷贝赋值运算符,这三兄弟缺一不可。
但 C++11 引入了移动语义之后,事情变了。现在我们有五件套:
- 析构函数
- 拷贝构造函数
- 拷贝赋值运算符
- 移动构造函数
- 移动赋值运算符
这就是所谓的 Rule of Five(五法则)。说白了,只要你定义了析构函数(意味着你在管理资源),那你就应该把这五个函数都考虑清楚。
核心原则:要么一个都不定义(让编译器帮你生成),要么五个都定义清楚。别只定义其中两三个,那样容易出问题。
二、为自定义类实现移动操作
咱们直接看代码。假设我有一个 StringBuffer 类,管理一块动态内存:
class StringBuffer {
public:
// 构造函数
StringBuffer(size_t size = 0)
: size_(size), data_(size ? new char[size] : nullptr) {}
// 析构函数
~StringBuffer() { delete[] data_; }
// 拷贝构造函数
StringBuffer(const StringBuffer& other)
: size_(other.size_),
data_(other.size_ ? new char[other.size_] : nullptr) {
if (data_) {
memcpy(data_, other.data_, size_);
}
}
// 拷贝赋值运算符
StringBuffer& operator=(const StringBuffer& other) {
if (this != &other) {
delete[] data_;
size_ = other.size_;
data_ = size_ ? new char[size_] : nullptr;
if (data_) {
memcpy(data_, other.data_, size_);
}
}
return *this;
}
// 移动构造函数 —— 重点来了
StringBuffer(StringBuffer&& other) noexcept
: size_(other.size_), data_(other.data_) {
// 把源对象置为「空」状态
other.size_ = 0;
other.data_ = nullptr;
}
// 移动赋值运算符
StringBuffer& operator=(StringBuffer&& other) noexcept {
if (this != &other) {
// 释放当前资源
delete[] data_;
// 窃取源对象的资源
size_ = other.size_;
data_ = other.data_;
// 把源对象置为空
other.size_ = 0;
other.data_ = nullptr;
}
return *this;
}
private:
size_t size_;
char* data_;
};
你注意看移动构造函数——它做的事情其实很简单:偷走别人的资源,然后让别人变成空壳。没有内存分配,没有深拷贝,就是指针的搬运工。我在项目中遇到过,一个包含大数组的类,从拷贝改成移动后,性能提升了将近 10 倍。
小技巧:移动操作记得加 noexcept。为什么?因为标准库容器(比如 std::vector)在重新分配内存时,如果移动操作是 noexcept 的,它会优先用移动而不是拷贝。否则,它只能老老实实走拷贝路径。
三、移动语义与 Pimpl 惯用法
Pimpl(Pointer to Implementation)是个老套路了——把实现细节藏到指针后面,减少编译依赖。但移动语义出现后,Pimpl 用起来更顺手了。
我以前写 Pimpl 类,最头疼的就是拷贝。每次拷贝都得 new 一个实现对象,然后深拷贝所有成员。有了移动语义,事情简单多了:
// widget.h
class Widget {
public:
Widget();
~Widget();
// 移动操作 —— 只需要移动指针
Widget(Widget&& other) noexcept;
Widget& operator=(Widget&& other) noexcept;
// 拷贝操作 —— 需要深拷贝实现对象
Widget(const Widget& other);
Widget& operator=(const Widget& other);
void doSomething();
private:
struct Impl;
std::unique_ptr<Impl> pImpl_;
};
// widget.cpp
struct Widget::Impl {
std::string name;
std::vector<int> data;
// ... 其他复杂成员
};
Widget::Widget() : pImpl_(std::make_unique<Impl>()) {}
Widget::~Widget() = default; // unique_ptr 自动处理
// 移动操作 —— 简单到令人发指
Widget::Widget(Widget&& other) noexcept
: pImpl_(std::move(other.pImpl_)) {}
Widget& Widget::operator=(Widget&& other) noexcept {
pImpl_ = std::move(other.pImpl_);
return *this;
}
// 拷贝操作 —— 需要自己实现深拷贝
Widget::Widget(const Widget& other)
: pImpl_(std::make_unique<Impl>(*other.pImpl_)) {}
Widget& Widget::operator=(const Widget& other) {
*pImpl_ = *other.pImpl_;
return *this;
}
你看,移动操作就是 std::move 一下指针的事。而拷贝操作,得老老实实 new 一个 Impl 出来。这就是移动语义给 Pimpl 带来的好处——移动几乎零成本,拷贝该多重还是多重。
注意:用 std::unique_ptr 管理 Pimpl 指针时,析构函数不能在头文件里定义(因为 unique_ptr 的析构需要看到完整类型)。要么在 .cpp 里定义析构函数,要么用 ~Widget() = default; 声明在 .cpp 中。
四、知识体系总览
下面这张图帮你理清本章的核心脉络:
五、避坑指南
我这些年写移动语义,踩过不少坑。挑几个典型的说说:
- 移动后对象还能用吗? 能,但只能做「安全」的操作——比如赋值、析构。别假设它还有有效数据。
- 自移动赋值 —— 就是
a = std::move(a)。虽然很少见,但万一出现呢?所以移动赋值运算符里最好加个if (this != &other)判断。 - 忘记 noexcept —— 我之前有个类没加 noexcept,结果
std::vector扩容时硬是走了拷贝路径,性能直接崩了。排查了半天才发现是这个问题。 - Pimpl 的析构位置 —— 前面说了,
unique_ptr析构需要完整类型。如果你在头文件里写~Widget() = default;,编译器会报错。得在 .cpp 里写。
一句话总结:Rule of Five 不是让你盲目写五个函数,而是让你想清楚——你的类到底需不需要自定义这些操作。如果需要,那就五个都考虑到位。移动操作的核心就是「偷资源、留空壳」,配合 Pimpl 用起来特别顺手。
公众号:蓝海资料掘金营,微信 deep3321