
写 C 写了几年的人多半见过两种迥异风格一个类里析构函数手写 delete拷贝构造、拷贝赋值、移动构造、移动赋值也跟着写了一大堆另一种类里几乎不见 new/delete成员全是 string、vector、unique_ptr 这类标准库组件编译器生成的析构、拷贝、移动就已经够用了。前者踩的是五规则这条线后者就是零规则的典型形态。这两个“规则”并不是互相替代的关系而是同一套资源管理思路的两端你到底需要亲自写几个特殊成员函数还是干脆一个都不写让 RAII 类型替你把所有脏活干完。这篇文章想把五规则和零规则拆开讲清楚包括它们背后的原理、代码到底长什么样、实际项目里怎么选型以及我在开发过程中真踩过的那些坑。无论你是对特殊成员函数只有模糊印象的新手还是已经写了很久业务代码的老手这篇文章应该都能给你一套可以“抄作业”的判断口径。1. 特殊成员函数与 RAII先把基础拼图摆齐1.1 五个特殊成员函数到底在说谁很多初学者一上来就直接背“五规则”却不清楚五规则里那五个函数是谁。实际上 C 里能够被编译器隐式生成的特殊成员函数不止五个除了默认构造函数还包括析构函数、拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符。五规则关心的是后面这五个因为默认构造函数通常和资源所有权没有直接关系——就算一个类没有默认构造函数它照样可能存在资源管理的问题。编译器隐式生成行为的关键在于这些函数不是总是被生成也不是想当然的“只要你没写它就会自动出现”。例如你写了一个带参数的构造函数默认构造函数就不会被隐式生成但你声明了析构函数拷贝构造和拷贝赋值还是会被隐式生成只是这种行为在 C11 之后被视为弃用移动构造和移动赋值一旦你显式声明了析构函数就不会再生成。这些细节是判断“这个类是否安全”的基础也是为什么五规则这一整套说法在 C11 引入移动语义之后变得比原来的三规则更重要。把编译器隐式生成的默认行为理解成“逐成员操作”是一个很实用的记忆方法。默认的拷贝构造就是拿右边对象的每个成员去拷贝构造左边的对象默认的移动构造就是拿右边对象的每个成员去移动构造左边的对象默认的析构函数就是依次调用每个成员的析构函数。逐成员操作本身很合理问题是当成员里有裸指针这类“值语义很弱”的东西时逐成员复制就变成了浅拷贝逐成员析构就可能导致同一块资源被释放两次。这一点是所有资源管理问题的源头也是五规则要存在的根本原因。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 两次未定义行为这个代码在 C98 时代就是经典 bug 模板写了析构函数释放内存但没写拷贝构造结果别人随便一拷贝就炸。C11 之前大家提的是三规则如果你自定义了析构、拷贝构造或者拷贝赋值其中之一另外两个往往也要一起处理。C11 引入移动语义后升级成五规则因为移动构造和移动赋值如果缺位资源转移就会退化成拷贝轻则性能损失重则语义错误。认清这个演变过程才能理解后面零规则的真正价值。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 为什么移动函数必须标记 noexceptRawBuffer 的移动构造函数和移动赋值我都标了 noexcept。这不是习惯问题而是 C 标准库容器面对“可能抛异常的移动”会做出完全不同的选择。std::vector 扩容时如果元素的移动构造函数是 noexceptvector 会优先把老元素移动过去如果移动构造函数可能抛异常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::vectorPermission permissions_; std::unique_ptrAvatar avatar_; std::shared_ptrAuditLog audit_; public: UserProfile(std::string name, std::vectorPermission perms) : name_(std::move(name)), permissions_(std::move(perms)) {} void setAvatar(std::unique_ptrAvatar 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_ptrunique_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 包装。绝大多数情况下答案都是“能”然后你的特殊成员函数负担就会瞬间归零。