什么是 RAII
RAII(资源获取即初始化,Resource Acquisition Is Initialization),作为 C++ 的一个重要编程范式,已经被贯彻于标准库的各个角落。RAII 的核心思想是将资源与类的生命周期绑定,RAII 类是针对内部资源封装的资源管理类。
RAII 有什么作用
RAII 的作用主要体现在:自动资源管理,异常安全,简化代码,提高可维护性。
自动资源管理 获取资源后交由 RAII 类保管,离开作用域后资源被妥善释放,减少手动资源管理容易出现的忘记释放和重复释放。
异常安全 代码可能在任何步骤抛出异常,C++ 保证在异常发生后,已经完全构造的局部变量会被析构,所以如果资源被一个已经构造好的 RAII 类保存着,那么在异常发生后它就能被安全释放。
简化代码 在复杂逻辑,特别是多返回路径的函数中,使用 RAII 类管理资源或状态,可大大降低手动管理带来的复杂性,增强可读性。
提高可维护性 RAII 类封装了资源管理的细节,与其他逻辑分离,便于代码维护。
RAII 类的工作原理
RAII 类依赖于 C++ 的栈对象生命周期管理机制,通过定义构造、拷贝和析构函数来精确控制类在创建、复制和销毁时的行为,以实现核心的资源保存、流转和释放。
构造函数 构造函数接受资源,将其存储在类中,同时初始化相关状态或接受其他与资源管理相关联的数据。比如 std::shared_ptr 除了存储指针外,还存储该指针的引用计数,在构造时必须初始化引用计数,它还支持传入自定义的删除器(我的上一篇随笔C++ 智能指针的删除器对它作过讨论)。
拷贝和移动函数 包括拷贝构造、移动构造、拷贝赋值、移动赋值四个成员函数,它们共同描述了资源的转移行为。
当资源为独占时,就不能允许发生复制动作,那么拷贝构造和拷贝赋值函数应该定义为删除,但是从一个临时的 RAII 类接管资源很合理,所以需要定义它的移动构造和移动赋值函数。一个现成的例子就是 std::unique_ptr:
代码- std::unique_ptr<int> create_unique(int value)
- {
- std::unique_ptr<int> ret(new int(value));
- return ret;//可能触发{{tip-code}}NRVO{{/tip-code}}
- }
- std::unique_ptr<int> piu1(new int(42));
- std::unique_ptr<int> piu2 = piu1;//错误,无法拷贝构造
- std::unique_ptr<int> piu3;
- piu3 = piu1;//错误,无法拷贝赋值
- piu3 = create_unique(42);//可以,接管指针
- piu3 = std::move(piu1);//强行转移所有权
- piu3.reset(piu1.release());//使用unique_ptr提供的接口强行转移所有权
复制代码 上述代码提到 NRVO(Named Return Value Optimization,具名返回值优化)是 C++ 拷贝消除机制(Copy Elision)的一种具体形式,该机制旨在消除不必要的临时对象拷贝以提高程序性能,可到 cppreference:copy_elision 查看详细讲解。
示例代码中的 create_unique 返回一个名为 ret 的局部变量,并且没有其他引用绑定到 ret 上,如果这样调用create_unique:std::unique_ptr 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::vector<int> veci;
- veci.reserve(1);//预先申请动态内存
- veci.push_back(0);
- veci.push_back(1);//内存不够用了,自动重新申请动态内存并迁移数据
- veci.clear();//清理数据和内存
复制代码 文件流类 使用标准库的文件流类,而不是直接使用 FILE *, 那么就不用担心有个打开的文件在不使用之后忘记关闭了。
锁管理类 使用标准库的锁管理类在进入临界区时锁定互斥量,那么一个提前离开临界区的动作就不会导致互斥量的解锁被跳过了:
代码- static std::mutex mutex1, mutex2, mutex3;
- void syncOperation()
- {
- //C++17之前,先同时锁定三个互斥量,然后用lock_guard领养它们
- std::lock(mutex1, mutex2, mutex3);
- std::lock_guard<std::mutex> guard1(mutex1, std::adopt_lock);
- std::lock_guard<std::mutex> guard2(mutex2, std::adopt_lock);
- std::lock_guard<std::mutex> guard3(mutex3, std::adopt_lock);
- //不好的锁定方式,若其他线程以不同的顺序锁定互斥量,极易造成死锁
- //std::lock_guard<std::mutex> guard1(mutex1);
- //std::lock_guard<std::mutex> guard2(mutex2);
- //std::lock_guard<std::mutex> guard3(mutex3);
- //C++17之后,使用scoped_lock
- std::scoped_lock lock(mutex1, mutex2, mutex3);
- ...//后续操作无论无论在何处返回,或者抛出异常,三个互斥量都保证能被解锁
- }
复制代码 智能指针类 使用标准库的智能指针管理指针,那么当无人引用该指针后,它所指涉的资源就能被及时释放:
代码- std::shared_ptr<int> create_shared(int value)
- {
- std::shared_ptr<int> ret(new int(value));
- return ret;
- }
- //函数内创建的指针被智能指针接管
- auto pis1 = create_shared(42);
- //资源在智能指针之间流转
- auto pis2 = pis1;
- pis1.release();
- std::shared_ptr<int> pis3(pis2);
- ...
- //最后一个持有资源的智能指针析构时释放资源
复制代码 创建自己的 RAII 类
标准库的 RAII 设施兼顾通用性和高性能,在设计上都极端考究,并且已经可以满足绝大部分的日常需求了,我们通常没有必要去构建与标准库类似的复杂设施(如果你有,那么能读到这里我实在受宠若惊),但是将 RAII 思想应用到日常的编码中,也能给我们带来诸多益处,在此我抛出几块拙劣的砖用以举例。
值同步
假如我们在调试一个函数时,需要将某个值改变为一个临时的测试值,但是函数结束后,这个值需要被还原为它初始的值,不能影响后续的程序执行:
代码- template<typename 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的一个快照
- ValueSynchronizer<int> vs(debug_value, debug_value);
- //后续的调试操作修改debug_value
- }
复制代码 现在无论 foo() 的逻辑多么复杂,在它返回时 debug_value 一定会还原到函数进入时的数值。
ValueSynchronizer 的设计还能让它做其他一些事情,比如有一个设置值的函数,它需要将目标变量设置为传入的新值,但是在离开函数之前,旧值可能还会被使用,那么我们可以这样编写这个函数:
代码- //假如_value是一个全局变量或者set_value所属类的一个成员变量
- void set_value(int new_value)
- {
- ValueSynchronizer<int> vs(_value, new_value);
- //其他的一些可能还会用到_value旧值的逻辑
- if(_value == 0)
- return;
- ...
- }
复制代码 ValueSynchronizer 的作用可以概括为:在创建时为操作对象指定一个目标值,保证在离开作用域后,该操作对象同步到设置的目标值。
过程计时器
RAII 类将资源与类的生命周期绑定的特性,很容易让人联想到一种过程计时器的实现:
代码[code]class ScopedTimer{public: explicit ScopedTimer(const std::string &scope_name) : _start(clock()), _scope_name(scope_name){ } ~ScopedTimer() { std::cout |