在介绍 RAII 之前,先来看一段代码 [1]
bool fn(std::mutex &someMutex, SomeDataSource &src) {
someMutex.lock();
BufferClass buffer;
if (not src.readIntoBuffer(buffer)) {
someMutex.unlock();
return false;
}
buffer.display();
someMutex.unlock();
return true;
}
这段代码有哪些问题?
首先,代码中有繁杂的加锁和解锁操作。在函数退出前,someMutex
必须被解锁,否则会造成死锁。这意味着每次因操作错误提前退出时,都要重复 someMutex.unlock();
和 return false;
这两条语句,不仅写起来麻烦,也降低了可读性。如果哪一次忘了解锁,还会造成死锁。
其次,这段代码不是异常安全的。如果在第 5 行 src.readIntoBuffer(buffer)
中抛出了异常,而函数 fn
又没有捕获异常,那么 fn
就会直接退出,第 6 行的解锁代码就不会执行,这又导致了死锁。
为了使 fn
能正确处理异常,需要使用 try 块包裹 4 ~ 9 行,并在 catch 里处理解锁。
bool fn(std::mutex &someMutex, SomeDataSource &src) {
someMutex.lock();
try {
BufferClass buffer;
if (not src.readIntoBuffer(buffer)) {
someMutex.unlock();
return false;
}
buffer.display();
}
catch (std::exception &e) {
someMutex.unlock();
throw e;
}
someMutex.unlock();
return true;
}
当抛出异常时,fn
首先在 catch 里解锁 someMutex
,再向上层抛出异常。
显然,try-catch 使代码更加复杂,可读性进一步降低。
一个解决方法是使用 goto 语句并禁用异常。在操作出错时,使用 goto 直接跳转到错误处理的代码,在那里解锁并退出。使用这种方法就不需要每次退出都重复解锁代码。禁用异常保证了控制流的简单。
bool fn(std::mutex &someMutex, SomeDataSource &src) {
someMutex.lock();
BufferClass buffer;
if (not src.readIntoBuffer(buffer))
goto unlock_and_exit;
buffer.display();
someMutex.unlock();
return true;
unlock_and_exit:
someMutex.unlock();
return false;
}
虽然 goto 语句通常被认为是有害的,但这个例子属于少数可以考虑使用 goto 的情况。
不过,这看起来并不像 C++ 的风格,在 C++ 里,你可以更好地处理这种情形......
RAII,即资源获取即初始化(Resource Acquisition Is Initialization),是一种自动管理资源的设计思想。RAII 的核心在于:将资源的获取和释放与 C++ 对象的生命周期绑定。当对象初始化时,资源也被获取并被初始化,当对象销毁时,资源也被释放。这里的资源可以是一段内存、一个文件、一个网络连接,或是一切需要获取和释放的东西。
C++11 引入的智能指针就是 RAII 的典型例子。
#include <memory>
int main() {
{
std::unique_ptr<int> ptr = std::make_unique<int>(10);
}
}
当变量 ptr
初始化时,一段内存已经被分配好了,里面的内容被初始化为 10,这是资源的获取,这里的资源指一段内存。
当程序退出 ptr
所在的语句块时,ptr
被销毁,原先分配的内存被释放,这是资源的释放。
可以看到,内存资源和 ptr
的生命周期紧密绑定,当 ptr
被初始化时,内存也被分配,当 ptr
被销毁时,内存也被释放。
C++11 还引入了 lock_guard
类。lock_guard
持有一个互斥量,它会在构造函数里对互斥量加锁,在析构函数里对互斥量解锁。有了 lock_guard
,就可以不用处处手动加锁解锁互斥量了。
用 lock_guard
重写本文开头的例子:
bool fn(std::mutex &someMutex, SomeDataSource &src) {
std::lock_guard<std::mutex> lock(someMutex);
BufferClass buffer;
if (not src.readIntoBuffer(buffer))
return false;
buffer.display();
return true;
}
当 lock
被构造时,构造函数会自动给 someMutex
加锁,因此不需要手动加锁。当 src.readIntoBuffer(buffer)
返回 false 时,函数会提前退出,lock
在退出前会自动销毁,在析构函数里解锁 someMutex
,因此也不需要手动解锁。当 src.readIntoBuffer(buffer)
抛出异常时,函数提前退出,lock
同样也会自动销毁并解锁 someMutex
,无需特殊处理。当函数正常退出时,同样会自动解锁。
可以说,lock_guard
利用其生命周期自动管理了互斥量的加锁解锁,大大简化了对互斥量的处理,不仅代码更简洁,bug 也更少。
在上面的例子中,资源是互斥量。接下来,看看如何使用 RAII 管理文件资源。
struct file_deleter {
void operator()(std::FILE *file) {
std::fclose(file);
}
};
bool fn(const char *filename) {
std::unique_ptr<std::FILE, file_deleter> file(std::fopen("file.txt", "r"));
if (file == nullptr)
return false;
if (!validate(file.get()))
return false;
return true;
}
这里使用了 unique_ptr
的自定义删除器。当 file
变量销毁时,会自动将其管理的指针传给删除器 file_deleter
,关闭文件。这段代码同样保证了文件一定会被关闭,无论是函数提前退出,还是发生异常。
这个例子使用 fopen
而不是 C++ 的 fstream
,是为了展示使用 RAII 机制管理 C 语言资源。如果是自己写的 C++ 项目,推荐使用 fstream
或其他现代 C++ 的设施。
善用 RAII 设计可以减少很多资源泄露。在现代 C++ 中,应避免使用 new 和 delete 手动管理内存,而应该使用智能指针自动管理内存。对于具有唯一所有权的资源,使用 unique_ptr
,对于具有多个所有权或需要在多线程使用的资源,使用 shared_ptr
,对于无所有权的资源观察者,应使用裸指针(不要 delete 裸指针)。
对于其他非内存资源,最好也使用 RAII 类封装,以便自动管理资源的释放。
参考
[1] CppCon2022 Back to Basics: RAII in C++ - Andre Kostur