写 C++ 写了几年的人,多半见过两种迥异风格:一个类里析构函数手写 delete,拷贝构造、拷贝赋值、移动构造、移动赋值也跟着写了一大堆;另一种类里几乎不见 new/delete,成员全是 string、vector、unique_ptr 这类标准库组件,编译器生成的析构、拷贝、移动就已经够用了。前者踩的是五规则这条线,后者就是零规则的典型形态。这两个“规则”并不是互相替代的关系,而是同一套资源管理思路的两端:你到底需要亲自写几个特殊成员函数,还是干脆一个都不写,让 RAII 类型替你把所有脏活干完。这篇文章想把五规则和零规则拆开讲清楚,包括它们背后的原理、代码到底长什么样、实际项目里怎么选型,以及我在开发过程中真踩过的那些坑。无论你是对特殊成员函数只有模糊印象的新手,还是已经写了很久业务代码的老手,这篇文章应该都能给你一套可以“抄作业”的判断口径。
1. 特殊成员函数与 RAII:先把基础拼图摆齐
1.1 五个特殊成员函数,到底在说谁
很多初学者一上来就直接背“五规则”,却不清楚五规则里那五个函数是谁。实际上 C++ 里能够被编译器隐式生成的特殊成员函数不止五个,除了默认构造函数,还包括:析构函数、拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符。五规则关心的是后面这五个,因为默认构造函数通常和资源所有权没有直接关系——就算一个类没有默认构造函数,它照样可能存在资源管理的问题。
编译器隐式生成行为的关键在于:这些函数不是总是被生成,也不是想当然的“只要你没写,它就会自动出现”。例如你写了一个带参数的构造函数,默认构造函数就不会被隐式生成;但你声明了析构函数,拷贝构造和拷贝赋值还是会被隐式生成,只是这种行为在 C++11 之后被视为弃用;移动构造和移动赋值一旦你显式声明了析构函数,就不会再生成。这些细节是判断“这个类是否安全”的基础,也是为什么五规则这一整套说法在 C++11 引入移动语义之后变得比原来的三规则更重要。
把编译器隐式生成的默认行为理解成“逐成员操作”是一个很实用的记忆方法。默认的拷贝构造就是拿右边对象的每个成员去拷贝构造左边的对象;默认的移动构造就是拿右边对象的每个成员去移动构造左边的对象;默认的析构函数就是依次调用每个成员的析构函数。逐成员操作本身很合理,问题是当成员里有裸指针这类“值语义很弱”的东西时,逐成员复制就变成了浅拷贝,逐成员析构就可能导致同一块资源被释放两次。这一点是所有资源管理问题的源头,也是五规则要存在的根本原因。
1.2 RAII:资源这东西,必须和生命周期绑定
RAII 的中文叫“资源获取即初始化”,说起来玄乎,本质就是一句话:资源在对象构造时拿到,在对象析构时释放,两者绑定在同一个作用域生命周期上。标准库里的 std::string、std::vector、std::unique_ptr、std::lock_guard 都是 RAII 类型。你不需要手动管理 string 内部那堆字符数组,vector 扩容时的内存分配也有它自己负责,锁加在 scoped_lock 构造时、释放作用域结束时会自动解锁。这套机制天然规避了“忘了释放”“提前释放”“释放两次”这类问题。
生活类比是这样的:你向图书馆借了一本书,图书馆给了你一张借书卡。RAII 类型的对象就像是那张卡,书本身则像是堆内存或者文件句柄。如果你把卡复印一份给另一个人,两个人都拿着同一张卡去还书,图书馆就会懵——到底是谁还的,书到底还算不算被占用。裸指针浅拷贝后的麻烦就是这个道理:两个对象保存同一个地址,析构时各释放一次,double free 就出现了。
所以 RAII 的核心观点是:资源绝不裸奔,它必须由一个明确的对象持有和负责。如果某个类直接持有一个裸指针,那么那个类实际上承担了“资源管理类”的角色,它就需要认真对待析构、拷贝、移动这一整套函数。反之,如果类里面全是已经成熟的 RAII 类型,那这个类就只是一个“资源使用者”,根本不需要亲自处理资源释放。
1.3 为什么“我不写”不等于“系统会处理”
最常见的误解是:只要不写析构函数,编译器就会自动处理内存。这句话只对了一半。编译器确实会生成析构函数,但它只会逐成员地析构;如果你的成员是裸指针,它只会释放这个指针变量本身,不会释放指针指向的内存。所以“不写”不等于“处理好”,关键看你手里握着的是 RAII 成员还是裸资源。
看这个例子:
struct RawList { int* items; ~RawList() { delete[] items; } }; void demo() { RawList a; RawList b = a; // 隐式浅拷贝,两个对象指向同一块内存 } // a、b 析构时对同一块内存 delete 两次,未定义行为这个代码在 C++98 时代就是经典 bug 模板:写了析构函数释放内存,但没写拷贝构造,结果别人随便一拷贝就炸。C++11 之前大家提的是三规则:如果你自定义了析构、拷贝构造或者拷贝赋值其中之一,另外两个往往也要一起处理。C++11 引入移动语义后升级成五规则,因为移动构造和移动赋值如果缺位,资源转移就会退化成拷贝,轻则性能损失,重则语义错误。认清这个演变过程,才能理解后面零规则的真正价值。
2. 五规则:把人肉管理的资源写对
2.1 什么情况下你不得不亲自上手
零规则不是万能的,现实世界里总有一些资源是标准库没有现成 RAII 包装的。比如操作系统返回的文件描述符、第三方 C 库返回的不透明句柄、自定义顶点缓冲区的裸内存块,这些在标准库里找不到直接对应的 string 或智能指针。此时如果不想让资源泄漏,就必须自己写一个资源管理类,这时候五规则就是基本要求。
判断标准其实很简单:只要类里存在一个“裸资源”,而且这个裸资源的生命周期由当前类负责,你就不能只靠编译器默认生成的拷贝构造和析构函数。默认拷贝构造会浅拷贝裸资源地址,默认析构会释放资源,两者一组合就是 double free。反而是移动操作如果能正确搬走裸资源地址,再置空原对象,还相对安全一点。但问题在于,只写移动不写拷贝,类就变得不可拷贝;只写拷贝不写移动,右值场景又退化成深拷贝。五规则之所以叫五规则,是因为这五个函数一起定义,才能把语义完整地确定下来。
2.2 一个完整的五规则实现长什么样
我用一个自己管理堆内存的 RawBuffer 来演示。它在语义上类似于极简版的 std::vector ,但故意不用标准库容器,就是为了展示手工实现时到底要处理哪些事情。
class RawBuffer { int* ptr_ = nullptr; std::size_t size_ = 0; void release() noexcept { delete[] ptr_; ptr_ = nullptr; size_ = 0; } public: RawBuffer() = default; explicit RawBuffer(std::size_t n) : ptr_(n > 0 ? new int[n] : nullptr), size_(n) {} RawBuffer(const RawBuffer& other) : ptr_(other.size_ > 0 ? new int[other.size_] : nullptr), size_(other.size_) { if (ptr_) { std::copy(other.ptr_, other.ptr_ + size_, ptr_); } } RawBuffer(RawBuffer&& other) noexcept : ptr_(other.ptr_), size_(other.size_) { other.ptr_ = nullptr; other.size_ = 0; } RawBuffer& operator=(const RawBuffer& other) { if (this == &other) return *this; RawBuffer tmp(other); swap(tmp); return *this; } RawBuffer& operator=(RawBuffer&& other) noexcept { if (this == &other) return *this; release(); ptr_ = other.ptr_; size_ = other.size_; other.ptr_ = nullptr; other.size_ = 0; return *this; } ~RawBuffer() { release(); } void swap(RawBuffer& other) noexcept { using std::swap; swap(ptr_, other.ptr_); swap(size_, other.size_); } int* data() noexcept { return ptr_; } const int* data() const noexcept { return ptr_; } std::size_t size() const noexcept { return size_; } };拆开来看细节:析构函数调 release,释放当前对象持有的内存。拷贝构造里先分配新内存,再用 std::copy 把原数据拷过来,这是真正意义上的深拷贝。拷贝赋值采用“临时对象加交换”的方式,先用 other 造一个临时副本,如果拷贝过程中抛异常,当前对象保持原样没有任何损失;副本构造成功后 swap,临时对象析构时会带着旧内存一起释放。移动构造和移动赋值直接搬走指针,然后把源对象置空。这样的实现同时保证了异常安全和资源唯一性。
2.3 为什么移动函数必须标记 noexcept
RawBuffer 的移动构造函数和移动赋值我都标了 noexcept。这不是习惯问题,而是 C++ 标准库容器面对“可能抛异常的移动”会做出完全不同的选择。std::vector 扩容时,如果元素的移动构造函数是 noexcept,vector 会优先把老元素移动过去;如果移动构造函数可能抛异常,vector 不能冒险在搬一半时发生异常破坏数据完整性,就只能改成拷贝构造。深拷贝的开销显然远高于移动,所以一个没标 noexcept 的移动构造,在很多场景下会退化成没有价值的深拷贝。
有朋友可能会问,移动构造里不是只交换了几个指针吗,怎么可能抛异常?问题在于“可能”。编译器看不到你函数体内部会不会 throw,它只看声明里有没有 noexcept。如果你不标,保守的标准库实现就会假设它可能抛。标记 noexcept 的本质是向编译器承诺“这个操作不会抛异常”,从而让容器和算法敢于选择移动路径。
2.4 五规则的代价:代码量、维护量和出错率
五规则看起来清晰,但真实项目里写起来并不轻松。上面这个 RawBuffer 还只是最朴素的资源类型,如果再加上自定义删除器、线程安全、分配器对齐、引用计数、日志审计,五个成员函数里的每一个都会膨胀。而且这些函数之间彼此耦合:改了移动赋值里的资源转移逻辑,可能忘了同步移动构造函数里的置空逻辑;写了 copy-and-swap 的复制赋值,又要小心它和移动赋值同时存在时不会重载冲突。
所以在团队协作中我对五规则的定位是:它是“兜底技能”,不是“默认偏好”。能在零规则下做成的设计,就不要主动跳到五规则。只有那些标准库实在替代不了的资源封装,才值得付出这些代码量和心智成本。
3. 零规则:能不写就不写,让 RAII 成员接管一切
3.1 零规则的核心定义
零规则这个名字听起来像一个具体的技术规则,其实更像一种设计取向:让你的类只包含本身已经具备完整生命周期管理能力的成员类型,因此不需要为这个类手动声明任何特殊成员函数。编译器自动生成的析构、拷贝、移动,就足以完成所有成员的正确处理。
为什么编译器生成的版本能做到正确?因为你选择的成员类型都不是裸资源。std::string 的拷贝是深拷贝,std::vector 的移动是 O(1) 指针交换,unique_ptr 的拷贝被显式删除,shared_ptr 的拷贝维护引用计数。当每个成员都对自己的资源负责时,类的整体资源管理就是这些成员行为的组合,根本不需要你额外写一行释放代码。
3.2 一个业务类如何做到零规则
假设我们要写一个用户资料类,里面有用户名、权限列表、头像和审计日志。头像是一份独占资源,日志则允许多个对象共享同一份实例。来自动手的话,这个类要写的特殊成员函数至少六七个;但用零规则可以直接这样:
class UserProfile { std::string name_; std::vector<Permission> permissions_; std::unique_ptr<Avatar> avatar_; std::shared_ptr<AuditLog> audit_; public: UserProfile(std::string name, std::vector<Permission> perms) : name_(std::move(name)), permissions_(std::move(perms)) {} void setAvatar(std::unique_ptr<Avatar> avatar) { avatar_ = std::move(avatar); } };这个类里没有析构函数,没有拷贝构造,没有移动构造,什么都没有。编译器生成的析构函数会依次析构 name_、permissions_、avatar_、audit_,内存全部释放干净。因为成员里有 unique_ptr,整个 UserProfile 类天然不可拷贝;移动构造会逐成员移动每个资源,像一个打包好的搬家流程。想让它可拷贝,就把 unique_ptr 换成 shared_ptr ,或者把头像改成值类型 Avatar,然后编译器又会自动生成深拷贝语义。这种“通过成员类型来塑造类语义”的做法,比手写五规则那个大函数簇要优雅得多。
3.3 哪些标准库组件是零规则的好零件
零规则的可行性依赖一套可靠的 RAII 组件。最常用的组合大概是这几类:字符串和容器,比如 std::string、std::vector、std::map、std::unordered_set,它们负责动态内存,拷贝深、移动高效;所有权智能指针,比如 std::unique_ptr 表示独占所有权且禁止拷贝,std::shared_ptr 表示共享所有权并维护引用计数;同步锁,比如 std::lock_guard、std::unique_lock,让互斥量的加解锁跟随作用域;文件流,比如 std::fstream 在析构时自动关闭文件。把这些组件组合起来,一个新类基本不需要碰 new/delete。
有一个容易忽略的点:哪怕是第三方的裸句柄,也可以自己写一个极小的 RAII 包装器,比如用 std::unique_ptr 配合自定义删除器。一旦把裸句柄包进 unique_ptr,外部业务类就不需要再关心句柄释放,反射性地满足零规则。
3.4 零规则避免了哪些幺蛾子
零规则最大的价值不是“少写代码”这种表面好处,而是从结构上消灭了一整类 bug。手写五规则时常见的 double free、浅拷贝、移动后源对象悬空、忘记置空原点位、拷贝赋值里先释放再分配导致异常安全问题,在零规则类里根本不会出现,因为编译器生成的默认操作已经由标准库组件承接。
我在团队评审代码时经常按这个逻辑问:这个类里有没有出现裸 new 或者裸 delete?如果有,要解释清楚为什么不能用 RAII 成员替代。如果没有,那就闭嘴别写析构函数。这条简单问题能挡掉大量不必要的资源管理代码,也让 review 的重点回归到业务逻辑上。
4. 零规则还是五规则:先别急着写析构函数
4.1 决策路径,我平时这样走
当我要给一个新类做资源设计,通常会按下面这个路径走一遍。首先看这个类是否持有裸资源,比如裸指针、文件句柄、套接字,或者任何需要手动释放的东西。如果没有,这个类大概率就是零规则,编译器替你处理一切。如果持有裸资源,再看能不能用标准库 RAII 组件替换。比如裸 int* 可以换成 std::vector ,裸所有权指针可以换成 std::unique_ptr 或 std::shared_ptr ,文件句柄可以封装进一个带自定义删除器的 unique_ptr。只要替换成功,仍然走零规则。如果资源特殊,标准库没有任何合适的封装,才轮到五规则出场。
这背后的逻辑是:写五规则相当于你亲自当资源的管家,管家越多,房间越容易乱。RAII 组件的优势在于它们已经被标准库打磨得很成熟,边界情况、异常安全、性能特性都经过长期验证,比自己手写那五个函数可靠得多。
4.2 一张表理清选择依据
| 类成员的形态 | 推荐路线 | 主要原因 |
|---|---|---|
| 只有值成员、标准容器、智能指针 | 零规则 | 编译器逐成员处理已经正确,无裸资源 |
| 标准库组件无法包装的不透明资源句柄 | 五规则 | 需要自己定义资源生命周期和转移语义 |
| 成员里有 unique_ptr 但不想被禁止拷贝 | 手动定义拷贝或换 shared_ptr | unique_ptr 默认删除拷贝,改用共享所有权 |
| 多态基类,需要通过基类指针释放 | virtual 析构 + 不可拷贝 | 多态释放需要虚析构,拷贝还容易造成切片 |
| 只需要自定义析构,不需要资源转移 | 五规则或显式删除拷贝移动 | 避免隐式浅拷贝引发 double free |
这张表不是死规定,但能覆盖我工作中九成以上的情况。剩下那不到一成,基本都涉及外部系统集成或历史遗留代码。
4.3 多态基类是一个常见例外
零规则有一个著名例外:多态基类。如果一个类被设计成基类,并且你打算通过基类指针或引用 delete 派生类对象,那么基类的析构函数必须是 virtual。声明 virtual 析构函数本身就是用户声明的析构函数,它会抑制移动操作的隐式生成,也让拷贝行为变得微妙。这种情况下,你还得考虑要不要把拷贝构造和拷贝赋值显式 delete 掉,防止切片。
实践中我倾向于把多态基类做成不可拷贝的抽象接口:析构函数写成 virtual,拷贝构造和拷贝赋值显式 delete,对象统一用 std::unique_ptr 持有。这样既满足多态释放要求,又避免派生类被错误拷贝成基类导致切片。此时基类内部如果还有其他资源成员,那些成员照样可以用 RAII 组件,所以基类本身并没有完全退化成五规则形态。
4.4 性能上要不要担心
零规则常被误会成“性能不够好”。实际上没这么绝对。std::vector 的移动是 O(1) 的指针交换,std::unique_ptr 在绝大多数实现里和不带捕获的普通指针一样大,访问和释放几乎没有额外开销。std::shared_ptr 确实引入引用计数的原子操作,但这是你选择共享所有权时应该付出的成本,不是 RAII 本身的锅。反过来,手写五规则时如果移动操作没写 noexcept,或者移动函数压根没生成,性能可能反而更差。所以选择零规则还是五规则,应该先讨论语义正确性,再谈性能优化;顺序反了很容易写出一堆看似高效实则危险的代码。
5. 常见问题与排查实录:这些坑我几乎都踩过
5.1 moved-from 对象还能不能继续用
移动后的源对象处于“合法但未指定”的状态,这句话是标准里的原话,但它非常容易让人误读。很多人以为既然合法,那就可以继续调用它的接口、读它的数据。实际上标准只保证一件事:源对象可以安全析构,可以被再次赋值,除此之外不保证其他任何行为。具体来说,我们的 RawBuffer 被移动后,源码里因为移动构造做了置空,size() 返回 0;但另一个实现完全可以不置空、只把指针搬走,让 size() 保留旧值。所以如果你的代码写完a = std::move(b)之后还去读 b 的内容,这在语义上就是依赖未指定行为,迟早会踩到编译器和标准库实现的差异。
我的经验是:移动操作之后,对源对象只做两件事——赋值或析构。如果循环里需要保留源对象的某些数据,就先拷贝再移动,不要指望移动之后源对象还会“留着”给你用。
5.2 自赋值到底要不要防
很多人觉得自赋值不可能发生,但大型软件里x = std::move(x)这种写法会通过宏、模板、回调函数曲折出现。最典型的手写五规则错误是:拷贝赋值里先 delete 当前资源,再从右边对象读取数据,结果右边对象就是左边自己,资源一删之后数据就没了。所以生产级代码里拷贝赋值和移动赋值都应该检查this == &other。就算 copy-and-swap 的代码能天然防自赋值,多写了一行判断也不亏,因为你不知道后续代码会被改成什么样。
5.3 手写析构却漏了拷贝和移动,几乎是必修课
在某服务端项目里我排查过一个问题:一个 Session 类持有底层连接的 fd,析构函数里封装了 close 操作。后来有人把 Session 对象放进 std::vector,需要扩容重排时触发隐式拷贝构造,两个 Session 就持有了同一个 fd 数值。第一个对象析构把 fd close 掉之后,第二个对象析构时又会 close 同一个 fd。如果这个 fd 号在这个过程中被操作系统重新分配给新的连接,就会莫名其妙关掉别人家的连接。那个问题查起来极其隐蔽,因为 crash 现场根本不在 Session 所在的模块。
这个案例很好地说明了五规则不是理论教条。手写了析构函数,就必须把拷贝和移动的局面一起定死:要么显式实现,要么显式 delete,不能留着编译器隐式生成一个危险的浅拷贝行为。很多编译器在这种场景其实有相关警告,但默认不会开启,真的等 double free 跑起来再查,成本已经高出太多了。
5.4 好用工具:把隐患提前炸出来
排查资源管理问题,我常用的组合是编译期警告、静态分析和运行时检测三层。编译期至少开全警告,部分编译器的诊断能提示类里声明了析构但没写拷贝/移动,或者只有拷贝没有移动。静态分析工具里有一些专门检查特殊成员函数声明一致性的规则,比如提示“类有用户声明的析构函数,建议补全五规则”。运行时我用 ASan 检测 double free、use-after-free 和内存泄漏,有时也会用内存检测工具做完整泄漏扫描。这套流程下来,五规则相关的绝大多数问题都能在测试阶段现出原形,而不是等到线上半夜报警。
我特别建议在写新类时养成一个习惯:如果这个类里有裸资源,先把它包进 RAII 组件,再谈要不要写五规则。能把裸资源成员消灭掉的地方,后面那一大堆排查工具基本就用不上了。
6. 经验谈:我最终倾向哪种写法
写了很久的业务代码之后,我对这两条规则的态度变得很明确:零规则是默认路线,五规则是例外路线。设计新类时,我第一步永远是去检查成员里有没有裸资源;有裸资源,我就去想能不能换成 unique_ptr、shared_ptr、vector、string 或者其他封装。只有当这些标准工具都无法覆盖资源语义时,我才会动手写那五个特殊成员函数,而且一定坚持把移动操作标成 noexcept。
让我印象很深的一次重构:一个日志封装类原本直接持有句柄,析构、复制、移动全都手写,每次加功能都提心吊胆。后来我把句柄塞进了一个带自定义删除器的 std::unique_ptr,整个类只剩下一个成员变量,所有特殊成员函数交给编译器。改动之后代码量少了一半,行为反而更可预测,因为 unique_ptr 把所有权语义钉死了。那次重构让我彻底认同零规则的价值:它不是偷懒,而是把安全责任从“每个开发者手动保证”转移到“语言标准库保证”上。
最后再分享一个小技巧:每次你手痒想在某类的析构函数里写释放代码时,先停一下,审视那个要被释放的东西是不是已经是一个对象的成员。如果是,这个类大概率不需要你写析构函数;如果不是,问问自己能不能先给它套一层 RAII 包装。绝大多数情况下,答案都是“能”,然后你的特殊成员函数负担就会瞬间归零。