在介绍 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

最后修改:2025 年 06 月 05 日
如果觉得我的文章对你有用,请随意赞赏