C++ 里的 defer,本质上就是在离开当前作用域时,自动执行一段预先注册的清理代码。很多从 Go 或者其他带 defer 的语言转过来的 C++ 开发,第一反应是找标准库里有没有类似工具;在 C++23 之前标准库没有这个能力,所以大部分老项目需要自己实现。这个完善过程看起来只是一段“guard 类 + 宏”的代码,但实际落地时会碰到初始化顺序、变量名冲突、复制/移动被禁用、临时对象提前析构、清理动作顺序颠倒、异步捕获悬垂等一堆细节。
先说结论:如果你的项目还在 C++11/14/17,自己实现一个 defer 是值得的。它不能替代 RAII,但在处理 C 接口资源、作用域内临时状态归还、多段条件清理时,能让代码短很多,也不容易漏掉退出分支。下面会把一个 defer 从“能跑”完善到“生产环境也敢用”的过程拆开讲一遍。
1. 先搞清楚 defer 到底在补什么空缺
1.1 RAII 解决了大部分资源管理,剩下的反而更麻烦
RAII 是 C++ 资源管理的基础,只要资源能对应到一个对象生命周期,就优先用 RAII。比如std::unique_lock负责互斥锁的解锁,std::ofstream负责文件句柄,std::unique_ptr配合删除器负责裸指针内存。这类场景下,使用 RAII 没有争议。
但实际开发里还会遇到另一类同样常见的问题:
- 调用了一个 C 库,拿到了一个裸句柄,这个句柄只在当前函数里用一下,不想为了它专门写一个 Wrapper 类型。
- 需要在函数所有退出点上把某个全局状态恢复回去,比如日志缩进、线程局部标志、数据库事务的临时上下文。
- 一个函数里前半段申请资源 A,后半段申请资源 B,失败时 A 要释放,B 要回滚,还要保持释放顺序正确。
这些问题如果全部用 RAII 类解决,得给每个场景单独写类。如果只是在单个函数里用,写类的成本比手动清理还高。如果直接手动清理,又容易在新增分支时漏掉。defer 补上的空缺,就是把一段“临时清理逻辑”和“当前作用域退出”绑定起来。
1.2 C++ 的 defer 依赖析构顺序,不需要编译器新关键字
在 C++ 里,实现 defer 的常见方案是创建一个局部对象,在它的析构函数里执行注册好的 lambda。C++ 保证离开作用域时,局部对象会按构造顺序的逆序析构,所以这个机制天然成立,不需要新增关键字。
这里需要注意一点:C++ 的 defer 和 Go 的 defer 语义上很接近,都是后注册的先执行,也就是后进先出(LIFO)。后面的实现和代码,执行顺序都按这个规则来。如果看到多个 defer 执行顺序和自己预期不一致,多半不是代码 bug,而是执行顺序理解没有对齐。
1.3 使用场景和标准的对应关系
如果你的项目已经切到 C++23,可以直接用标准库里的std::scope_exit,它在<scope>头文件里,语义上和这里要写的 defer 基本一致。但大量存量项目还在 C++11、C++14 或 C++17,标准库没有现成方案,自己实现仍然有现实意义。
我一般把 defer 用在以下位置:
- 函数内部只需要执行一次释放的 C 接口资源,例如
fclose、free、CloseHandle。 - 需要把互斥锁、读写锁、自旋锁手动解锁,但
std::unique_lock又有点过重的时候。 - 临时修改全局状态、线程局部状态后,必须在函数结束前恢复旧值。
- 测试函数里创建文件、目录、临时数据库,退出时要清理掉。
先想清楚这些场景,后面实现时就不会为了“造语法糖”而做过度设计。
2. 第一版 defer:用最小 guard 类把 lambda 留在作用域里
2.1 核心代码从最简单的存函数开始
第一版不需要太多东西,只需要一个模板类,构造时接收一个可调用对象,析构时调用它。为了让这个 guard 能处理 lambda、函数指针、std::function和其他可调用对象,用模板最合适。
#include <type_traits> #include <utility> template <typename F> class defer_guard { public: explicit defer_guard(F&& f) : f_(std::forward<F>(f)) {} ~defer_guard() { f_(); } defer_guard(const defer_guard&) = delete; defer_guard& operator=(const defer_guard&) = delete; private: F f_; }; template <typename F> defer_guard<typename std::decay<F>::type> make_defer_guard(F&& f) { return defer_guard<typename std::decay<F>::type>(std::forward<F>(f)); }用法很直观:
void example() { FILE* fp = fopen("test.txt", "r"); if (!fp) { return; } auto guard = make_defer_guard([&]() { fclose(fp); }); // 其他业务逻辑 }不管函数中间是 return 还是抛出异常,只要fp打开成功,清理动作就会在离开作用域时执行。这段代码最大的价值,不是省掉一行fclose,而是让后续新增分支时不会再漏清理。
2.2 为什么这里要单独做一个make_defer_guard
在 C++11 和 C++14 里,编译器还不能直接从构造函数参数推导类模板的参数,所以直接写defer_guard guard([&]{ ... });会编译失败。make_defer_guard是当时的常用手法,它根据传入的 lambda 类型推导出defer_guard<F>。
如果项目是 C++17 及以上,类模板参数推导已经可用,代码可以简化一点:
defer_guard guard([&]() { fclose(fp); });但为了让代码在 C++11/14 里也能跑,后面的大部分示例还是用make_defer_guard。这个细节在完善时很重要,很多新手在这个位置会卡住,报错信息通常都是“缺少模板参数”或“没有匹配的构造函数”。
2.3 第一版的问题:没有 enabled 标志,也没有移动语义
第一版只适合最简单的情况,一旦遇到“条件清理”就不好办了。比如资源申请成功后,某条分支里清理动作不该执行,或者对象需要从函数返回,第一版都支持不了。
这个版本至少还有两个明显缺陷:
- 析构函数无条件执行,做不到“取消注册”。
- 类内部持有 lambda,不能复制,移动语义也没有定义,C++11 下
return路径可能出问题。 - 如果
f_()本身会抛出异常,析构函数会直接传播异常,可能触发std::terminate。
这些不是理论问题,而是实际使用中马上会碰到的。下面开始逐步完善。
3. 完善命名问题:宏封装是新手最容易踩的区域
3.1 手写变量名的问题
上一节的用法里,必须给defer_guard取一个变量名:
auto guard = make_defer_guard([&]() { ... });问题在于,函数里经常会写多个 defer。如果每次都手动取名,可能写出guard1、guard2、guard3,时间一长很容易重复,或者在嵌套作用域里产生遮蔽。
万一两个变量名重名,编译器的报错会非常绕,容易让人误以为是自己 lambda 写错了。更稳妥的办法是借助宏生成唯一名称,让使用者不需要关心这个局部对象叫什么。
3.2 用__LINE__还是__COUNTER__
生成唯一名字,常见有两个来源:
__LINE__,当前行号,是标准预定义宏。__COUNTER__,每次展开都会递增,是主流编译器扩展。
如果使用__LINE__,在同一行写两个 defer 会生成相同的变量名,出现编译错误。比如:
DEFER({ fclose(a); }); DEFER({ fclose(b); });这两条语句在同一行,但展开后名字都是_defer_guard_行号,直接冲突。
__COUNTER__没有这个问题,每次展开都会生成一个新值。GCC、Clang、MSVC 都支持。如果你的项目有编译器兼容性要求,可以保留__LINE__版本,但约定“一行最多写一个 defer”。生产环境我更推荐__COUNTER__。
3.3 一个能用的 defer 宏
把宏封装完整写出来,需要先解决宏展开时的两级拼接问题。直接写#define CAT(a, b) a##b在很多嵌套场景下,a或b本身又是一个宏时,展开结果不稳定。所以要包一层CAT_IMPL:
#define DEFER_CAT_IMPL(a, b) a##b #define DEFER_CAT(a, b) DEFER_CAT_IMPL(a, b) #define DEFER_UID(prefix) DEFER_CAT(prefix, __COUNTER__) #define DEFER(...) \ auto DEFER_UID(_defer_guard_) = make_defer_guard([&]() __VA_ARGS__)用法:
void example() { FILE* fp = fopen("test.txt", "r"); if (!fp) { return; } DEFER({ fclose(fp); }); DEFER({ fflush(NULL); }); }展开后大约是这样:
auto _defer_guard_0 = make_defer_guard([&]() { fclose(fp); }); auto _defer_guard_1 = make_defer_guard([&]() { fflush(NULL); });整个宏的核心逻辑并不复杂,难点在宏名拼接。DEFER_UID把_defer_guard_和__COUNTER__拼接成一个稳定且唯一的变量名,使用者完全不需要关心局部变量名。
3.4 宏封装不要过度
宏封装最常见的问题是过度设计。有人会想再包一层,让 defer 支持类似defer { ... };的花括号写法,或者把 lambda 捕获方式也做成参数。但每多一层宏,展开结果的不可读性都会上升,排查问题时也更难定位。
我建议只保留一层简单宏,目标很明确:生成唯一变量名,避免手写名字。其他东西尽量留在普通代码里完成。宏一旦复杂到需要读三遍才能理解,就失去工具的意义了。
4. 完善生命周期与执行语义:移动、禁用拷贝和 dismiss
4.1 为什么必须删除拷贝
defer_guard内部保存的是一个 lambda,lambda 捕获了外部局部变量的引用。如果允许拷贝,两个对象可能持有同一个清理动作,当两个对象析构时,清理动作会执行两次。
最典型的错误是:
auto guard1 = make_defer_guard([&]() { release(); }); auto guard2 = guard1;如果这段代码能编译,release()会执行两次。C++ 里删掉拷贝构造函数和拷贝赋值运算符是正确的做法。
defer_guard(const defer_guard&) = delete; defer_guard& operator=(const defer_guard&) = delete;这样,所有试图复制 defer 对象的代码都会在编译期报错,而不是运行期产生诡异 bug。
4.2 移动语义和“双重执行”问题
只删除拷贝还不够,make_defer_guard返回时会产生临时对象,C++11 下通常依赖移动构造来转移对象。如果没有移动构造,可能无法编译或退化成意外行为。
移动时需要注意的关键点是:如果不能把“是否启用”的状态一起转移,源对象析构时仍然会执行清理动作,清理动作也会执行两次。所以移动构造必须把源对象的enabled_置为 false。
template <typename F> class defer_guard { public: explicit defer_guard(F&& f) : f_(std::forward<F>(f)) {} defer_guard(defer_guard&& other) noexcept( std::is_nothrow_move_constructible<F>::value) : f_(std::move(other.f_)), enabled_(other.enabled_) { other.enabled_ = false; } defer_guard(const defer_guard&) = delete; defer_guard& operator=(const defer_guard&) = delete; ~defer_guard() { if (enabled_) { f_(); } } void dismiss() noexcept { enabled_ = false; } private: F f_; bool enabled_ = true; };有了enabled_标志,流程就清楚了:
- 构造时默认启用。
- 移动构造时,新对象接管
enabled_,源对象禁用它。 - 析构时只有
enabled_为 true 才执行。 dismiss()可以在运行时主动禁用。
4.3 dismiss 用于条件清理
dismiss()让 defer 从“无脑执行”变成“按条件执行”。比如写一个需要注册和反注册的函数:
void register_and_work() { auto guard = make_defer_guard([&]() { unregister_handler(); }); if (!register_handler()) { return; } // 假设这里后面还需要继续持有 handler guard.dismiss(); }注册成功后才需要一直持有,注册失败时立刻释放由 defer 处理。而guard.dismiss()调用之后,当前作用域退出时不会再执行unregister_handler()。
有些标准库实现用release()表示这个动作,语义是一样的,都是“放弃在析构时执行”。
4.4 无名字临时对象会提前析构,这是高频坑
很多人第一次用 defer,会写成这样:
// 错误示例 make_defer_guard([&]() { fclose(fp); });这条语句创建了一个临时对象,表达式结束后临时对象立刻析构,清理动作在“这一行结束”时就会执行,而不是当前作用域结束。如果后面还有代码在使用fp,就会出现“文件已经关闭”的诡异问题。
在给其他同学 review 代码时,我第一眼就是看 defer 有没有赋给一个局部变量,或者宏是否正确展开了。很多“defer 提前执行”的问题,根因不是析构函数写错,而是这个 guard 对象没有被命名。
5. 多动作与执行顺序:把 defer 完善到复杂场景
5.1 同一个作用域注册多个 defer 的执行顺序
同一个函数里注册多个 defer,执行顺序由局部对象构造顺序决定:
void example() { DEFER({ std::cout << "cleanup A\n"; }); DEFER({ std::cout << "cleanup B\n"; }); }输出结果是先 B 后 A。因为第二个 guard 后构造,析构时先执行。如果你希望先执行 A 再执行 B,调整注册顺序即可。
在写代码时,我会把“最后注册的清理动作”想象成离退出点最近的动作,这样更容易理解执行顺序。这个规则和 Go 的 defer 一致,不需要额外记忆。
5.2 顺序依赖时,最稳的办法是显示指定阶段
如果清理逻辑之间本身有明确依赖,比如必须先提交事务、再关闭连接、最后删除临时目录,那用多个 defer 可读性并不高。因为执行顺序是反着来的,阅读代码时要从下往上看。
遇到这种强顺序依赖,我建议用一个 defer 包住完整流程,或者用一个状态机/阶段变量:
int stage = 0; DEFER({ if (stage >= 3) remove_temp_dir(); if (stage >= 2) close_connection(); if (stage >= 1) finalize_transaction(); });这样顺序是显式的,不会因为调整 defer 的位置而改变。defer 更适合动作之间相对独立的清理,不适合描述复杂的依赖链。
5.3 引用捕获要注意作用域边界
DEFER([&]() { ... })里的 lambda 默认使用引用捕获,捕获的是局部变量。defer 在同一个作用域退出时执行,此时这些局部变量还没有销毁,所以引用捕获没有问题。
但如果 defer 对象被转发到其他函数,或者被放进容器、绑定到异步任务,引用捕获就有可能悬垂。
需要记住:defer 适合“同步作用域退出时清理”,不适合“把清理动作交到别的线程/别的生命周期里执行”。如果你真的需要把延迟动作保存下来,后续手动触发,应该用std::function容器,而不是 defer。
5.4 线程和异步边界
在多线程环境下,defer 对象本身不提供线程同步。同一个 defer 不应该被多个线程同时访问,也不需要,因为析构是在创建它的线程里发生。
比较常见的问题是:在回调里创建 defer,把局部对象的生命周期和回调作用域绑定。比如在线程池的任务函数里注册 defer,任务函数结束时自动执行清理,这是安全的。如果把 defer 对象作为共享资源传给多个线程,那问题不在 defer,而在设计本身。
6. 生产使用时的参数取舍和性能判断
6.1 用模板类还是std::function
如果使用std::function<void()>作为成员,实现会简单很多:
class defer_guard { public: template <typename F> explicit defer_guard(F&& f) : fn_(std::forward<F>(f)) {} ... private: std::function<void()> fn_; };但std::function在保存大 lambda 或捕获变量较多的 lambda 时,可能发生堆内存分配。每次进入作用域注册 defer,都可能带来一次分配开销。
模板版本的defer_guard<F>把 lambda 直接存在对象内部,不依赖虚函数和堆分配,性能上更接近“手写析构函数”。对于高频路径,比如游戏里每帧创建临时清理逻辑、循环注册 defer,模板版本更合适。
判断标准可以这样:
- 低频清理,比如文件关闭、锁释放、日志缩进,用
std::function版本方便,代码可读性也好。 - 高频调用、每帧多次、对延迟敏感的代码,用模板版本。
- 追求代码通用性,不想引入大模板,可以先量一下性能再决定。
我一般直接在项目里保留模板版本,因为它同时兼容低频和高频场景。std::function版本可以作为教学示例,不放进公共库。
6.2 析构函数和异常,这是最容易被忽略的点
defer 的清理逻辑通常在析构函数里执行,而析构函数默认是 noexcept。如果清理逻辑抛出异常,会触发std::terminate,程序直接终止。
因此,defer 里不该放可能抛异常的代码。如果清理逻辑本身调用了可能抛异常的函数,需要自己捕获:
DEFER({ try { service.stop(); } catch (...) { // 记录日志,不能让异常从析构函数里继续往外抛 } });一个完善的 defer 实现,可以在析构函数里对f_()做保护,但我更建议从调用方约束:defer 的 lambda 体应该保持不抛出异常。毕竟在析构函数里吞掉所有异常,也可能掩盖真正的问题。
6.3 什么时候优先走 RAII,而不是 defer
defer 很灵活,但灵活不是免费的。一个资源如果会在多处使用、跨越多个函数、需要复制/转移所有权,应该优先考虑 RAII,而不是处处 defer。
举几个典型场景:
- 需要明确拥有关系的内存缓冲区,用
std::vector、std::unique_ptr。 - 需要锁保护的结构体,用
std::lock_guard或std::unique_lock。 - 需要管理复杂生命周期的对象,交给智能指针。
defer 更适合“一次性”“局部性”“动作型”的清理,比如状态还原、句柄关闭、临时文件清理。它不是智能指针的替代品,也不是所有资源管理的终点。
6.4 项目已经能切 C++23,直接用标准库的 scope_exit
C++23 在<scope>头文件里提供了std::scope_exit等类型,语义和这里实现的 defer 接近。如果你的项目标准版本允许,用标准库可以减少自研代码的维护成本。
但要注意,标准库版本同样要求“作用域退出时执行”,代码依然要遵循局部对象生命周期、析构函数不抛异常这些基础约束。defer 的核心问题不会因为标准库提供而消失,只是少写一层实现。
旧项目想切 C++23 往往需要升级工具链、检查第三方依赖、处理编译兼容问题。这个东西不是短期能完成的。所以自己的 defer 实现,在 C++11/14/17 项目里会继续存在很长时间。
7. 完整实现示例:一个相对完善的 defer
把前面的细节合并起来,一个可用的 defer 实现如下:
#include <type_traits> #include <utility> template <typename F> class defer_guard { public: explicit defer_guard(F&& f) : f_(std::forward<F>(f)) {} defer_guard(defer_guard&& other) noexcept( std::is_nothrow_move_constructible<F>::value) : f_(std::move(other.f_)), enabled_(other.enabled_) { other.enabled_ = false; } defer_guard(const defer_guard&) = delete; defer_guard& operator=(const defer_guard&) = delete; ~defer_guard() { if (enabled_) { f_(); } } void dismiss() noexcept { enabled_ = false; } private: F f_; bool enabled_ = true; }; template <typename F> defer_guard<typename std::decay<F>::type> make_defer_guard(F&& f) { return defer_guard<typename std::decay<F>::type>(std::forward<F>(f)); } #define DEFER_CAT_IMPL(a, b) a##b #define DEFER_CAT(a, b) DEFER_CAT_IMPL(a, b) #define DEFER_UID(prefix) DEFER_CAT(prefix, __COUNTER__) #define DEFER(...) \ auto DEFER_UID(_defer_guard_) =