免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

C++ defer 实现:从最小版本到完善的 RAII 作用域守卫

C++ defer 实现:从最小版本到完善的 RAII 作用域守卫 有一次在 review 一段业务代码时我看到了一个非常典型的场景。函数里有三个提前返回的分支每个分支前面都写着同一段收尾逻辑把某个标志位复位把临时目录删掉再补一行日志。前两个分支的清理代码写得一模一样第三个分支却漏掉了。问作者他回答得很自然“那个分支是我后来加的忘补了。”这就是 C 里一直没有一个显式 defer 机制时最常见的日常痛点。Go、Rust、Zig 都把 defer 当成必要的语言能力但 C 没有语言级 defer——它选择把这种能力下沉到 RAII 里让开发者靠析构函数来模拟。这个模拟本身不难真正难的是把一个“能用”的 defer 做成一个“完善”的 defer执行顺序要正确关键时刻能取消不会在析构时偷偷抛异常不会捕获悬垂引用也不会用宏把代码库搞得一团糟。这篇文章就从“最小 defer”出发沿着完善链路走一遍。我会把每步设计取舍的原因写清楚再给出一套落到工程里的验证和排查方法。最后也会说清楚哪些收尾逻辑其实不该用 defer。1. 你真正需要的不是 defer而是收尾逻辑不遗漏1.1 一段多返回路径的函数清理代码为什么容易漏一个函数一旦有多个返回路径手写收尾逻辑就会迅速变得不可靠。看一个简化但很常见的例子bool handle_message(const Message msg) { temp_dir_.create(); processing_ true; if (msg.type() Type::kPing) { processing_ false; temp_dir_.cleanup(); return true; } if (!validate(msg)) { processing_ false; temp_dir_.cleanup(); return false; } process(msg); processing_ false; temp_dir_.cleanup(); return true; }这段代码里processing_的恢复和临时目录的清理重复出现了三次。新增一个分支时你需要再复制一遍。复制本身不危险危险的是“复制到一半被打断”“合并代码时忘记了”“别人新增分支时不知道这里有一条默认约束”。如果改成 defer 的写法清理逻辑会被注册在资源创建附近而不是散落在各个 return 附近bool handle_message(const Message msg) { temp_dir_.create(); processing_ true; auto cleanup make_defer([] { processing_ false; temp_dir_.cleanup(); }); if (msg.type() Type::kPing) { return true; } if (!validate(msg)) { return false; } process(msg); return true; }两种写法的差异本质上不是“少写几行”而是把收尾从“过程式复制”变成了“声明式注册”。清理逻辑只存在一处且它不依赖程序员在每一个出口都记得写一遍。1.2 C 为什么没有语言级 defer很多人会问C 为什么不学 Go 那样直接加一个defer关键字原因是 C 早就有一条更底层的机制可以覆盖这个需求局部对象在离开作用域时一定会析构析构函数里写清理逻辑即可。这就是 RAII 的核心思想。std::lock_guard、std::unique_ptr、文件流对象本质都是某种形式的“defer”。但 RAII 的问题是如果要为一小段清理逻辑专门写一个类成本太高。不是所有收尾都适合封装成“资源”对象。比如“把某个开关状态恢复原样”“删除一个临时目录”“发送一条退出日志”这些逻辑放在析构函数里比较奇怪又确实希望它能在作用域结束时自动执行。于是社区达成了一个共识做一个通用的作用域守卫把任意一段可调用对象存起来在析构时执行。这个通用工具在社区里有很多名字叫 ScopeGuard、ScopeExit、Defer、Finally 的都有。C 缺的其实不是“能不能实现”而是一个被标准化的、所有人都可以无成本使用的统一接口。1.3 这篇文章的主判断defer 的“完善”重点不在于把 API 写得花哨而在于把“离开作用域后必然执行收尾”这个承诺变成一套可验证、可维护、有边界的代码约束。一个 defer 实现是否完善可以从五个维度来判断正确性正常退出、提前返回、异常退出三种路径都能执行执行顺序符合预期。灵活性需要时可以取消执行支持移动能区分成功与失败退出。性能不引入不必要的堆分配和类型擦除至少不要成为瓶颈。可用性调用处清晰不要求使用者了解底层实现也不要用宏制造编译理解成本。安全性不能因为析构抛异常导致程序终止不能因为捕获引用不当导致悬垂。后面几节就按这五个维度展开。2. 先做一个能跑的最小 defer把核心链路跑通2.1 最小实现基于 std::function 的版本很多项目里的第一个 defer 版本都长这样#include functional #include utility class Defer { public: explicit Defer(std::functionvoid() fn) : fn_(std::move(fn)) {} ~Defer() { if (fn_) { fn_(); } } Defer(const Defer) delete; Defer operator(const Defer) delete; private: std::functionvoid() fn_; };这个版本在功能上已经能覆盖最基本的场景在作用域结束或函数提前返回时调一次清理逻辑。它的执行机制也很直白局部对象在离开作用域时析构析构函数里调用fn_。使用方式void do_work() { Defer cleanup([] { std::cout cleanup\n; }); if (!ready()) { return; } // 做正经事 }无论函数从哪个出口离开cleanup对象都会在函数栈展开时析构清理逻辑都会执行。2.2 先验证三种退出路径初次用 defer 时不要直接上业务代码先在测试里确认三种退出路径的执行行为正常走完整个函数。执行到某个return提前返回。函数中途抛出异常。对应的验证代码很简单#include cassert int main() { bool cleaned false; { Defer guard([] { cleaned true; }); // 正常走到作用域末尾 } assert(cleaned); }提前返回和异常路径的验证类似只要把return或throw放在 guard 创建之后即可。这里的关键是guard 对象必须被真正创建出来并且它的作用域要覆盖所有需要收尾的路径。2.3 多个 defer 对象的执行顺序后创建的先执行如果你在一个函数里创建了多个 Defer 对象它们的执行顺序是“后创建的先执行”。这是由 C 局部对象析构顺序决定的同一作用域内后构造的对象先析构。这个顺序很重要。比如你写了两个 guardauto g1 make_defer([] { std::cout first\n; }); auto g2 make_defer([] { std::cout second\n; });退出函数时输出会是second first也就是说g2 先执行g1 后执行。这符合直觉上的“栈式释放”但也有一个隐藏风险如果你依赖两个清理动作的顺序就必须把后创建的那个视为“先执行”的那个。2.4 这个最小版本处理不了什么上面这个版本看起来很简洁但它离“完善”还有距离。第一它使用了std::function这意味着函数对象可能会发生堆分配和类型擦除对性能敏感模块不友好。第二它没有 release 接口一旦声明就只能执行。第三它不支持移动在某些需要转移所有权的场景下会写得不顺手。第四如果fn_内部抛异常析构函数默认noexcept(true)程序会直接 terminate。这个阶段的目标是跑通。只有跑通了你才会真正感受到“收尾逻辑不遗漏”带来的安全感也才有动力继续把它完善下去。3. 从“能用”到“完善”五个维度挨个补3.1 模板化去掉 std::function 的开销既然 Defer 保存的是一个可调用对象更好的办法是直接用模板保存原始类型。这样就不需要类型擦除也避免了std::function潜在的堆分配。template typename Fn class Defer { public: explicit Defer(Fn fn) : fn_(std::move(fn)) {} ~Defer() { fn_(); } Defer(const Defer) delete; Defer operator(const Defer) delete; private: Fn fn_; }; template typename Fn auto make_defer(Fn fn) { return DeferFn(std::move(fn)); }使用方式auto guard make_defer([] { processing_ false; temp_dir_.cleanup(); });如果你的项目已经使用 C17还可以依赖 CTAD 直接写Defer guard(...)。不过在 C11 和 C14 里make_defer是最稳妥的写法。模板化带来的收益不只是性能。编译器可以直接看到 lambda 类型内联展开更友好调试时也更容易看到实际调用的对象。这个“让类型可见”的好处会被很多工程团队低估。3.2 提供 release()让调用方可以取消执行不是所有 defer 逻辑都一定需要执行。比如某个流程成功完成后清理动作就应该被撤销只有失败时才需要回滚。增加一个release()接口即可template typename Fn class Defer { public: explicit Defer(Fn fn) : fn_(std::move(fn)), active_(true) {} ~Defer() { if (active_ fn_) { fn_(); } } Defer(const Defer) delete; Defer operator(const Defer) delete; void release() { active_ false; } private: Fn fn_; bool active_; };典型场景auto rollback make_defer([] { pending_changes_.rollback(); }); if (!apply_changes()) { return false; // 自动回滚 } rollback.release(); return true; // 成功后不需要回滚关键点是release()必须在正确时机调用而且只能调用一次语义通常是“不再需要清理函数了”。如果误调用收尾逻辑就会被静默跳过所以这个接口要配上清晰注释最好在代码 review 时确认每个release()的调用位置都真的有取消依据。3.3 支持移动语义但要小心“被移走”的含义一个完善的 defer 通常应该支持移动构造和移动赋值。这在函数需要返回 guard、或者在条件分支不同路径里创建 guard 时特别有用。template typename Fn class Defer { public: explicit Defer(Fn fn) : fn_(std::move(fn)), active_(true) {} ~Defer() { if (active_) { fn_(); } } Defer(const Defer) delete; Defer operator(const Defer) delete; Defer(Defer other) noexcept : fn_(std::move(other.fn_)), active_(other.active_) { other.active_ false; } Defer operator(Defer other) noexcept { if (this ! other) { fn_ std::move(other.fn_); active_ other.active_; other.active_ false; } return *this; } void release() { active_ false; } private: Fn fn_; bool active_; };这里有一个非常重要的设计决策移动赋值时原来的 defer 对象已持有的清理函数是执行还是丢弃上面的实现选择“丢弃”让它被新对象接管。这是多数 scope guard 库采取的语义因为 defer 的根本意图是“在任何退出路径上只执行一次”如果移动赋值时执行旧清理执行时机就会变得不可预测。移动后的原对象active_会被置为 false因此它的析构不会重复执行清理。假如你后续还要检查“原对象是否还能用”唯一的答案就是不要用。3.4 用宏包装时小心展开和命名问题为了调用更接近关键字很多人会再包一层宏#define DEFER_CONCAT_IMPL(a, b) a##b #define DEFER_CONCAT(a, b) DEFER_CONCAT_IMPL(a, b) #define DEFER \ auto DEFER_CONCAT(_defer_, __LINE__) make_defer([]使用方式DEFER { processing_ false; temp_dir_.cleanup(); };这个写法在功能上没什么问题但它有三个隐藏风险。第一__LINE__不是全局唯一的。如果同一行出现了两个DEFER变量名就会冲突。解决办法是改用__COUNTER__但__COUNTER__是编译器扩展不是标准预定义宏。多数主流编译器都支持但要确认你的工具链兼容性。第二宏会把[]固定为 lambda 的捕获方式。这意味着 lambda 内部捕获的是所有引用非常容易在生命周期复杂的场景里造成悬垂引用。更好的做法是支持可配置捕获方式但这会让宏定义变得复杂。第三宏表达式没有类型检查一旦 lambda 内部写得有问题报错信息会指向一个很长的展开表达式调试很不友好。所以我的经验是小项目、个人工具可以用宏公共头文件和多人协作项目里更建议只提供make_defer工厂函数让调用者写出显式的auto cleanup ...。这多出来的几行字符换来的是可读性和可调试性。3.5 区分三种退出状态exit、fail、success如果只提供一个“退出时执行”的 defer很多语义其实表达得不够精确。清理逻辑在某些场景下只希望在异常退出时执行失败回滚只有失败时才需要清理脏状态。成功后通知只有成功时才提交事务、发消息。通用清理无论成功失败都要恢复状态。C 社区里常见的做法是类似标准提案中的scope_exit、scope_fail、scope_success三个守卫。其中scope_exit就是前面写的普通 defer另外两个需要判断当前是否存在异常展开。判断手段是std::uncaught_exceptions()。简单实现的思路是在对象构造时记录当时的异常计数在析构时再次读取如果数量变多说明当前正在异常展开。class ScopeFail { public: explicit ScopeFail(std::functionvoid() fn) : fn_(std::move(fn)), count_(std::uncaught_exceptions()) {} ~ScopeFail() { if (std::uncaught_exceptions() count_) { fn_(); } } // 禁用拷贝、移动 private: std::functionvoid() fn_; int count_; };需要注意std::uncaught_exceptions()是 C17 引入的接口使用时需要确认编译标准。它只能可靠表示“析构时是否存在异常展开”并不能告诉你函数正常退出时的全部细节所以 scope_fail / scope_success 的语义边界需要靠测试和评审来保证。大多数业务代码其实只需要普通的 scope_exit。scope_fail 和 scope_success 属于“完善”的高级选项不要从一开始就堆上所有变体。3.6 noexcept 与 constexpr边界能力不用强求析构函数默认是noexcept(true)所以如果 defer 内部的可调用对象抛异常程序会被直接 terminate。这是一个非常容易被忽视的坑。想修复这个坑并不简单。如果把析构函数声明为noexcept(false)会全面改变异常传播模型增加风险。更常见、也更推荐的做法是在 lambda 内部把异常处理掉或者约定清理函数绝不抛异常。清理逻辑通常是删文件、恢复标志位、关闭句柄绝大多数情况下本来就不应该抛异常。至于 constexprC20 以后析构函数可以是 constexpr但 defer 不是那种需要进入编译期求值的工具。除非你要在
返回列表