RAII 的作用主要体现在自动资源管理异常安全简化代码提高可维护性。自动资源管理 获取资源后交由 RAII 类保管离开作用域后资源被妥善释放减少手动资源管理容易出现的忘记释放和重复释放。异常安全 代码可能在任何步骤抛出异常C 保证在异常发生后已经完全构造的局部变量会被析构所以如果资源被一个已经构造好的 RAII 类保存着那么在异常发生后它就能被安全释放。简化代码 在复杂逻辑特别是多返回路径的函数中使用 RAII 类管理资源或状态可大大降低手动管理带来的复杂性增强可读性。提高可维护性 RAII 类封装了资源管理的细节与其他逻辑分离便于代码维护。RAII 类的工作原理RAII 类依赖于 C 的栈对象生命周期管理机制通过定义构造、拷贝和析构函数来精确控制类在创建、复制和销毁时的行为以实现核心的资源保存、流转和释放。构造函数 构造函数接受资源将其存储在类中同时初始化相关状态或接受其他与资源管理相关联的数据。比如 std::shared_ptr 除了存储指针外还存储该指针的引用计数在构造时必须初始化引用计数它还支持传入自定义的删除器我的上一篇随笔C 智能指针的删除器对它作过讨论。拷贝和移动函数 包括拷贝构造、移动构造、拷贝赋值、移动赋值四个成员函数它们共同描述了资源的转移行为。当资源为独占时就不能允许发生复制动作那么拷贝构造和拷贝赋值函数应该定义为删除但是从一个临时的 RAII 类接管资源很合理所以需要定义它的移动构造和移动赋值函数。一个现成的例子就是 std::unique_ptr代码std::unique_ptrint create_unique(int value) { std::unique_ptrint ret(new int(value)); return ret;//可能触发NRVO } std::unique_ptrint piu1(new int(42)); std::unique_ptrint piu2 piu1;//错误无法拷贝构造 std::unique_ptrint piu3; piu3 piu1;//错误无法拷贝赋值 piu3 create_unique(42);//可以接管指针 piu3 std::move(piu1);//强行转移所有权 piu3.reset(piu1.release());//使用unique_ptr提供的接口强行转移所有权上述代码提到 NRVONamed Return Value Optimization具名返回值优化是 C 拷贝消除机制Copy Elision的一种具体形式该机制旨在消除不必要的临时对象拷贝以提高程序性能可到 cppreference:copy_elision 查看详细讲解。示例代码中的 create_unique 返回一个名为 ret 的局部变量并且没有其他引用绑定到 ret 上如果这样调用create_uniquestd::unique_ptrint piu4 create_unique(42);在编译器支持 NRVO 的情况下ret 变量不会被实际创建而是直接在外部 piu4 的内存位置直接构造达到消除拷贝的目的。若编译器未支持或者代码情况不满足 NRVO 条件移动构造则作为第二候选用来避免拷贝拷贝构造的优先级最低因为拷贝一个对象可能付出高昂的代价。由于 std::unique_ptr 删除了拷贝构造和拷贝赋值函数我们无法复制一个现有的实例但是定义了移动构造和移动赋值函数我们可以在函数中返回一个局部构造的实例用以构造或者赋值给另一个 std::unique_ptr。强行转移 std::unique_ptr 的资源所有权是可以的但是为了宣示独占性手动转移的语法都不那么自然。而当资源能够共享时除了定义移动构造函数和移动赋值函数用以接管临时对象资源外拷贝构造和拷贝赋值函数的定义显得更为重要。std::shared_ptr 的拷贝函数维护引用计数这是它实现指针管理的重要一环而容器类如 std::vector 的拷贝函数需要负责可能的内存清理和分配所有元素的拷贝以及过程中异常的处理。析构函数 析构函数负责资源的清理工作意味着一个实例工作的结束但是要避免让异常逃离析构函数Scott Meyers, Effective C, Item 8。上述函数的主要职责是确保 RAII 类与编译器的协作实现资源的自动生命周期管理而为了使资源管理更加灵活RAII 类通常还会提供一系列面向用户的接口这些接口依据具体资源的特性设计用以支持资源的读取、修改或状态查询兼顾自动化与可操作性。这使得 RAII 类成为底层机制与上层接口之间的桥梁保证精细复杂的资源操作以稳定可靠的方式进行。使用标准库的 RAII 设施标准库提供的诸多常用设施都是典型的 RAII 思想践行者且都是精工细作历经千锤百炼的使用它们可以使绝大部分的资源管理变得自然而简洁。容器类 大部分标准库容器都需要申请和释放动态内存而这些工作都被标准库的实现者隐藏于表面之下阻隔了手动管理动态内存的危险代码std::vectorint veci; veci.reserve(1);//预先申请动态内存 veci.push_back(0); veci.push_back(1);//内存不够用了自动重新申请动态内存并迁移数据 veci.clear();//清理数据和内存文件流类 使用标准库的文件流类而不是直接使用 FILE * 那么就不用担心有个打开的文件在不使用之后忘记关闭了。锁管理类 使用标准库的锁管理类在进入临界区时锁定互斥量那么一个提前离开临界区的动作就不会导致互斥量的解锁被跳过了代码static std::mutex mutex1, mutex2, mutex3; void syncOperation() { //C17之前先同时锁定三个互斥量然后用lock_guard领养它们 std::lock(mutex1, mutex2, mutex3); std::lock_guardstd::mutex guard1(mutex1, std::adopt_lock); std::lock_guardstd::mutex guard2(mutex2, std::adopt_lock); std::lock_guardstd::mutex guard3(mutex3, std::adopt_lock); //不好的锁定方式若其他线程以不同的顺序锁定互斥量极易造成死锁 //std::lock_guardstd::mutex guard1(mutex1); //std::lock_guardstd::mutex guard2(mutex2); //std::lock_guardstd::mutex guard3(mutex3); //C17之后使用scoped_lock std::scoped_lock lock(mutex1, mutex2, mutex3); ...//后续操作无论无论在何处返回或者抛出异常三个互斥量都保证能被解锁 }智能指针类 使用标准库的智能指针管理指针那么当无人引用该指针后它所指涉的资源就能被及时释放代码std::shared_ptrint create_shared(int value) { std::shared_ptrint ret(new int(value)); return ret; } //函数内创建的指针被智能指针接管 auto pis1 create_shared(42); //资源在智能指针之间流转 auto pis2 pis1; pis1.release(); std::shared_ptrint pis3(pis2); ... //最后一个持有资源的智能指针析构时释放资源创建自己的 RAII 类标准库的 RAII 设施兼顾通用性和高性能在设计上都极端考究并且已经可以满足绝大部分的日常需求了我们通常没有必要去构建与标准库类似的复杂设施如果你有那么能读到这里我实在受宠若惊但是将 RAII 思想应用到日常的编码中也能给我们带来诸多益处在此我抛出几块拙劣的砖用以举例。值同步假如我们在调试一个函数时需要将某个值改变为一个临时的测试值但是函数结束后这个值需要被还原为它初始的值不能影响后续的程序执行代码templatetypename Op, typename Tar Op class ValueSynchronizer { public: ValueSynchronizer(Op operand, Tar target) : _operand(operand), _target(target){ } ~ValueSynchronizer(){ _operand _target; } private: _Op _operand; const Tar _target; }; //在调试时使用假如debug_value是一个全局变量或foo所属类的一个成员变量 void foo() { //创建debug_value的一个快照 ValueSynchronizerint vs(debug_value, debug_value); //后续的调试操作修改debug_value }现在无论 foo() 的逻辑多么复杂在它返回时 debug_value 一定会还原到函数进入时的数值。ValueSynchronizer 的设计还能让它做其他一些事情比如有一个设置值的函数它需要将目标变量设置为传入的新值但是在离开函数之前旧值可能还会被使用那么我们可以这样编写这个函数代码//假如_value是一个全局变量或者set_value所属类的一个成员变量 void set_value(int new_value) { ValueSynchronizerint vs(_value, new_value); //其他的一些可能还会用到_value旧值的逻辑 if(_value 0) return; ... }ValueSynchronizer 的作用可以概括为在创建时为操作对象指定一个目标值保证在离开作用域后该操作对象同步到设置的目标值。过程计时器RAII 类将资源与类的生命周期绑定的特性很容易让人联想到一种过程计时器的实现代码class ScopedTimer { public: explicit ScopedTimer(const std::string scope_name) : _start(clock()), _scope_name(scope_name){ } ~ScopedTimer() { std::cout _scope_name duration: (clock() - _start)/(float)CLOCKS_PER_SEC seconds.\n; } private: clock_t _start; const std::string _scope_name; }; //使用过程计时器 void foo() { ScopedTimer function_timer(__func__); { ScopedTimer block_timer(inner block); ... } ... }这非常适用于函数调用或者一个代码块的耗时统计不用在作用域的开始和结束位置分别插入时间统计的代码有利于维持代码的整洁可读。临时目录管理数据处理类代码对临时目录的管理也非常契合 RAII 思想在开始处理前创建临时目录处理过程中写入临时数据过程结束后需要删除临时目录代码class TemporaryDirectory { public: explicit TemporaryDirectory(const std::string dir): _dir(dir) { create_directory(_dir); } ~TemporaryDirectory() { //目录存在则将其删除 if(directory_exist(_dir) true) remove_directory(_dir); } bool valid() const { return directory_exist(_dir); } private: std::string _dir; }; void process_data() { TemporaryDirectory td(temp); if(td.valid() false) { std::cerr cant create temporary directory, abort.\n return; } ...//处理数据 }目前为止前面理论部分强调的拷贝和移动函数我们都没有关注过因为这些例子使用场景非常简单暂不触及它们。如果代码中需要它们无论是直接的还是间接的而我们又没有定义时编译器就会按需合成他们的默认版本具体的合成规则会深远影响到代码的行为。我们扩展一下 TemporaryDirectory 的使用场景以说明这一影响。假如我们的函数接受一个临时目录列表需要创建好这些临时目录后再开展工作我们这样编写代码