多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

C++三/五/零法则:从裸指针到RAII的资源管理实践

C++三/五/零法则:从裸指针到RAII的资源管理实践 如果你写过几年 C大概率经历过这样一次“事故”某个类里加了一个原始指针成员没过多久程序在析构时 double free或者拷贝之后两个对象莫名其妙互相干扰。排了半天最后发现原因无非是——自定义析构函数写了但拷贝构造和拷贝赋值没跟上。这正是 C 里最有名的资源管理纪律三/五/零法则。它不复杂但想用对需要真正理解背后的资源所有权模型。这篇文章我会把三法则、五法则、零法则串起来讲配合完整代码实例和一套可以直接上手的排查经验适合从入门到进阶的 C 开发者参考。1. 三/五/零法则一句话概括的资源管理纪律1.1 资源的本质不止是内存先说清楚一个容易被忽略的事实三/五/零法则里的“资源”远不止“new 出来的内存”这一种。文件句柄、socket、锁、数据库连接、GPU 显存凡是“用完必须归还”的东西都能算资源。C 和 Java、C# 不一样它没有垃圾回收器资源释放的责任在编译器生成的“析构函数”和“所有权转移”机制上。于是自然会有一个问题如果类内部有一个裸指针编译器默认生成的析构和拷贝行为对吗默认生成的析构函数是逐成员释放默认生成的拷贝构造是逐成员复制。你有一个int* data_逐成员复制会把这个指针的“值”复制一遍——也就是两个对象持有同一个地址。等到析构时第一个对象 delete 一次第二个对象再 delete 一次double free。如果其中一个对象先释放另一个对象还会继续用这块内存变成悬垂指针。这就是三法则要收拾的烂摊子一旦需要手动管理资源编译器的默认行为就不够用了。理解这一点后你会发现三/五/零法则本质上不是在教你“怎么写函数”而是在帮你回答三个问题这个资源归谁拷贝它意味着什么能不能转移所有权想清楚了函数看起来多写起来反而很机械。1.2 从三法则到五法则再到零法则的演进脉络C98 时代没有移动语义一个对象一旦被拷贝就是实打实的一份深拷贝。那时候社区总结出的经验是如果类需要用户自定义的析构函数几乎肯定也需要自定义的拷贝构造函数和拷贝赋值运算符。这三个函数缺一不可所以叫“三法则”。这段时期的主流代码风格是“管好每个裸指针的生死”心智负担很重。C11 引入了右值引用和移动语义场景变了。临时对象可以被“偷资源”而不是被迫重新分配内存。于是三法则扩展成五法则析构函数、拷贝构造、拷贝赋值、移动构造、移动赋值都得考虑。再后来现代 C 的 RAII 容器和智能指针日益成熟社区又提出了“零法则”最好的类恰恰是那些一个特殊成员函数都不用写、完全依赖编译器默认行为的类。三条法则的演进背后其实是 C 所有权观念的进步。从“手动管理”到“移动转移”再到“把管理交给别人”每前进一步代码都更接近声明式、更不容易出错。现在写新代码我更愿意默认走零法则路线只有真正绕不开资源管理时才会退回五法则或三法则。1.3 先判断你的类该走哪条路我不知道你拿到一个新类时会怎么做反正我现在第一步永远是做资源审计数数成员变量里有没有裸指针、原生句柄、需要手动释放或清理的东西。说白了就是快速扫描一遍数据成员。如果所有成员都是std::string、std::vector、std::unique_ptr这类自带正确析构/拷贝/移动语义的 RAII 类型那就走零法则什么都不写让编译器默认生成即可。如果类里确实有裸指针或旧式句柄先确认所有权是否唯一唯一时尽量用unique_ptr包装包装不了再考虑写五法则。如果你明确这个资源不允许拷贝比如文件句柄、互斥锁那就应该把拷贝构造和拷贝赋值定义为delete只保留析构和移动。如果你的类没有任何需要手动管理的成员那就不要写析构函数更不要自作聪明地写~MyStruct() {}这会让编译器停止生成移动操作白吃移动语义退化的亏。这个判断流程我愿称之为“五法则决策树”。它看起来简单却能在设计阶段拦住一大半资源泄漏和悬垂指针。2. 三法则的实现与隐患老代码里最常见的坑2.1 为什么析构函数是“发令枪”很多教程把三法则讲成“三个函数要一起写”却没有解释为什么触发点是析构函数。我的理解是默认析构是逐成员析构默认拷贝是逐成员拷贝它们在处理普通成员时是配套的、也对。但当类里出现裸指针时默认析构会在释放成员指针本身的同时不去释放它指向的内存默认拷贝复制了指针值却没有复制它指向的数据。这两个默认行为叠加就产生了浅拷贝和双重释放。所以当你为了释放裸指针而手写析构函数的那一刻其实就已经宣告了“默认逐成员语义在我的类上不成立”。如果不接着修正拷贝语义你只是解决了“释放”这一半另一半“拷贝”仍然是错的。举一个老掉牙但很经典的例子class NaiveString { char* data_; public: NaiveString(const char* s) { data_ new char[strlen(s) 1]; strcpy(data_, s); } ~NaiveString() { delete[] data_; } // 手写了析构 // 拷贝构造和拷贝赋值没有写 };然后你写NaiveString b a;b 的data_和 a 的data_指向同一个堆内存。作用域结束a 先析构释放内存b 再析构再次释放同一块地址。放在线上就是偶发的崩溃、内存损坏排错成本极高。2.2 拷贝构造与拷贝赋值为何缺一不可先做一个更细的拆解。如果只写了深拷贝的拷贝构造没写拷贝赋值那么a b;这种语句仍然走默认的浅拷贝赋值问题照旧。反过来只写拷贝赋值不写拷贝构造NaiveString b a;的初始化阶段照样浅拷贝。这两个函数负责的语法场景不同构造管“初始化”赋值管“已存在对象重新赋值”二选一都会留下一个半残的状态。另外拷贝赋值还有一个独有的坑自赋值。a a;如果先释放再分配自己就把自己的数据释放了。我在早期写三法则时喜欢用“先 delete 再 new 再 copy”的传统写法后来被 review 了几次才彻底改掉。更稳妥的做法是使用 copy-and-swap 惯用法先从一个临时副本里拷贝出数据再通过交换替换当前对象。class Buffer { char* data_; size_t size_; public: Buffer(size_t n) : data_(new char[n]), size_(n) {} ~Buffer() { delete[] data_; } Buffer(const Buffer other) : data_(new char[other.size_]), size_(other.size_) { std::copy(other.data_, other.data_ other.size_, data_); } Buffer operator(const Buffer other) { Buffer tmp(other); // 先构造临时副本 swap(tmp); // 再交换 return *this; } void swap(Buffer other) noexcept { using std::swap; swap(data_, other.data_); swap(size_, other.size_); } };这段代码看起来多了个swap但好处非常直接自赋值天然安全因为 tmp 是 other 的独立副本赋值过程中如果new抛异常当前对象保持不变满足强异常安全保证。以前我总觉得“代码短才是好”后来发现这种“冗余”恰恰是降低心智负担的关键。2.3 一个完整三法则类的边界条件严格来说拷贝构造里还有一个细节值得注意new char[n]的 n 是size_t如果other.size_是 0标准是允许返回非空指针或 nullptr 的std::copy不会对空区间做任何事问题不大。如果存放的数组元素类型不是 POD理论上std::copy会比memcpy更安全它会调用元素自身的拷贝语义就算你确定元素是char用std::copy也没有额外成本编译器会内联。还有一个常见误区三法则类里如果拷贝构造写成了只复制指针、不分配新内存那跟没写一样如果析构写的释放方式和分配方式不匹配比如new[]却用free那就直接踩中未定义行为。这些错误在编译器眼里不会报错只有跑起来才炸。我的习惯是写完三法则类第一件事不是写业务而是先写一个简单的自测创建两个对象互相赋值打印地址和内容再跑一下 ASan看有没有 double free 和内存泄漏。3. 五法则移动语义带来的两个新义务3.1 从右值到移动构造临时对象值得“偷”C11 引入移动语义之后原本“拷贝一份临时对象再销毁临时对象”的低效路径变成“直接把临时对象的资源拿过来再把临时对象置空”。举一个显而易见的例子如果一个函数返回一个大的 Buffer旧时代它会被拷贝一次资源开销很大移动语义出来后这个返回过程可以变成一次指针的转移近乎零开销。但移动操作不是“免费的深拷贝优化”它有自己必须遵守的契约移动后被移动的对象必须处于“有效但未指定”的状态。翻译成实际操作就是你必须把源对象的指针置空而不是只把地址赋给目标对象。如果我写Buffer::Buffer(Buffer other) noexcept : data_(other.data_), size_(other.size_) { // 忘记 other.data_ nullptr; }这个移动只做了一半。目标对象接管了堆内存但源对象析构时还是会把同一块内存 delete 一次double free 一点都不会少。移动构造的本质是“偷窃 善后”偷完线索要抹掉。比较简便的写法是用std::exchange一次性完成“读取并置空”Buffer::Buffer(Buffer other) noexcept : data_(std::exchange(other.data_, nullptr)), size_(std::exchange(other.size_, 0)) {}这里std::exchange把other.data_的旧值取出来赋给data_同时把other.data_设为nullptr。移动后源对象仍然可以安全析构、可以被再次赋值只是不再持有那块内存。3.2 noexcept决定容器选择 move 还是 copy移动构造和移动赋值有一个容易被忽略的“出场条件”声明为noexcept。为什么要这样因为标准容器尤其是std::vector扩容时需要把旧元素搬到新内存。如果元素的移动操作可能抛异常vector 根本没法保证“要么全部搬成功要么回到原状”。所以标准库的实现通常会用std::is_nothrow_move_constructible判断只有移动构造声明了noexcept扩容才会真正调用移动否则宁可退回拷贝构造。这个坑我印象极深。有一次写网络库自定义请求对象里有裸指针和字符串我写了移动构造但忘了加noexcept。结果std::vectorRequest在装了几万个元素后触发扩容整个操作从预期的几毫秒变成几百毫秒。排查了半天最后发现 vector 扩容阶段每次都在深拷贝对象移动根本没有被启用。后来我把移动构造、移动赋值全标上noexcept性能立刻恢复正常。所以五法则里移动两个函数的标准签名是Buffer(Buffer other) noexcept; Buffer operator(Buffer other) noexcept;3.3 五法则的完整实现与“源对象置空”细节把五法则完整铺开大概是下面这个样子。默认构造、析构、拷贝构造、拷贝赋值、移动构造、移动赋值一个类六个特殊成员函数全穿上class Buffer { char* data_; size_t size_; public: Buffer() : data_(nullptr), size_(0) {} Buffer(size_t n) : data_(new char[n]), size_(n) {} ~Buffer() { delete[] data_; } Buffer(const Buffer other) : data_(new char[other.size_]), size_(other.size_) { std::copy(other.data_, other.data_ other.size_, data_); } Buffer operator(const Buffer other) { if (this ! other) { delete[] data_; data_ new char[other.size_]; size_ other.size_; std::copy(other.data_, other.data_ other.size_, data_); } return *this; } Buffer(Buffer other) noexcept : data_(std::exchange(other.data_, nullptr)), size_(std::exchange(other.size_, 0)) {} Buffer operator(Buffer other) noexcept { if (this ! other) { delete[] data_; data_ std::exchange(other.data_, nullptr); size_ std::exchange(other.size_, 0); } return *this; } };移动赋值里的delete[] data_;是在释放当前对象本来就持有的旧资源这一步不能少。如果把旧资源直接丢弃不管就是内存泄漏。这里我建议用“先释放、再接管、再置空源对象”的顺序逻辑直观如果不喜欢自赋值检查也可以改成移动版本的 copy-and-swap但核心都一样旧资源要释放源对象要置空。写五法则时还有个通病拷贝赋值和移动赋值写出来的逻辑高度重复。如果类里的资源管理逻辑复杂我一般会用统一的swap函数让两种赋值都走“构造副本 交换”的路线。这样每个函数的代码都很短最不容易出错的地方也被压缩到swap一个点上了。4. 零法则真正的优雅是让编译器做对事4.1 零法则背后的“值语义 RAII”组合零法则的定义很朴素一个类如果不需要自定义析构、拷贝、移动中的任何一个就不写它们。听起来像偷懒其实这是现代 C 最推荐的设计。因为默认生成的特殊成员函数是“逐成员”的当每个成员自己都把自己的生命周期管好时组合起来的行为自然也是正确的。比如一个类有两个成员一个是std::string一个是std::vectorint编译器生成的析构会自动释放 string 的内部缓冲和 vector 的堆数组生成拷贝时会深拷贝两个成员生成移动时会转移两个成员的所有权。所有行为都符合直觉你根本不需要写六个函数。零法则的前提是“值语义加 RAII”。std::string、std::vector、std::map、std::unique_ptr都属于这类它们内部自己管理资源对外暴露的是值语义。只要把它们作为成员你就把资源管理的复杂度封装在标准库里了。很多老代码的问题恰恰是不知道这些类型也能当成员习惯性地用char*代替 string用裸数组代替 vector硬生生把一个本可以“零法则完成”的类变成了“必须手写五法则”的类。4.2 用智能指针把资源类改造成零法则类身上有原生句柄的类也能往零法则靠方法是包装。拿一个管理FILE*的配置类为例如果不包装你可能要手写拷贝、移动和析构三个函数还要考虑fclose只调用一次。包装之后class ConfigFile { using FilePtr std::unique_ptrFILE, decltype(fclose); FilePtr file_; public: explicit ConfigFile(const std::string path) : file_(FilePtr(fopen(path.c_str(), r), fclose)) {} // 不需要写析构、拷贝或移动 // unique_ptr 天然实现了“独占所有权 可移动 禁止拷贝” };这个类一个特殊成员函数都没写。unique_ptr本身负责析构时调用fclose移动构造和移动赋值也由它代劳拷贝构造和拷贝赋值则在编译期被unique_ptr删除从类型层面就禁止了复制一个文件句柄。如果你需要多个对象共享同一个资源可以用std::shared_ptr加自定义删除器共享意味着引用计数每个持有者都能安全析构也不需要自己写任何特殊成员函数。这里给一个方向上的建议能用unique_ptr就不用shared_ptr。独占所有权更省心引用计数开销也更小。零法则不是你什么都不管而是你把“怎么管理”这个责任委托给了更小、更稳的组件。4.3 什么时候必须放弃零法则零法则不是银弹。至少有三类场景绕不开手写特殊成员函数。第一类是资源本身没有现成 RAII 包装比如你要连接一个旧 C 库对方暴露的 API 是handle* open()、void close(handle*)而没有给出现成的智能指针语义。这种情况可以包一层unique_ptr但删除器如果是“需要访问对象其他成员”的复杂逻辑还是可能要自己写析构善后。第二类是资源所有权语义特殊。比如你管理一个内存池池内存需要在类析构时统一释放但池里的部分资源又被外部借出。编译器默认的逐成员析构没法表达“借出的资源不归我管”必须手写析构和拷贝/移动策略。第三类是严格要求异常安全级别的场景。容器底层的通用实现往往难以同时满足“强异常安全”和“高性能”这时候实现自定义移动、交换逻辑反而更可控。但这类场景占比很小。我的判断标准很简单先尝试零法则构造不出来再降级到五法则绝不一开始就伸手去写裸指针管理代码。5. 实战避坑与排查技巧5.1 编译器自动生成规则的五个陷阱很多问题不是出在你不会写三/五/零法则而是出在你不清楚编译器什么时候“帮你”生成、什么时候“故意”不生成。这里我整理五个最容易踩中的编译器陷阱陷阱一用户声明析构函数会抑制移动操作生成。哪怕析构函数体是空的只要它被用户声明移动构造和移动赋值就不会被隐式声明。结果就是你的类看起来可以移动实际移动时悄悄退化成拷贝。这是性能骤降最常见的来源。陷阱二const 成员或引用成员会让你写不了拷贝。一个类里如果有一个const int id_或std::string ref_编译器不会隐式生成拷贝赋值因为 const 和引用成员无法重新赋值。这条规则的副作用是某些看似简单的包装类会突然变得不可赋值。陷阱三单参数构造函数不加explicit隐式转换会制造临时对象。临时对象多了移动和拷贝的次数也会失控。对资源管理类来说隐式转换还容易让所有权语义被绕过去建议所有单参数构造函数都默认加explicit除非你刻意要提供隐式转换。陷阱四多态基类的析构函数没有标记virtual。通过Base* p new Derived; delete p;释放派生类对象时如果基类析构不是虚函数就属于未定义行为。这是三法则在继承体系里的变体我见过不少老代码在这里翻车。陷阱五把“仅移动”类型塞进旧接口。std::optional、std::variant、std::function以及带unique_ptr成员的类都可能是“可移动但不可拷贝”的。如果你用一个要求拷贝的旧 API编译会直接报错这其实是编译器在保护你。遇到这种情况要重新审视接口设计而不是强行写一个危险的浅拷贝。我把常见症状和原因整理成了一张速查表症状大概率原因推荐处理double free / pointer being freed was not allocated浅拷贝导致同一资源释放两次实现深拷贝或改用智能指针容器操作性能暴跌移动被用户析构抑制退回拷贝去掉手写析构或补全移动并标 noexcept编译提示 deleted functionconst/引用成员或不可拷贝基类成员按语义显式实现或删除拷贝操作隐式类型转换产生异常行为单参数构造函数未加 explicit构造函数加 explicit多态 delete 崩溃基类析构非 virtual基类析构加 virtual内存泄漏但无明显崩溃移动赋值未释放旧资源移动赋值里先 delete 旧资源5.2 最实用的验证与排查手法写完三/五/零法则相关代码光靠读代码往往看不出来问题我通常组合使用三类手段。第一类内存检测。ASan 是我最常用的编译时加-fsanitizeaddress -g运行到 double free、内存泄漏、越界访问它都会给出详细的调用栈和报告。valgrind 也能做类似的事但速度慢一截。对排查资源管理类的问题这两者比任何 code review 都有效。第二类构造/析构日志。在特殊成员函数里临时打印一条日志标注copy、move、destroy然后跑一段包含临时对象、容器扩容、返回值优化入口的测试代码观察日志的调用顺序。这个方法能直观看出移动是否生效、临时对象是否出现、析构次数是否匹配。第三类静态检查工具。clang-tidy 的cppcoreguidelines-special-member-functions检查项可以直接帮你定位“这个类写了析构但没写移动操作”之类的隐患。我在团队里推行过一个简单规矩所有新提交的类定义都要过一遍 clang-tidy 的特殊成员函数检查。写单元测试时我最常补的场景有三个拷贝后两个对象完全独立、移动后源对象可以安全析构和重新赋值、自赋值不会崩溃或泄漏。这三个测试写起来都比较短但能覆盖掉绝大多数资源管理错误。5.3 个人经验命名、评审与排查口诀经验累积到一定程度后我对“要不要手写特殊成员函数”这件事形成了固定的肌肉记忆分享几条体会。第一条新类默认零法则这是设计态度不是偷懒。每次看到类里有裸指针但没有任何特殊成员函数声明我的第一反应就是红牌。裸指针意味着所有权没有明确表达即使当前类不管理它也要用一个std::observer_ptr或者注释把“我借用但不拥有”写清楚。第二条如果决定写五法则建议六个特殊成员函数默认构造、析构、拷贝构造、拷贝赋值、移动构造、移动赋值全部显式声明一遍用 default和 delete明确意图再手写真正有逻辑的那几个。这样代码评审的人一眼就能看出你的设计意图而不是靠猜测编译器生成了什么。第三条移动操作唯一容易被遗忘的细节就是“源对象置空”。每次写完移动构造或移动赋值我都会隔几秒再回看一眼other成员是否被重置。这个检查动作虽然简单但真的能拦住大部分 double free。第四条代码评审里看到“手写析构 拷贝构造 拷贝赋值”却没有任何移动操作不要轻易放行先确认这个类是否需要被放进容器、是否会被 return 返回值。如果会移动一定会退化成拷贝性能问题迟早要找上门。尾声一个本质上很简单但足以重构代码习惯的法则三/五/零法则这几个字听起来像面试考点其实是最贴近日常 C 开发的一条生存法则。我在实际项目里的体会是写资源管理类之前先用一分钟做资源审计比代码写完再回头补各种特殊成员函数要省力得多。现在每写一个自定义类我都会问自己三句话不写析构行不行不写拷贝行不行不写移动行不行哪句答案是“不行”就老老实实按法则补齐哪句答案是“行”就绝不多写一行。这套判断帮我在无数个内存错误和性能退化发生之前就先把问题挡在了门外。
返回列表