我第一次听到 Rule of 3。
是在一次线上事故复盘会上。
会议室里烟味很重。
日志也很重。
那位老前辈把一段代码甩到投影上。
struct Buf {
int* p = new int[4];
~Buf() { delete[] p; }
};
Buf a;
Buf b = a; // 这句当时没爆
他没讲大道理。
就说了一句。
你写了析构。 就等于承认你在手动养资源。 那拷贝这口锅,也得你自己背。
这话难听。
但那天的 core dump 更难看。
因为 b 只是把指针值抄了一遍。
两个人拿着同一把钥匙。
等作用域结束。
析构跑两次。
堆管理器就开始骂人。
那会儿还是 C++98。
没有 move。
库也没今天这么“替你擦屁股”。
所以那一代人总结得很直接。
别写一半。
写一半,等于给未来挖坑。
后来大家把这种经验写成了三句话。
叫 Rule of 0/3/5。
0. 先把三条规则说人话
先别被数字吓到。
它说的不是“写多少行”。
而是你要不要亲自管那几个特殊成员函数。
也就是这五个。
~T();
T(const T&);
T& operator=(const T&);
T(T&&) noexcept;
T& operator=(T&&) noexcept;
Rule of 3。
你如果亲自写了析构。
就等于承认你在手动养资源。
那拷贝这件事。
你也得亲自交代。
如果你不交代。
也不禁止。
那编译器就会按默认规则替你补上拷贝。
默认规则很朴素。
逐成员复制。
对指针来说。
就是把地址抄一遍。
struct Buf {
int* p = new int[4];
~Buf() { delete[] p; }
};
Buf a;
Buf b = a; // b.p == a.p
看起来没事。
但它其实已经把所有权搞乱了。
两个对象。
拿着同一块内存。
等作用域结束。
析构跑两次。
你就会得到 double free。
拷贝赋值也一样。
它会先把旧指针丢掉。
再把新指针抄过来。
泄漏和 double free 一起到齐。
不想交代也行。
那就把拷贝禁了。
struct Buf {
int* p = new int[4];
~Buf() { delete[] p; }
Buf(const Buf&) = delete;
Buf& operator=(const Buf&) = delete;
};
Rule of 5。
C++11 之后。
除了拷贝。
还多了“搬家”。
很多人会问。
那是不是从此就只有 Rule of 5。
Rule of 3 过时了。
不是。
Rule of 3 依然成立。
它说的是一件永恒的事。
你只要自己管资源。
你就必须把拷贝语义交代清楚。
不然默认浅拷贝就会让你翻车。
Rule of 5 只是把话补全。
在 Rule of 3 的基础上。
再加上移动语义。
你既然允许“搬家”。
那就别只签一半合同。
把 move 构造和 move 赋值也一起交代清楚。
怎么记最省事。
你只要写了析构。
脑子里先响起 Rule of 3。
拷贝要不要。
不想要就禁。
想要就把深拷贝写清楚。
然后再多问一句。
这个类型要不要能 move。
不想要也行。
你不用为了“凑五件套”去实现 move。
你只需要把态度写清楚。
我就是不支持搬家。
那就把两种 move 都禁了。
因为在 C++11 之后。
你一旦自己写了析构。
编译器往往就不会再白送你 move。
你想要搬家。
就按 Rule of 5。
把两种 move 都明确写出来。
实现。
或者 = default。
或者 = delete。
但 Rule of 5 真正想提醒你的。
不是“你必须手写五件套”。
而是你一旦开始自定义其中任何一个。
你就别假装另外四个不存在。
它们要么你实现。
要么你 = default。
要么你 = delete。
否则你很容易出现这种尴尬。
你以为自己写了 move。
其实只写了搬家协议的一半。
struct X {
X() = default;
X(X&&) = default;
};
X a;
X b;
a = std::move(b); // 这里需要 X& operator=(X&&)
这句很多人第一次见会愣一下。
因为你写了移动构造。
但移动赋值并不会自动送给你。
Rule of 5 说的是。
你得把这五件事一起交代清楚。
你如果允许资源转交所有权。
就把 move 也写清楚。
不然你的类型要么很难进容器。
要么处处退化成昂贵的拷贝。
struct Buf {
std::size_t n = 4;
int* p = new int[n];
~Buf() { delete[] p; }
Buf(const Buf&) = delete;
Buf& operator=(const Buf&) = delete;
Buf(Buf&& o) noexcept : n(o.n), p(o.p) {
o.n = 0;
o.p = nullptr;
}
Buf& operator=(Buf&& o) noexcept {
if (this != &o) {
delete[] p;
n = o.n;
p = o.p;
o.n = 0;
o.p = nullptr;
}
return *this;
}
};
Rule of 0。
今天最划算的做法。
是你根本不用碰 new/delete。
把资源塞进 std::vector、std::string、std::unique_ptr 这些现成的房东。
然后你一个特殊成员函数都别写。
默认行为就会是正确行为。
struct Job {
std::string name;
std::vector<int> data;
};
它们不是戒律。
更像事故统计。
你见过足够多的翻车。
你就会知道哪些地方别赌。
说到底。
就一个问题。
这块资源到底谁负责。
负责到什么时候。
1. 这三条规则到底在保护什么
保护的不是“代码风格”。
保护的是一致性。
你把资源放进类型里。
你就得把三件事说清楚。
析构的时候谁收尾。
拷贝的时候你到底想复制值。
还是复制所有权。
赋值的时候旧资源谁去善后。
你不写。
编译器就按默认规则来。
默认规则很朴素。
逐成员复制。
struct Buf {
std::size_t n = 4;
int* p = new int[n];
~Buf() { delete[] p; }
};
Buf a;
Buf b = a; // b.p == a.p
这不是第二份数据。
这是同一个地址。
所以最后就是两次 delete[]。
你如果真想要值语义。
那就把“深拷贝”写进类型。
Buf(const Buf& o)
: n(o.n), p(new int[o.n]) {
std::copy(o.p, o.p + o.n, p);
}
你如果想要独占所有权。
那也别含糊。
要么禁拷贝。
要么交给 std::unique_ptr。
让类型系统替你踩刹车。
拷贝不是动作。
它是契约。
2. Rule of 3 的来历:C++98 的真实世界
在 C++98 那个年代。
库没今天这么强。
你想让资源体面退场。
很多时候只能靠自己。
new/delete。
open/close。
写析构只是第一步。
拷贝才是事故集中地。
比如文件描述符。
struct Fd {
int fd = -1;
explicit Fd(int fd) : fd(fd) {}
~Fd() { if (fd != -1) ::close(fd); }
};
Fd a(::open("a.txt", O_RDONLY));
Fd b = a; // 两次 close
第一次 close。
还算体面。
第二次 close。
就看运气。
有时只是 EBADF。
有时你关掉了别人刚复用的 fd。
然后你半夜起来背锅。
所以 Rule of 3 并不是“鼓励你多写三个函数”。
它是在说。
你既然接管了释放。
那就把拷贝和赋值也一起接管。
不然编译器会按默认规则来。
默认规则对指针和句柄一视同仁。
都只是值复制。
3. 为什么“只写深拷贝构造”还不够
我见过不少人把“深拷贝”补在拷贝构造里。
然后就觉得收工。
结果栽在 b = a; 上。
struct Buf {
std::size_t n;
int* p;
explicit Buf(std::size_t n) : n(n), p(new int[n]) {}
~Buf() { delete[] p; }
Buf(const Buf& o) : n(o.n), p(new int[o.n]) {
std::copy(o.p, o.p + o.n, p);
}
};
看起来挺完整。
但你写到这句。
Buf a(4);
Buf b(8);
b = a; // b 原来的那块内存,就没人管了
默认赋值只会把指针覆盖掉。
旧指针丢了。
泄漏就这么来的。
老派项目里常用一招。
copy-and-swap。
先拿到一份安全的副本。
再跟自己交换。
friend void swap(Buf& x, Buf& y) noexcept {
std::swap(x.n, y.n);
std::swap(x.p, y.p);
}
Buf& operator=(Buf other) {
swap(*this, other);
return *this;
}
你会觉得它像绕路。
但绕路是为了不翻车。
4. Rule of 5:移动语义把世界改了
我印象最深的不是标准条文。
是那阵子“升级工具链”的狼狈。
一边有人催着上 C++11。
一边老代码一跑就冒烟。
评审的时候最常见的画面是。
大家盯着一行看似无害的代码。
然后突然开始较劲。
“你这是想复制。”
“还是想把东西交出去。”
听起来像抬杠。
但抬杠的背后其实是老时代留下的肌肉记忆。
在 C++98/03 那会儿。
没有“搬家”这个概念。
大对象要走。
基本就是复印。
一页一页抄。
抄得风扇都起飞。
你不想复印也行。
那就得用各种办法绕开。
传指针。
传引用。
到处 swap。
能不动就不动。
所以你只要写出一个“带堆内存”的小玩意。
光是让它在 std::vector 里不出事。
就能把人折腾到夜宵凉透。
struct Buf {
std::size_t n;
int* p;
explicit Buf(std::size_t n) : n(n), p(new int[n]) {}
~Buf() { delete[] p; }
};
这段代码看着朴素。
但它其实已经在跟你摊牌。
你写了析构。
你就是在认领一份责任。
那后面两件事。
你得把话说清楚。
拷贝怎么拷。
赋值怎么赋。
你不说。
编译器就按默认浅拷贝来。
然后你就会在复盘会上听见自己的名字。
C++11 之后。
桌上又多摆了一张合同。
能不能搬家。
如果能。
搬家时候行李怎么交接。
这才是 Rule of 5 的来历。
不是“鼓励你多写函数”。
是你开始进现代社会了。
以前我们靠复印机活着。
现在终于有搬家公司了。
说到“在旧世界里硬拧出新语义”。
就绕不开一个老古董。
std::auto_ptr。
它当年干了一件很大胆的事。
用“拷贝”来转交所有权。
第一次见的人往往会笑。
觉得这招挺机灵。
std::auto_ptr<int> p(new int(1));
std::auto_ptr<int> q = p; // p 变空
看起来像拷贝。
但你回头一看。
源对象已经被掏空了。
这事在函数里还好。
在容器里就出大事。
容器做扩容。
它以为自己在“复制元素”。
复制完原来的还在。
结果 auto_ptr 复制一次。
就把老对象的指针搬走一次。
你会看到一种很魔幻的景象。
vector 里有些元素突然变成空。
还不吭声。
所以标准后来对它的评价也很直白。
别进容器。
别装成值类型。
unique_ptr 就是从这堆事故里长出来的。
它可以搬。
但它拒绝拷。
拒绝得非常明确。
你一眼就能看出来。
回到 Rule of 5。
移动语义的本质。
其实就一句话。
“资源不复制。”
“所有权转交。”
所以最短的移动构造。
一般就是把指针挪过来。
再把对方置空。
Buf(Buf&& o) noexcept
: n(o.n), p(o.p) {
o.n = 0;
o.p = nullptr;
}
你别嫌它土。
土归土。
但它把所有权交接讲得很清楚。
接下来最容易翻车的是。
很多人写完移动构造。
就以为“我已经支持 move 了”。
然后在这句上被教育。
Buf a(4);
Buf b(8);
a = std::move(b);
a = std::move(b) 走的是移动赋值。
不是移动构造。
你只写了 Buf(Buf&&)。
那这句就没合同可用。
这就是 Rule of 5 里“5”的实际含义。
你别只签半张。
构造是合同。
赋值也是合同。
移动赋值长什么样。
也很短。
先把自己的旧资源收掉。
再接手对方的。
最后把对方清空。
Buf& operator=(Buf&& o) noexcept {
if (this != &o) {
delete[] p;
n = o.n;
p = o.p;
o.n = 0;
o.p = nullptr;
}
return *this;
}
这里还有个更“现代一点”的写法。
用 std::exchange。
少写两行。
也更不容易漏。
Buf(Buf&& o) noexcept
: n(std::exchange(o.n, 0)),
p(std::exchange(o.p, nullptr)) {}
再说一个很多新同学不知道的坑。
也是我见过最多的“怎么突然不能 move 了”。
你写了析构。
然后把拷贝禁了。
你以为你得到的是“只可移动”。
实际得到的是“啥也不让干”。
struct OnlyDtor {
~OnlyDtor() {}
OnlyDtor(const OnlyDtor&) = delete;
OnlyDtor& operator=(const OnlyDtor&) = delete;
};
OnlyDtor a;
OnlyDtor b = std::move(a); // 也不行
原因很现实。
你一旦自己声明了析构。
编译器就会变得谨慎。
它不再“顺手送你”移动构造和移动赋值。
你又把拷贝删了。
那就两边都堵死。
类型直接变成“不能搬也不能复印”。
所以 Rule of 5 在提醒你。
你只要开始碰特殊成员函数。
就别假装另外几个不存在。
它们要么你明确 = default。
要么你明确 = delete。
要么你就老老实实实现。
最后再补一个高手才会在意的细节。
noexcept。
你会发现我上面所有 move 都写了 noexcept。
不是为了好看。
是为了让容器敢用。
std::vector 扩容的时候。
要把旧元素搬到新内存。
它有一个很保守的底线。
“我宁愿慢一点。”
“也别在中途抛异常把半个数组搬丢了。”
所以 move 可能抛异常时。
vector 会倾向于用 copy 来保持强异常保证。
你把 copy 删了。
move 又不是 noexcept。
那它就只能报错。
你会觉得容器很倔。
但你站在容器的角度想想。
它是在替你兜底。
所以写资源类的时候。
move 不仅要写。
还最好写成不会抛。
能 noexcept 就 noexcept。
说到底。
Rule of 5 不是在鼓励你手写五件套。
它是在提醒你。
当你决定自己养资源。
你就别把“搬家这件事”交给默认行为去赌。
你要么把合同写全。
要么。
下一节。
我们就聊聊更省心的那条路。
5. Rule of 0:最好的资源类,是你不用写的资源类
Rule of 5 讲到最后。
很多人会把笔一扔。
说。
“懂了。”
“以后我就五件套拉满。”
我一般会把他笔捡起来。
再放回去。
我说别急。
你写那五个东西。
不是为了凑齐。
是因为你没得选。
Rule of 0 的意思刚好相反。
它在教你怎么把“没得选”变成“我不需要选”。
别自己养资源。
把资源交给已经写好合同的人。
你只写业务。
剩下的交给成员。
交给编译器。
听起来像甩锅。
但这是 C++ 最靠谱的甩锅。
你自己写五件套。
你是在跟标准库抢工作。
抢赢了也没奖金。
最典型的业务类。
其实就该长这样。
struct Job {
std::string name;
std::vector<int> data;
};
你看。
这里面没有 new。
也没有 delete。
你不需要写析构。
也不需要写拷贝。
更不需要写 move。
编译器会替你把那五个特殊成员函数生成出来。
而且往往比你手写更“对”。
它知道哪些成员能 move。
知道哪些成员必须拷贝。
也知道哪些操作是 noexcept。
你手写一套。
等于把这些信息全抹平。
然后你就开始补丁式修修补补。
最累的那种。
还有一个老误会。
很多人把 Rule of 0 听成。
“不写析构就没有析构。”
不是。
你不写。
编译器会写。
而且它写得很勤快。
它会挨个调用成员的析构。
struct Job {
std::string name;
std::vector<int> data;
};
name 自己清内存。
data 自己清那一堆元素。
你就别插手。
你插手。
很多时候还会把编译器本来能给你的 move 拿走。
你说你图啥。
Rule of 0 真正想禁止的。
其实不是“写函数”。
而是“裸资源塞进业务类型”。
比如这种。
struct Bad {
int* p = new int[4];
};
这个类型你什么都不写。
也确实是 0。
但这是另一种意义上的 0。
0 个释放动作。
内存直接漏到天荒地老。
默认析构不会替你 delete[]。
它只会把指针变量本身销毁掉。
指针指向的那块堆内存。
没人认领。
所以 Rule of 0 的正确姿势是。
你要的不是“别写析构”。
你要的是“别用裸指针表达所有权”。
你真要一块堆数组。
先把所有权写清楚。
struct Buf {
std::unique_ptr<int[]> p;
explicit Buf(std::size_t n) : p(new int[n]) {}
};
这时候你会得到一种很爽的感觉。
你没写任何特殊成员函数。
但类型的行为已经很干净。
不能拷贝。
可以搬家。
你甚至不用跟同事解释。
编译器会替你解释。
它会把“拷贝被删除了”这句话。
直接塞进报错里。
如果你连 unique_ptr 都不想管。
你只是想要一段连续内存。
那就更别自己写资源类。
用 std::vector。
struct Buf {
std::vector<int> data;
explicit Buf(std::size_t n) : data(n) {}
};
这就是真正的主路。
你写业务。
容器写资源管理。
类型天然就能拷贝。
也天然就能 move。
能进 std::vector。
能做返回值。
性能也不会差到哪去。
因为标准库那帮人。
早就替你被骂过了。
当然。
Rule of 0 也不是一句“从此不准写析构”。
有些场景你写了是为了语义。
不是为了释放。
比如多态基类那条生死线。
再比如某些类型要做注销。
要归还。
要解锁。
这些我们下一节接着聊。
6. 一条经验:你在写“业务类”,还是在写“资源类”
很多纠结来自一个混淆。
你以为你在写业务对象。
其实你在写资源管理器。
我一般只问一句。
这个类型析构的时候。
会不会做“释放资源”这种动作。
如果不会。
那它就是业务对象。
struct User {
std::string id;
std::string nick;
};
这种类型就别写析构。
你写了也没意义。
还容易把编译器送你的 move 拿走。
当然也别把这句话听歪。
有些类型你必须写出析构。
哪怕只是 = default。
比如多态基类。
struct Base {
virtual ~Base() = default;
};
因为你很可能会这么用。
Base* p = new Derived;
然后 delete p;。
这时候析构是不是 virtual。
就是生死线。
还有一种情况。
你的析构里要做注销。
要归还。
要关 fd。
要解锁。
那你就别再把自己当业务类了。
你是在写资源类。
如果会。
那它就是资源类。
struct Fd {
int fd = -1;
~Fd() { if (fd != -1) ::close(fd); }
};
你写了析构。
就等于你签了字。
后面的 copy/move。
你得负责。
如果你不想负责。
那就回到 Rule of 0。
别让业务类直接碰裸资源。
把资源装进能托付的类型里。
业务类就会轻。
也更好维护。
7. 写在最后
Rule of 0/3/5 这三句话。
你可以当成口诀。
也可以当成一张路线图。
但我更愿意把它当成“事故后留下的路标”。
因为它的出发点从来不是优雅。
也不是洁癖。
是事故。
是线上凌晨三点。
你盯着 core dump。
然后突然意识到。
“原来我只把契约写了一半。”
所以我现在只坚持一件事。
别跟编译器赌。
你要么把契约写全。
要么干脆不写。
把资源交给已经写全的人。