
写 C 最怕的是一类代码看着没问题、编译全通过一跑起来就崩。我经常在代码 review 时问同事一句话你这个类三/五/零法则考虑过吗所谓三/五/零法则说的是 C 类里那组特殊成员函数——析构函数、拷贝构造、拷贝赋值以及 C11 之后再加入的移动构造、移动赋值。它们决定了对象的资源由谁创建、由谁复制、由谁销毁也决定了你写的类到底是“值语义”还是“一堆指针的集合体”。我刚入行那年封装字符串类就因为在拷贝构造函数里少写一个深拷贝两个对象析构时直接 double free断点打在delete[]上才发现两个对象的指针指向同一块地址。从那以后我养成一个习惯每写一个自定义类先问自己它属于三法则、五法则还是零法则。这篇文章就是把这些年在实战里的理解、测试代码和踩坑记录下来适合正在学 C 拷贝控制的新手也适合写了很多年代码但偶尔还想搞清楚“为什么 vector 扩容这么慢”的老开发。1. 三/五/零法则到底在说什么1.1 一个崩溃现场浅拷贝是怎么把程序搞崩的先看一段最典型的“反三法则”示例#include cstring class MyString { public: explicit MyString(const char* s) { data_ new char[std::strlen(s) 1]; std::strcpy(data_, s); } ~MyString() { delete[] data_; } private: char* data_; }; int main() { MyString a(hello); MyString b a; // 使用编译器隐式生成的“拷贝构造函数” return 0; }这段代码编译完全没问题但运行时大概率崩在退出 main 之后的析构阶段。原因是MyString b a;用的不是我们自己写的深拷贝而是编译器自动生成的拷贝构造函数。编译器对char*这种内建指针类型做的就是“按位复制”它把a.data_这个地址原封不动地复制给了b.data_。于是两个对象的data_指向同一块堆内存。main 末尾a 和 b 按构造的反序析构先析构 bdelete[]第一次释放这块内存没问题再析构 adelete[]再去释放同一个地址就是 double free。这个崩溃在不同平台、不同堆管理器下的表现完全不一样有时候一闪而过有时候在客户那边稳定复现是最难排查的一类问题。问题根源在于编译器默认的“逐成员拷贝”根本不认识“这个指针指向的是一块需要独立拥有的堆资源”。对int、double这样的内建值类型逐成员拷贝无可厚非但对裸指针、文件句柄、socket 描述符这类资源句柄浅拷贝就是灾难。1.2 三法则的原始表述三法则Rule of Three是 C FAQ 的作者 Marshall Cline 很早就总结出来的经验法则原始表述大致是如果一个类需要自定义析构函数、拷贝构造函数或拷贝赋值运算符中的任何一个那么这三个通常应该一起提供。它不是一个语法规则编译器不会因为你只写了析构函数就报错。它是一个“经验法则”告诉你怎么写才能规避未定义行为。为什么是三个因为这三个函数恰好覆盖了一个对象管理资源的完整生命周期构造阶段申请资源或用已有资源构造对象拷贝阶段让新对象拥有一份独立资源赋值阶段把对象里已有资源释放再复制另一份资源析构阶段回收对象持有的资源。三法则说“三个要一起写”本质上是在强调只要你介入了其中一个环节就必须把整个资源管理的闭环补完。只写析构、只写拷贝构造或者只写拷贝赋值都会让这个闭环出现缺口。1.3 三条总是一起出现的逻辑我把常见的组合拆开看你就明白为什么缺一个都不行。只写了析构函数没写拷贝构造和拷贝赋值拷贝时浅拷贝两个对象共享资源析构时 double free。写了析构函数和拷贝构造函数没写拷贝赋值a b时编译器默认的拷贝赋值只会把a.data_的值覆盖成b.data_的值a 原来持有的那块内存直接泄漏而且之后 a 和 b 又指向同一块内存析构时继续 double free。写了析构函数和拷贝赋值没写拷贝构造函数MyString b a;时仍然浅拷贝问题回到第一种情况。写了拷贝构造和拷贝赋值没写析构函数资源泄漏。每次对象销毁堆上的内存都没有人回收。所以三法则其实是“资源所有权”的配套方案当你的类用裸指针管理资源时拷贝构造决定“新对象怎么获得自己的资源”拷贝赋值决定“旧资源怎么被替换”析构函数决定“资源什么时候被回收”。这三件事的决策绑定在同一个资源语义上拆开写就会步调不一致。正确的深拷贝类至少应该是class MyString { public: explicit MyString(const char* s) { size_ std::strlen(s); data_ new char[size_ 1]; std::strcpy(data_, s); } MyString(const MyString other) : size_(other.size_), data_(new char[other.size_ 1]) { std::strcpy(data_, other.data_); } MyString operator(const MyString other) { if (this ! other) { delete[] data_; size_ other.size_; data_ new char[size_ 1]; std::strcpy(data_, other.data_); } return *this; } ~MyString() { delete[] data_; } private: size_t size_; char* data_; };先别急着背代码注意两个细节一是拷贝构造参数必须用const MyString传值会导致再次拷贝无限递归二是拷贝赋值先判断this ! other这叫自赋值检查后面我会细说为什么必要。2. 五法则详解拷贝、移动与析构的完整博弈2.1 为什么 C11 之后从“三”变成了“五”C11 引入了移动语义和右值引用核心思路是像return一个临时对象、std::vector扩容这种场景原本需要把资源逐字节深拷贝一份但实际上源对象马上就要销毁了不如直接把它的资源“偷”过来。于是新增了两个特殊成员函数移动构造函数和移动赋值运算符。三法则也就自然扩展成了五法则如果一个类需要自定义析构、拷贝构造和拷贝赋值中的任何一个那么最好把移动构造和移动赋值也一起写出来。这里有个隐式生成规则必须搞清楚如果用户没有声明析构函数、拷贝构造、拷贝赋值、移动构造、移动赋值中的任何一个编译器可以隐式生成移动构造和移动赋值但是只要用户声明了析构函数或者任何拷贝操作中的一个移动构造和移动赋值就不会被隐式生成。这句话的杀伤力极大。很多人写了一个三法则类析构函数、拷贝构造、拷贝赋值都有但没写移动构造。他以为std::vector可以把这种对象高效搬来搬去实际上std::move调过去之后编译器只能退回去走拷贝构造——因为移动接口根本不存在。功能没错性能塌了。2.2 拷贝构造与拷贝赋值深拷贝的两种姿势直接写一个完整的五法则示例类我习惯用MyBuffer来演示。它管理一块动态数组既足够简单又能展示所有特殊成员函数的行为。#include algorithm #include iostream class MyBuffer { public: explicit MyBuffer(size_t size 0) : size_(size), data_(size 0 ? new int[size_]{} : nullptr) { std::cout ctor this \n; } ~MyBuffer() { std::cout dtor this \n; delete[] data_; } MyBuffer(const MyBuffer other) : size_(other.size_), data_(other.size_ 0 ? new int[other.size_]{} : nullptr) { std::cout copy ctor this \n; std::copy(other.data_, other.data_ size_, data_); } MyBuffer operator(const MyBuffer other) { std::cout copy assign this \n; if (this ! other) { delete[] data_; size_ other.size_; data_ size_ 0 ? new int[size_]{} : nullptr; std::copy(other.data_, other.data_ size_, data_); } return *this; } MyBuffer(MyBuffer other) noexcept : size_(other.size_), data_(other.data_) { std::cout move ctor this \n; other.data_ nullptr; other.size_ 0; } MyBuffer operator(MyBuffer other) noexcept { std::cout move assign this \n; if (this ! other) { delete[] data_; data_ other.data_; size_ other.size_; other.data_ nullptr; other.size_ 0; } return *this; } void fill(int value) { for (size_t i 0; i size_; i) { data_[i] value; } } private: size_t size_; int* data_; };这里面拷贝构造和拷贝赋值都很好理解分配新内存再把源对象的数据逐字节复制过来。重点说一下几个设计决策。第一拷贝构造参数必须是const MyBuffer。如果写成MyBuffer(MyBuffer other)调用拷贝构造时还需要对other做一次拷贝形成无限递归。第二拷贝赋值返回MyBuffer而不是void是为了支持a b c这种链式赋值。第三拷贝赋值里的this ! other不能省。如果出现a a自赋值没有这个判断就会先delete[]掉自己的内存然后拿着已经被释放的other.data_做拷贝——这是血淋淋的教训。不过拷贝赋值这里有更优雅的写法叫 copy-and-swapclass MyBuffer { public: void swap(MyBuffer other) noexcept { using std::swap; swap(size_, other.size_); swap(data_, other.data_); } MyBuffer operator(const MyBuffer other) { std::cout copy assign (copy-and-swap) this \n; MyBuffer temp(other); // 先做一份独立拷贝 swap(temp); // 把拷贝换进来 return *this; } };为什么推荐这种写法因为它天然解决了两个痛点一是不用写自赋值检查a a时先拷贝一份自己交换回来发现没变化安全二是异常安全性更强如果new失败抛了异常函数在进入 swap 之前就退出了原来对象的资源完好无损。常规写法里如果new失败旧资源已经被delete[]对象进入不可恢复状态。2.3 移动构造与移动赋值偷走而不是复制移动构造和拷贝构造最大的区别是“不分配新资源”。移动构造函数直接把other.data_这个指针接管过来然后立刻把other.data_置为nullptr。这一步非常关键。为什么不把other.data_置空因为临时对象在表达式结束后一定会调用析构函数。如果它的data_还指向那块已经被我们接管的堆内存析构时就会delete[]同一块内存又是 double free。把源对象置空析构时执行delete[] nullptr这是安全的C 标准保证删除空指针是一种空操作。移动赋值同理先释放自己的旧资源再把对方资源接管过来然后把对方置空。这里同样需要自赋值检查不过也可以用std::exchange简化核心逻辑MyBuffer operator(MyBuffer other) noexcept { if (this ! other) { delete[] data_; size_ std::exchange(other.size_, 0); data_ std::exchange(other.data_, nullptr); } return *this; }std::exchange做的事情是把other.data_的原值取出来赋给data_同时把other.data_写成nullptr一步完成“接管 置空”。注意必须先把对方的size_换出来否则data_换了但大小不匹配整个对象就是坏的。2.4 noexcept一个小标记决定容器性能你可能会想移动构造和移动赋值上加不加noexcept不就是优化吗有没有都行真不是。拿std::vector扩容举例。当push_back导致 capacity 不够时vector需要申请一块更大的新内存然后把旧元素搬迁过去。标准里有一个非常关键的行为如果元素的移动构造函数是noexcept的vector会优先使用移动如果移动构造可能抛异常为了保证“扩容失败时原容器内容不被破坏”的强异常安全保证vector宁可退回拷贝构造。场景是这样的你的类有 10 万个元素每次扩容都逐个深拷贝复杂度直接上一个数量级。你写了移动构造函数但忘了写noexceptvector 就完全不使用它。表面上代码“没毛病”性能上却在给用户挖坑。排查这种问题写个静态断言是最快的static_assert(std::is_nothrow_move_constructible_vMyBuffer); static_assert(std::is_nothrow_move_assignable_vMyBuffer);如果编译报错说明你的移动函数没有正确声明noexcept。3. 零法则RAII 的胜利与现代 C 最佳实践3.1 什么是零法则三法则和五法则讲了半天怎么写好特殊成员函数但更高级的思路是干脆一个都不写。这就是零法则Rule of Zero。零法则的核心依赖是 RAII让资源管理由成员对象自己完成。如果一个自定义类的所有成员都是std::string、std::vector、std::shared_ptr、std::unique_ptr这类“自带资源管理能力”的类型那么编译器生成的所有特殊成员函数就是正确的析构函数自动逐成员销毁每个成员会释放自己的资源拷贝构造自动逐成员深拷贝vector拷贝自己元素string拷贝自己的字符移动构造自动逐成员移动每个容器把自己的堆指针搬走。也就是说你完全不用碰new/delete也完全不用操心深浅拷贝。看个实际例子#include string #include vector struct Person { std::string name; std::vectorint scores; }; int main() { Person a; a.name alice; a.scores {90, 95, 80}; Person b a; // 自动深拷贝name 和 scores 都是独立副本 Person c std::move(a); // 自动移动a 的资源被搬走a 处于有效但空的状态 return 0; }没有任何一个类名出现在特殊成员函数的自定义列表里Person却拥有正确、高效、异常安全的资源语义。三法则类里要手动处理的 double free、内存泄漏、自赋值问题到这里全部消失。零法则的思想并不是“不从堆上分配内存”而是“让专业类型干专业的事”。写业务类的时候你的职责是组合成员不是管理new出来的内存。这套思路在很大程度上就是现代 C 一直推崇的方向值语义类型 智能指针。3.2 零法则的边界与“法则五/零”零法则不代表任何时候都能用。当你需要实现底层基础设施时——自定义内存池、句柄包装类、文件锁、socket 连接——你就是在写一个 RAII 类必须亲自管理资源这时就要完整实现五法则。所以有人提出“法则五/零”的说法要么一个特殊成员函数都不写要么五个全都写最忌讳的是中间状态只写一半。中间状态为什么危险还是隐式生成规则只要写了析构函数移动操作就不隐式生成了。你可能觉得“我写了析构只是需要打个日志”结果这个类的移动语义被悄悄关闭所有基于移动的优化都失效。更糟的是别人拿到这个类以为它是可移动的实际上所有“移动”都退化成深拷贝。还有一种中间状态是用 delete显式禁止操作。比如日志类、配置类通常不需要拷贝用拷贝操作禁用比“不写”更清晰class Logger { public: Logger(const Logger) delete; Logger operator(const Logger) delete; Logger(Logger) noexcept default; Logger operator(Logger) noexcept default; // 成员函数... };这样既不会因为忘写而得到错误的隐式拷贝也不会因为没有移动而退化为拷贝。 default和 delete是表达意图的利器比什么都不写更有利于代码审查。那什么时候必须自己写五法则呢我列几个实际场景成员里有裸T*而且语义上必须“独占所有权”要包装系统资源句柄比如FILE*、pthread_mutex_t、Windows 的HANDLE需要实现某个容器的底层内存块比如自实现vector内部缓冲区必须控制拷贝浅深度的特殊业务比如某块内存要共享引用计数但这种情况优先考虑shared_ptr而不是手写。在这些场景之外我都建议先想想能不能用标准库组件替代。3.3 改造示例从手写五法则类到零法则类前面那个MyBuffer是个典型的手写资源管理类。如果业务逻辑只是“存一堆 int 并且能自动扩容”完全可以改成std::vector#include vector class MyBuffer { public: MyBuffer() default; explicit MyBuffer(size_t size) : data_(size) {} void resize(size_t size) { data_.resize(size); } int operator[](size_t i) { return data_[i]; } size_t size() const { return data_.size(); } private: std::vectorint data_; };这个类不需要写析构函数不需要写拷贝构造不需要写移动构造。拷贝它vector自己深拷贝移动它vector自己把堆指针搬走释放它vector自己清理数据。代码量少了一大半正确性却高了一个档次。但要注意零法则是有“封装纪律”的。如果外部要继续暴露data()之类的裸指针等于把资源管理重新暴露出去调用方一旦随便缓存这个指针生命周期问题就会绕过 RAII 重新出现。使用零法则类时接口设计要尽量保证成员的容器和智能指针不外泄至少不能把“所有权”转移给陌生人。4. 实操过程中的坑与排查技巧4.1 常见问题速查表我把这些年见过的典型报错和运行现象整理成一张表基本覆盖了拷贝控制的大多数坑。现象常见原因解决思路运行时报 double free默认拷贝是浅拷贝多个对象持有同一块堆内存提供深拷贝构造/赋值或改用shared_ptr对象赋值后旧资源泄漏拷贝赋值没有释放原有资源直接覆盖指针先释放再拷贝或使用 copy-and-swap临时对象构造时性能极差写了析构函数但没写移动构造移动退化为拷贝补移动构造/移动赋值检查noexceptvector 扩容突然变慢移动构造未标记noexcept容器强制退回拷贝补noexcept并用static_assert验证编译报 deleted function成员里有unique_ptr默认拷贝被禁用明确 move-only 语义若必须拷贝自定义深拷贝离开函数后调用悬空指针崩溃裸指针被缓存源对象已析构用shared_ptr/weak_ptr或规范所有权边界排查这类问题我建议不要只用眼睛盯代码。第一条铁律是跑 ASanAddressSanitizer。它会在第一次非法访问或释放的地方直接报错比在析构函数里打日志高效得多。第二条是写最小复现把出问题的类抽成一个单文件测试程序打印每个特殊成员函数的调用就能马上看出是谁在什么时候错误地拷贝或释放了资源。4.2 判断编译器是否隐式生成了特殊成员函数特殊成员函数的隐式生成规则是我见过的最容易被忽略的考点。它在实际开发里直接影响你的类能不能拷贝、能不能移动。我整理一个精简版如果用户一个特殊成员函数都没有声明编译器会尽量生成析构、拷贝构造、拷贝赋值在成员可移动的前提下也会生成移动构造和移动赋值。如果用户声明了析构函数、拷贝构造、拷贝赋值中的任何一个移动构造和移动赋值不会隐式生成但拷贝操作通常还会隐式生成。如果用户声明了移动构造或移动赋值中的任何一个拷贝构造和拷贝赋值会被隐式弃置也就是 delete。如果基类或成员类型不可拷贝那么隐式的拷贝构造/拷贝赋值也会被隐式弃置。第三条规则是很常见的“意外”。你只是给类加了一个移动构造函数结果编译器把拷贝构造禁掉了。这在设计 move-only 类型时是好事但如果你没意识到这一点就可能在调用拷贝构造时报出莫名其妙的 deleted function 错误。想知道编译器到底给你生成了哪些特殊成员函数写三个静态断言就够了static_assert(std::is_copy_constructible_vMyClass); static_assert(std::is_move_constructible_vMyClass); static_assert(std::is_nothrow_move_constructible_vMyClass);能编译过去说明编译器认可这个行为编译失败说明你的类在某种语义上不符合预期。静态断言放在类定义后面第一行是最便宜的反射手段。4.3 我的几点实操心得这些年写过、改过不少自定义类最后总结下来就是几条非常朴素的经验。我能强烈建议希望你能少走弯路。第一优先零法则。能用std::vector、std::string、智能指针解决的资源问题就不要自己封装裸指针。写五法则类的成本很高不只是代码量而是每一行都要反复确认边界情况。第二必须写五法则时一个完整类里五个特殊成员函数要么都写要么用 default或 delete明确表达意图。我自己写的时候习惯把五个函数在头文件里排在一起注释写明“为什么这个类不能被拷贝”或者“为什么拷贝是深拷贝”这些注释对三个月后的自己特别有用。第三移动操作之后把源对象的指针置空是我形成的肌肉记忆。哪怕某些平台下不置空也能勉强运行我也一定置空。原因很简单移动之后源对象还在作用域内一个残留着有效指针的“空壳”随时可能被析构、被重新赋值如果不清空就是埋在代码里的暗雷。第四遇到性能异常先怀疑移动语义没生效。尤其是那些写了析构函数的老类它们大概率没有移动构造。给它们补上移动操作并且标记noexcept很多时候比优化算法更立竿见影。第五copy-and-swap 写赋值运算符值得形成习惯。它把自赋值、异常安全、资源释放一次解决掉让赋值运算符的逻辑变得极其简单只剩下“拷贝一份别人换掉自己”这一件事。这些心得体会没有一句是天上掉下来的几乎全是靠 double free、内存泄漏和线上崩溃换来的。C 的拷贝控制是一个从“编译器默认行为”里抢回控制权的领域。理解了三法则、五法则、零法则你写出的每个类才会真的有“值语义”的底气能拷就拷得像样能移就移得干净不能复制就明明白白 delete。