scope_guard - 作用域守卫¶
ScopeGuard 是 RAII(Resource Acquisition Is Initialization)模式的轻量级实现,确保一段代码在作用域结束时执行,无论是因为正常返回还是抛出异常。这个看似简单的工具在实际代码里非常好用——它能把"清理资源"和"业务逻辑"分开,让代码更清晰,也更容易避免资源泄漏。
为什么需要 ScopeGuard¶
考虑一个需要手动管理的资源:
// 没有 ScopeGuard 的写法
void process_file(const std::string& path) {
FILE* f = fopen(path.c_str(), "r");
if (!f) return;
void* buffer = malloc(1024);
if (!buffer) {
fclose(f); // 别忘了关闭文件
return;
}
if (some_condition) {
free(buffer); // 别忘了释放内存
fclose(f); // 别忘了关闭文件
return;
}
// 更多代码...
free(buffer); // 三个出口,三个地方写清理代码
fclose(f);
}
每个可能的返回路径都要记得清理所有资源,漏一个就泄漏。用 ScopeGuard 就简单多了:
// 有 ScopeGuard 的写法
void process_file(const std::string& path) {
FILE* f = fopen(path.c_str(), "r");
if (!f) return;
cf::ScopeGuard close_file([&f]() { fclose(f); });
void* buffer = malloc(1024);
if (!buffer) return;
cf::ScopeGuard free_buffer([&buffer]() { free(buffer); });
if (some_condition) return; // 自动清理
// 更多代码...
}
无论从哪个路径退出,ScopeGuard 都会执行对应的清理代码。你不需要在每个返回点都写一遍,也不容易漏。
基本用法¶
ScopeGuard 接受一个可调用对象(通常是 lambda),在销毁时执行:
#include "base/scope_guard/scope_guard.hpp"
{
int counter = 0;
cf::ScopeGuard guard([&counter]() {
counter = 42;
});
// 做一些事情...
// 离开作用域时 counter 变成 42
}
lambda 按引用捕获 counter,所以在守卫内部可以修改它。你也可以按值捕获,看具体需求。
取消防护¶
有时候你不想让守卫执行清理代码,可以调用 dismiss():
void save_config(const std::string& path) {
std::string temp_path = path + ".tmp";
// 创建临时文件
FILE* f = fopen(temp_path.c_str(), "w");
cf::ScopeGuard cleanup([&temp_path]() {
// 失败时删除临时文件
std::remove(temp_path.c_str());
});
// 写入配置...
// 原子重命名
if (std::rename(temp_path.c_str(), path.c_str()) == 0) {
cleanup.dismiss(); // 成功,不需要删除临时文件
}
}
dismiss() 是不可逆的,一旦调用就不能再恢复。多次调用 dismiss() 是安全的,不会有额外效果。
多个守卫的执行顺序¶
同一个作用域可以有多个 ScopeGuard,它们按照创建相反的顺序执行:
{
std::vector<int> order;
cf::ScopeGuard guard1([&order]() { order.push_back(1); });
cf::ScopeGuard guard2([&order]() { order.push_back(2); });
cf::ScopeGuard guard3([&order]() { order.push_back(3); });
}
// order = {3, 2, 1}
这和 C++ 局部变量的析构顺序一致——后创建的先析构。这个顺序很重要,如果多个守卫之间有依赖,你需要知道哪个先执行。比如先分配的资源应该后释放(LIFO),正好符合这个顺序。
异常安全¶
ScopeGuard 的清理代码在异常抛出时也会执行:
try {
cf::ScopeGuard guard([]() {
printf("Cleanup executed\n");
});
throw std::runtime_error("error");
// guard 仍然会执行
} catch (...) {
// 异常被捕获,但清理已经执行
}
⚠️ 如果清理代码本身抛出异常,这个异常会传播出去。如果在栈展开过程中(已经有一个异常在处理)清理代码又抛出异常,程序会调用 std::terminate。所以确保清理代码不会抛异常,或者把可能抛异常的代码用 try-catch 包起来。
典型使用场景¶
文件句柄管理¶
void read_config(const std::string& path) {
FILE* f = fopen(path.c_str(), "r");
if (!f) return;
cf::ScopeGuard close_file([f]() { fclose(f); });
// 使用文件...
// 离开作用域自动关闭
}
状态回滚¶
void update_state(State& s) {
State backup = s;
cf::ScopeGuard rollback([&s, backup]() {
s = backup; // 失败时恢复
});
// 尝试修改状态...
rollback.dismiss(); // 成功,不需要回滚
}
锁的释放¶
void critical_section() {
mutex.lock();
cf::ScopeGuard unlock([&mutex]() { mutex.unlock(); });
// 临界区代码...
}
当然更推荐直接用 std::lock_guard 或 std::unique_lock,但 ScopeGuard 可以处理更复杂的场景。
恢复修改过的变量¶
void process_item(Item& item) {
auto original_priority = item.priority();
cf::ScopeGuard restore([&item, original_priority]() {
item.set_priority(original_priority);
});
item.set_priority(Priority::High);
// 临时提高优先级处理...
// 离开作用域自动恢复
}
限制和注意事项¶
ScopeGuard 不可复制也不可移动:
cf::ScopeGuard guard1([]() {});
cf::ScopeGuard guard2 = guard1; // 编译错误
cf::ScopeGuard guard3 = std::move(guard1); // 编译错误
这个设计是为了确保清理代码只执行一次。如果允许复制,同一个守卫可能被复制到多个地方,不清楚应该由谁负责清理。如果允许移动,移动后原守卫的清理代码就不应该再执行,但这会让语义变得复杂。
另一个限制是内部使用 std::function 存储,所以 lambda 必须可复制。这意味着你不能按值捕获只能移动的类型(如 std::unique_ptr)。解决方法是按引用捕获,或者用 std::shared_ptr 包装。
// 这样不行
auto ptr = std::make_unique<int>(42);
cf::ScopeGuard guard([ptr]() {}); // 编译错误
// 可以这样
auto ptr = std::make_unique<int>(42);
cf::ScopeGuard guard([&ptr]() {}); // 按引用捕获
性能考虑¶
ScopeGuard 的开销主要是 std::function 的类型擦除。一个守卫对象通常占用 32-64 字节(取决于平台),构造和析构各有一次虚函数调用(通过 std::function 的机制)。
在绝大多数场景下这个开销是可以忽略的。如果你在极度性能敏感的代码里使用,而且清理代码是固定的,可以考虑写一个专门的 RAII 类。但 99% 的情况下,ScopeGuard 的便利性远大于这点开销。
控制流交互¶
ScopeGuard 和各种控制流都能正确配合:
// early return
for (int i = 0; i < 10; ++i) {
cf::ScopeGuard guard([]() { /* cleanup */ });
if (i == 5) return; // 仍然执行清理
}
// break/continue
for (int i = 0; i < 10; ++i) {
cf::ScopeGuard guard([i]() { printf("%d\n", i); });
if (i == 5) break; // 守卫仍然执行
}
// 输出: 0 1 2 3 4 5
// goto
{
cf::ScopeGuard guard([]() { /* cleanup */ });
goto end;
end:;
}
// 守卫仍然执行
无论控制流怎么跳转,只要离开了守卫所在的作用域,清理代码就会执行。这得益于 C++ 的 RAII 机制——析构函数总会在作用域结束时被调用。
设计决策¶
我们没有使用更复杂的实现(比如支持 dismiss 后重新激活,或者支持移动),是因为简单够用。ScopeGuard 的定位就是"创建时注册,销毁时执行",额外的功能只会增加理解和维护成本。
也不提供类似 SCOPE_EXIT 宏的实现。宏确实可以让代码更紧凑,但会引入一些问题:调试时栈追踪不清楚,命名空间污染,以及宏展开的意外行为。显式的变量声明虽然多打几个字,但意图更清晰。