什么是 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:
代码
cpp
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<int> 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 思想践行者,且都是精工细作,历经千锤百炼的,使用它们可以使绝大部分的资源管理变得自然而简洁。
容器类 大部分标准库容器都需要申请和释放动态内存,而这些工作都被标准库的实现者隐藏于表面之下,阻隔了手动管理动态内存的危险:
代码
cpp
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 的设计还能让它做其他一些事情,比如有一个设置值的函数,它需要将目标变量设置为传入的新值,但是在离开函数之前,旧值可能还会被使用,那么我们可以这样编写这个函数:
代码
cpp
//假如_value是一个全局变量或者set_value所属类的一个成员变量
void set_value(int new_value)
{
ValueSynchronizer<int> 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 bolck");
...
}
...
}
这非常适用于函数调用或者一个代码块的耗时统计,不用在作用域的开始和结束位置分别插入时间统计的代码,有利于维持代码的整洁可读。
临时目录管理
数据处理类代码对临时目录的管理也非常契合 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<< "can't create temporary directory, abort.\n"
return;
}
...//处理数据
}
目前为止,前面理论部分强调的拷贝和移动函数我们都没有关注过,因为这些例子使用场景非常简单,暂不触及它们。如果代码中需要它们(无论是直接的还是间接的),而我们又没有定义时,编译器就会按需合成他们的默认版本,具体的合成规则会深远影响到代码的行为。
我们扩展一下 TemporaryDirectory 的使用场景,以说明这一影响。假如我们的函数接受一个临时目录列表,需要创建好这些临时目录后再开展工作,我们这样编写代码:
代码
void process_data(const std::vector<std::string> &dir_list)
{
//根据列表创建临时目录
std::vector<TemporaryDirectory> vectd;
for(const auto &dir : dir_list)
vectd.push_back(TemporaryDirectory(dir));
//检查临时目录是否都已创建
for(const auto &td : vectd)
{
if(td.valid() == false)
return;
}
//永远不会到达
}
如果像示例一样使用 TemporaryDirectory,那么可以确定,这个函数永远会在第二个检查循环内返回。直接原因是编译器只为 TemporaryDirectory 创建了默认的拷贝构造函数,而没有创建默认的移动构造函数。vectd 的 push_back 虽然接受了一个临时的 TemporaryDirectory 对象,但是只能复制而非移动它,临时对象持有的 _dir 相应的也未被移动,当 push_back 结束,这个临时对象即被销毁,创建的目录也立即会被删除。
既然问题出在临时对象析构,那么我们不创建临时对象,直接原位构造行不行呢:
代码
//根据列表创建临时目录
std::vector<TemporaryDirectory> vectd;
for(const auto &dir : dir_list)
vectd.emplace_back(dir);
可惜还是不行,现在这个循环执行完之后,如果 dir_list 不只有 1 个元素,大概率只有靠后的一个或几个目录存在。现在问题牵涉 std::vector 的扩容机制了,我们都知道,vector 在调用 push_back 时如果容量不够用了,会重新开辟内存,将原来内存位置的元素迁移到新内存位置,在末尾构造新元素,并且销毁原有内存位置所有的元素并归还原有内存。前面说过 TemporaryDirectory 没有合成的移动构造函数,所以这个迁移过程只能动用合成的拷贝构造函数将原来位置的 TemporaryDirectory 逐个复制而非移动到新的位置,原来的元素持有的 _dir 相应也未被移动,当 push_back 结束,这些元素都已被销毁,创建的目录也随之被删除。
上面强调的大概率,是因为 C++ 语言标准只规定了 std::vector 要保证 push_back,emplace_back 这种在尾部添加元素的操作具有摊销常数时间(amortized constant time),这就导致它的扩容必须采用指数增长策略,即每次重新开辟的容量是上一次的 K(K > 1.0)倍。
虽然如此,首次扩容(容量为 0 时添加元素)分配的容量以及后续扩容的 K 值并没有固定的规约,每个版本的标准库实现都可能不同。假如有一份标准库实现首次扩容分配 2,K 为 1.5,那么若 dir_list 内有 2 个元素,程序会正常工作,有 4 个元素的话,当创建循环结束时,只有第 4 个目录存在,前 3 个会被移除;而另一份标准库实现首次扩容分配 1,K 为 2,那么在 dir_list 内有 2 个元素时,第 1 个目录就会被移除,有 4 个元素时,只有第 3、4 个目录存在,前 2 个会被移除。
可见不同的实现细节对上述代码运行结果的显著影响,说明 TemporaryDirectory 的实现存在巨大的缺陷。
当然我们可以编写这样的代码来针对性地解决这个问题:
代码
cpp
//根据列表创建临时目录
std::vector<TemporaryDirectory> vectd;
vectd.reserve(dir_list.size());//预先分配内存,避免后续扩容
for(const auto &dir : dir_list)
vectd.emplace_back(dir);
这种操作确实会让程序按照预想工作,但这就像鸵鸟把头埋进沙里,没有解决根本的问题,况且每次都要记得预先分配内存,显然是极其麻烦容易出错的。
让我们来分析一下这个问题,前面提到一个关键点------------ TemporaryDirectory 没有合成的移动构造函数,为什么编译器不给我们合成默认的拷贝构造函数?这里就牵出 C++ 的一个重要规则:三/五/零规则(The rule of three/five/zero)。这里简单概括一下这个规则:
三 如果一个类需要自定义析构函数,拷贝构造函数,拷贝赋值函数三个中的任意一个,那么几乎可以肯定,这个类有必要自定义所有这三个函数。
五 自定义的析构函数、拷贝构造函数或者拷贝赋值函数会阻止编译器合成默认的移动构造函数和移动赋值函数,如果一个类有移动语义需求,那它需要定义所有这五个函数。
零 如果一个类没有管理资源或所有权的需求,就不应该定义这五个函数中的任意一个。Scott Meyers 在他的一篇博客A Concern about the Rule of Zero中指出,如果不定义这些函数的意图是依赖编译器合成的默认版本,那么就应该明确用 =default 标明。
现在来审视一下 TemporaryDirectory 的设计需求:1.构造时创建临时目录;2.析构时移除临时目录;3.无拷贝需求;4.有移动构造需求。我们现有的实现只考虑了 1 和 2,并且由于自定义了析构函数,默认的移动构造和移动赋值函数无法合成,现在我们找到了症结,便可以修改实现:
代码
class TemporaryDirectory
{
public:
explicit TemporaryDirectory(const std::string &dir): _dir(dir)
{
create_directory(_dir);
}
//注意,标注为=default或=delete都被编译器视为自定义版本
TemporaryDirectory(const TemporaryDirectory &) = delete;
TemporaryDirectory &operator=(const TemporaryDirectory &) = delete;
TemporaryDirectory(TemporaryDirectory &&) = default;
TemporaryDirectory &operator=(TemporaryDirectory &&) = default;
~TemporaryDirectory()
{
//目录存在则将其删除
if(directory_exist(_dir) == true)
remove_directory(_dir);
}
bool valid() const
{
return directory_exist(_dir);
}
private:
std::string _dir;
};
void process_data(const std::vector<std::string> &dir_list)
{
//根据列表创建临时目录
std::vector<TemporaryDirectory> vectd;
for(const auto &dir : dir_list)
vectd.push_back(TemporaryDirectory(dir));
//检查临时目录是否都已创建
for(const auto &td : vectd)
{
if(td.valid() == false)
return;
}
//使用临时目录存储临时文件
...
}
代码中提到,标注为 =default 或 =delete 都被编译器视为自定义版本。这引出另一个问题,我们知道用作多态的基类必须声明一个 virtual 的析构函数,即使这个析构函数什么都不做,使用 =default 标明,而这会阻止编译器为它合成默认的移动构造函数和移动赋值函数,导致它不可移动,那么所有直接或间接继承自它的子类将都无法被移动,我们是不是必须为所有这样的基类添加移动构造函数和移动赋值函数?按照三/五/零规则确实是这样的,但是经过我在 MSVC(v143 C++14) 和 GCC(v9.3.0 C++14) 上的实验发现,只要类为空或只包含基础类型和指针类型,即使定义了析构函数(不必须 =default),都不会阻止编译器为它合成默认移动构造函数和移动赋值函数。这令我非常迷惑,但是并未搜索到解释这个问题的官方文档,并且我发现在 StackOverflow 上也有人对这个问题提问:1、2,但是并没有看到令人完全信服的回答,这个问题也许值得深究。
无论如何代码终于能正常工作了,可喜可贺。仅仅是构建这么一个功能简易的类,就花费了相当多的时间和精力,这么一想,STL 中那么多通用、稳定、顺手的模板我们都能随意取用,实在是非常庆幸了。
大型项目
在大型 C++ 项目中,资源管理永远是绕不开的话题,而且标准库提供的设施可能无法满足特定的需求,RAII 思想的应用有助于构建稳定高效的自定义资源管理系统。作者资历浅薄,没什么拿得出手的例子,这里就简单介绍两个著名的开源项目:
OpenSceneGraph 简称 OSG,是一个高性能、跨平台的 OpenGL 渲染引擎,它通过场景树、状态树和遍历器的配合实现对场景数据和渲染状态的自动高效管理。它有一套量身定制的资源管理系统:OSG 数据节点类、状态类、遍历器类都间接继承自一个名为 Referenced 的基类,这个类管理着自身的引用计数,它将析构函数 ~Referenced() 定义为 protected 使外部仅能在堆内存中创建实例;另一个名为 ref_ptr 的模板类是一个典型的 RAII 类,它以类似于标准库智能指针的运作方式管理 Referenced 子类实例的生命周期;还有一个名为 DeleteHandler 的类管理资源的释放行为,可以通过继承它来自定义资源释放逻辑(比如在性能需求高的时间段只将待释放的数据收集,在后续合适的时机统一释放)。这套系统能够处理渲染时场景数据和状态之间错综复杂的引用和嵌套关系,以及自动释放过期数据资源。开发者使用这个库只需要遵循这套系统的规则,即 Referenced 的子类在创建时交由 ref_ptr 管理,后续的操作都经由这个管理类,就可以简单地实现场景管理,避免内存泄漏。
OpenSourceComputerVisionLibrary 大名鼎鼎的 OpenCV,它的核心类 Mat 家族(cv::Mat,cv::UMat,cv::cuda::GpuMat 下文统称为 Mat) 对内存块的管理同样是以 RAII 机制实现。它们都直接或间接地管理一个对内存块的引用计数,Mat 的拷贝(通过 operator=),兴趣区域(ROI)的引用都只会增加内存块的引用数量而不拷贝内存;resize、cvtColor 等改变图像性质的操作,会断开操作对象与原内存的引用关系,重新分配内存,但只要原内存尚有其他引用,就不会被释放;clone、copyTo 等手动的深拷贝操作则会创建新的内存数据,现有数据不会改变。这些丰富的内存管理措施是 OpenCV 安全灵活高效的基本保证。
总结
- 如果有资源管理需求,优先使用标准库的 RAII 设施,这会让编码更轻松,代码更可靠。
- 将 RAII 思想应用于日常编码,有助于编写稳定、简洁、可维护的代码。
- 构建自己的资源管理类非常有挑战性,也相当有趣味,可以帮助理解语言机制的细节。