跳至内容

RAII:基本的资源管理模式

学习 RAII(资源获取即初始化)── C++ 中异常安全资源管理的核心模式。实现 RAII 包装器,理解 lock_guard 和 scoped_lock,以及为什么 RAII 类型必须是仅移动或引用计数的。

RAII:在构造函数中获取,在析构函数中释放

RAII(资源获取即初始化)是 C++ 中最重要的模式。其思想很简单:

1. 在构造函数中获取资源(打开文件、锁定互斥锁、分配内存、连接数据库)。
2. 在析构函数中释放资源(关闭文件、解锁互斥锁、释放内存、断开连接)。

因为 C++ 保证当对象离开作用域时调用析构函数 ── 即使抛出了异常 ── RAII 使资源管理自动化且异常安全。你永远不会忘记释放,也永远不会释放两次。

每个管理资源的良好设计的 C++ 类型都使用 RAII:std::string、std::vector、std::unique_ptr、std::fstream、std::lock_guard、std::unique_lock。

实现 RAII 包装器

这是一个文件描述符(POSIX)的完整 RAII 包装器。它在构造函数中获取,在析构函数中释放,并支持移动语义以进行所有权转移。

raii_file_descriptor.cpp
#include <unistd.h>   // open, close, read, write
#include <fcntl.h>    // O_RDONLY, etc.
#include <stdexcept>
#include <utility>
#include <iostream>

class FileDescriptor {
    int fd_ = -1;  // -1 = no resource
public:
    // Acquire: open file in constructor
    explicit FileDescriptor(const char* path, int flags)
        : fd_(::open(path, flags)) {
        if (fd_ < 0) throw std::runtime_error("Cannot open file");
    }

    // Release: close file in destructor
    ~FileDescriptor() {
        if (fd_ >= 0) ::close(fd_);
    }

    // Non-copyable (copying a file descriptor is meaningless)
    FileDescriptor(const FileDescriptor&) = delete;
    FileDescriptor& operator=(const FileDescriptor&) = delete;

    // Moveable (transfer ownership)
    FileDescriptor(FileDescriptor&& other) noexcept
        : fd_(std::exchange(other.fd_, -1)) {}

    FileDescriptor& operator=(FileDescriptor&& other) noexcept {
        if (this != &other) {
            if (fd_ >= 0) ::close(fd_);
            fd_ = std::exchange(other.fd_, -1);
        }
        return *this;
    }

    int get() const { return fd_; }
};

int main() {
    try {
        FileDescriptor fd("/tmp/raii_test.txt", O_RDONLY);
        std::cout << "File opened: fd=" << fd.get() << '\n';
        // Use fd...
    } catch (const std::exception& e) {
        std::cerr << e.what() << '\n';
    }
    // fd.~FileDescriptor() called automatically — file closed
    // Even if an exception was thrown, the file is closed
}

通过 RAII 实现异常安全

没有 RAII,异常安全的代码需要为每个资源编写繁琐的 try/catch 块。有了 RAII,一切自动化 ── 每个析构函数在栈展开期间触发。

exception_safety.cpp
#include <memory>
#include <mutex>
#include <fstream>
#include <vector>
#include <stdexcept>

std::mutex g_mutex;

// WITHOUT RAII — manual cleanup is error-prone
void bad_example() {
    std::mutex* m = &g_mutex;
    m->lock();
    int* data = new int[100];
    // If this throws, mutex stays locked AND data leaks!
    // process(data);
    delete[] data;
    m->unlock();
}

// WITH RAII — automatic, exception-safe cleanup
void good_example() {
    std::lock_guard<std::mutex> lock(g_mutex);  // RAII lock
    auto data = std::make_unique<int[]>(100);   // RAII allocation
    // If this throws:
    //   1. data's destructor frees the memory
    //   2. lock's destructor unlocks the mutex
    //   Stack unwinding handles everything
    // process(data.get());
}

// Multiple resources — RAII composes naturally
void multi_resource() {
    std::lock_guard<std::mutex> lock(g_mutex);
    auto buffer = std::make_unique<char[]>(4096);
    std::ofstream file("/tmp/output.txt");

    // If any operation throws, ALL resources are released
    // in reverse order of construction:
    //   1. file closed
    //   2. buffer freed
    //   3. mutex unlocked
}

lock_guard、unique_lock 与 scoped_lock

标准库提供了互斥锁的 RAII 包装器:

- std::lock_guard ── 最简单的 RAII 锁。在构造函数中上锁,在析构函数中解锁。不可移动、不可拷贝。当你需要简单的作用域锁时使用。

- std::unique_lock ── 灵活的 RAII 锁。支持延迟上锁、定时上锁、手动解锁/重新上锁,并且可以移动。与 std::condition_variable 一起使用时必须用它。

- std::scoped_lock(C++17)── 使用死锁避免算法同时锁定多个互斥锁。这是锁定一个或多个互斥锁时的首选锁类型。

std::mutex m1, m2;
// C++17:无死锁风险地锁定两者
std::scoped_lock lock(m1, m2);
常见陷阱

1. 未命名的 RAII 对象立即销毁:

std::lock_guard<std::mutex>(mtx);  // BUG!临时对象 ── 上锁后立即解锁
std::lock_guard<std::mutex> lock(mtx);  // 正确 ── 有名字,存活到作用域结束

2. 永远不要天真地拷贝 RAII 类型:拷贝文件句柄或互斥锁没有意义。RAII 类型应该是:
- 仅移动(如 unique_ptr、unique_lock)用于独占所有权,或
- 引用计数(如 shared_ptr)用于共享所有权。

3. 析构函数不应抛出异常:如果析构函数在栈展开期间(另一个异常处于活跃状态时)抛出异常,std::terminate 会被调用。在 C++11+ 中析构函数默认为 noexcept。

最佳实践

1. 每个资源都应由 RAII 对象拥有 ── 内存、文件、锁、套接字、数据库连接、GPU 句柄。
2. 每个 RAII 类管理一个资源 ── 保持包装器专注。
3. 删除拷贝操作,除非拷贝资源有意义。
4. 实现移动操作以启用所有权转移。
5. 在移动操作中使用 std::exchange ── 它原子地读取并重置源:fd_ = std::exchange(other.fd_, -1);
6. 优先使用标准 RAII 类型(unique_ptr、lock_guard、fstream)── 仅为没有标准包装器的资源编写自定义包装器。

组合 RAII 类型(零法则)

RAII 的终极表达是零法则:如果你所有的成员变量都是 RAII 类型,你的类不需要用户声明的特殊成员。编译器生成的析构函数、拷贝和移动操作会自动做正确的事情。

class DatabaseSession {
    std::unique_ptr<Connection> conn_;    // RAII:自动关闭
    std::unique_lock<std::mutex> lock_;   // RAII:自动解锁
    std::vector<std::string> log_;        // RAII:自动释放
    // 不需要析构函数、拷贝或移动 ── 所有成员类型自行处理
};

零法则是目标。五法则(显式定义所有 5 个特殊成员)是针对低级资源包装器类型的。没有中间地带 ── 如果你声明了五个中的任何一个,就声明所有五个。

关键要点
  • RAII:在构造函数中获取,在析构函数中释放 ── 自动且异常安全
  • 析构函数在栈展开期间被调用,所以 RAII 自然地处理异常
  • RAII 类型应该是仅移动(独占所有权)或引用计数(共享所有权)
  • 使用 std::scoped_lock(C++17)进行无死锁的多互斥锁锁定
  • 未命名的 RAII 对象立即销毁 ── 始终给它们一个名字
  • 遵循零法则:使用 RAII 成员,让编译器生成正确的特殊成员函数

测验 — 检验你的知识

(15 经验值)

1. `std::lock_guard<std::mutex>(mtx);`(注意:没有变量名)有什么问题?

2. 为什么 RAII 类型通常应该删除其拷贝操作?

3. 什么是零法则?