C++ RAII 与 ScopeGuard
C++ RAII 与 ScopeGuard
RAII机制
RAII(Resource Acquisition Is Initialization),也就是“资源获取就是初始化”,是C++语言的一种管理资源、避免泄漏的惯用法。C++标准保证任何情况下,已构造的对象最终会销毁,即它的析构函数最终会被调用。简单的说,RAII 的做法是使用一个对象,在其构造时获取资源,在对象生命期控制对资源的访问使之始终保持有效,最后在对象析构的时候释放资源。
当我们在一个函数内部使用局部变量,当退出了这个局部变量的作用域时,这个变量也就别销毁了;当这个变量是类对象时,这个时候,就会自动调用这个类的析构函数,而这一切都是自动发生的,不要程序员显示的去调用完成。这个也太好了,RAII就是这样去完成的。
由于系统的资源不具有自动释放的功能,而C++中的类具有自动调用析构函数的功能。如果把资源用类进行封装起来,对资源操作都封装在类的内部,在析构函数中进行释放资源。当定义的局部变量的生命结束时,它的析构函数就会自动的被调用,如此,就不用程序员显示的去调用释放资源的操作了。
- 设计一个类封装资源;
- 在构造函数中初始化;
- 在析构函数中执行销毁操作;
- 使用时声明一个该对象的类;
先看一个不使用RAII的例子,在下面的UseFile函数中:
void UseFile(char const* fn) { FILE* f = fopen(fn, "r"); // 获取资源 // 使用资源 if (!g()) { fclose(f); return; } // ... if (!h()) { fclose(f); return; } // ... fclose(f); // 释放资源 }
在使用文件f的过程中,因某些操作失败而造成函数提前返回的现象经常出现。这时函数UseFile的执行流程将变为:
现在的问题是:用于释放资源的代码fclose(f)需要在不同的位置重复书写多次。如果再加入异常处理,情况会变得更加复杂。例如,在文件f的使用过程中,程序可能会抛出异常:
void UseFile(char const* fn) { FILE* f = fopen(fn, "r"); // 获取资源 // 使用资源 try { if (!g()) { fclose(f); return; } // ... if (!h()) { fclose(f); return; } // ... } catch (...) { fclose(f); // 释放资源 throw; } fclose(f); // 释放资源 }
我们必须依靠catch(...)来捕获所有的异常,关闭文件f,并重新抛出该异常。随着控制流程复杂度的增加,需要添加资源释放代码的位置会越来越多。如果资源的数量还不止一个,那么程序员就更加难于招架了。
如果使用RAII 的思想,
class FileHandle { public: FileHandle(char const* n, char const* a) { p = fopen(n, a); } ~FileHandle() { fclose(p); } private: // 禁止拷贝操作 FileHandle(FileHandle const&); FileHandle& operator= (FileHandle const&); FILE *p; };
FileHandle类的构造函数调用fopen()获取资源;FileHandle类的析构函数调用fclose()释放资源。请注意,考虑到FileHandle对象代表一种资源,它并不具有拷贝语义,因此我们将拷贝构造函数和赋值运算符声明为私有成员。如果利用FileHandle类的局部对象表示文件句柄资源,那么前面的UseFile函数便可简化为:
void UseFile(char const* fn) { FileHandle file(fn, "r"); // 在此处使用文件句柄f... // 超出此作用域时,系统会自动调用file的析构函数,从而释放资源 }
UserFile函数返回时,局部变量file生存期已过,会自动调用其析构函数。
如若使用文件file的代码中有异常抛出,难道析构函数还会被调用吗?此时RAII还能如此奏效吗?问得好。事实上,当一个异常抛出之后,系统沿着函数调用栈,向上寻找catch子句的过程,称为栈辗转开解(stack unwinding)。C++标准规定,在辗转开解函数调用栈的过程中,系统必须确保调用所有已创建起来的局部对象的析构函数。例如:
void Foo() { FileHandle file1("n1.txt", "r"); FileHandle file2("n2.txt", "w"); Bar(); // 可能抛出异常 FileHandle file3("n3.txt", "rw") }
当调用Bar()时,局部对象file1和file2已经在Foo的函数调用栈中创建完毕,而file3却尚未创建。如果Bar()抛出异常,那么file2和file1的析构函数会被先后调用(注意:析构函数的调用顺序与构造函数相反);由于此时栈中尚不存在file3对象,因此它的析构函数不会被调用。只有当一个对象的构造函数执行完毕之后,我们才认为该对象的创建工作已经完成。栈辗转开解过程仅调用那些业已创建的对象的析构函数。
再看一个多线程同步的例子:
int sum = 0; std::mutex m; void add() { m.lock(); for (int n=0; n<10000; n++) { sum++ ; } m.unlock(); } int main() { std::thread t1(add); std::thread t2(add); t1.join(); t2.join(); std::cout << sum << std::endl; }
对锁的管理(lock、unlock)也可以用RAII风格的方式:
int sum = 0; std::mutex lck; class MyMutex { public: MyMutex(std::mutex* p): plck(p) { plck->lock(); } ~MyMutex() { plck->unlock(); } private: std::mutex* plck; }; void add() { MyMutex mu(&lck); for (int n=0; n<10000; n++) { sum++ ; } } int main() { std::thread t1(add); std::thread t2(add); t1.join(); t2.join(); std::cout << sum << std::endl; }
事实上,std::lock_guard<T> 就是类似的实现。
综上所述,RAII的本质内容是用对象代表资源,把管理资源的任务转化为管理对象的任务,将资源的获取和释放与对象的构造和析构对应起来,从而确保在对象的生存期内资源始终有效,对象销毁时资源必被释放。换句话说,拥有对象就等于拥有资源,对象存在则资源必定存在。由此可见,RAII惯用法是进行资源管理的有力武器。
ScopeGuard
ScopeGuard 最大的用处也是释放资源。
比如分配内存,做某些操作,再释放内存,很多人会这样写:
void* data = malloc(size); xxxxx if (xxx) { xxxxx } else { xxxxx } free(data);
这样的代码是很脆弱的,有下面问题。
- malloc 和 free 可能会相隔很远,难以看出它们的对应关系。
- 另外中间有任何异常,中途 return,free 都不能被执行,就有资源泄露。
- 假如将这段代码复用,搬到另外地方,容易漏掉 free。
为解决资源释放问题,有些老旧的 C/C++ 代码,会采用折衷写法,会使用 do {} while(false)
,甚至会用到 goto。 比如:
do { if (xxx) { break; } if (xxx) { break; } xxx 正常操作 return 1; } while(false); free(data); xxxx 释放资源 return 0;
或者
if (xxxx) { goto fail; } if (xxxx) { goto fail; } xxx 正常操作 return 1; fail: free(data); xxxx 释放资源 return 0;
对比 ScopeGuard 的写法:
void* data = malloc(size); ON_SCOPE_EXIT { free(data); }; xxxxx if (xxx) { xxxxx } else { xxxxx }
资源一旦分配,接下来就立即使用 ScopeGuard 释放资源,这样分配和释放就会靠在一起,当退出作用域的的时候,里面的语句就被执行,释放掉资源。
无论下面的语句抛异常也好,中途退出也好,代码都是安全的。这样的代码也更容易修改,比如将其移动到另外的地方,它还是安全的。假如分配和释放分隔两地,移动代码时就很容易漏掉某些语句。
在Golang语言中,有defer关键字可以实现上面例子中的scope guard,而C++需要自己实现。
C++ 实现用到 C++ 11 的 lamda,定义对象将 lamda 存起来,在析构函数中调用。这个 lamda 在不同的场合也有不同的叫法,比如匿名函数,闭包,代码块,block。
有些小地方需要注意:一个是存储 lamda 使用了模板,而不是 std::function, 这个可以避免 lamda 转 std::function 的开销(尽管这个开销在绝大多数情况下可以忽略不计)。
#ifndef __SCOPE_GUARD_H__ #define __SCOPE_GUARD_H__ #define __SCOPEGUARD_CONCATENATE_IMPL(s1, s2) s1##s2 #define __SCOPEGUARD_CONCATENATE(s1, s2) __SCOPEGUARD_CONCATENATE_IMPL(s1, s2) #if defined(__cplusplus) #include <type_traits> // ScopeGuard for C++11 namespace clover { template <typename Fun> class ScopeGuard { public: ScopeGuard(Fun &&f) : _fun(std::forward<Fun>(f)), _active(true) { } ~ScopeGuard() { if (_active) { _fun(); } } void dismiss() { _active = false; } ScopeGuard() = delete; ScopeGuard(const ScopeGuard &) = delete; ScopeGuard &operator=(const ScopeGuard &) = delete; ScopeGuard(ScopeGuard &&rhs) : _fun(std::move(rhs._fun)), _active(rhs._active) { rhs.dismiss(); } private: Fun _fun; bool _active; }; namespace detail { enum class ScopeGuardOnExit {}; template <typename Fun> inline ScopeGuard<Fun> operator+(ScopeGuardOnExit, Fun &&fn) { return ScopeGuard<Fun>(std::forward<Fun>(fn)); } } // namespace detail } // namespace clover // Helper macro #define ON_SCOPE_EXIT \ auto __SCOPEGUARD_CONCATENATE(ext_exitBlock_, __LINE__) = clover::detail::ScopeGuardOnExit() + [&]() #else // ScopeGuard for Objective-C typedef void (^ext_cleanupBlock_t)(void); static inline void ext_executeCleanupBlock(__strong ext_cleanupBlock_t *block) { (*block)(); } #define ON_SCOPE_EXIT \ __strong ext_cleanupBlock_t __SCOPEGUARD_CONCATENATE(ext_exitBlock_, __LINE__) \ __attribute__((cleanup(ext_executeCleanupBlock), unused)) = ^ #endif #endif /* __SCOPE_GUARD_H__ */