i007.cc

i007.cc

优先队列-降维打击

05.价值资料

介绍下 RAII,我需要知道哪些知识?

RAII 是 C++ 最重要的编程惯用法,理解它是理解整个 C++ 资源管理体系的基础。


核心思想:资源生命周期 = 对象生命周期

RAII 全称 Resource Acquisition Is Initialization,翻译过来是”资源获取即初始化”,但这个名字不太直观。更准确的理解是:

把资源的获取放在构造函数里,把资源的释放放在析构函数里,利用 C++ 对象的确定性析构来保证资源一定被释放。

先看没有 RAII 时的代码有多脆弱:

cpp
// 没有 RAII:手动管理,到处是陷阱
void process_file(const char* path) {
    FILE* f = fopen(path, "r");
    if (!f) return;

    char* buffer = new char[1024];

    if (some_condition()) {
        delete[] buffer;    // 必须记得释放
        fclose(f);          // 必须记得关闭
        return;             // 如果忘了其中一个,就是 resource leak
    }

    // 如果这里抛出异常,下面两行永远不会执行
    do_something_that_may_throw(f, buffer);

    delete[] buffer;        // 可能执行不到
    fclose(f);              // 可能执行不到
}

 

每一个 return、每一个异常路径,都需要手动释放所有资源,遗漏一处就是 bug。


RAII 如何解决这个问题

cpp
// 有 RAII:对象析构时自动释放
void process_file(const char* path) {
    std::ifstream f(path);          // 构造时打开文件
    if (!f) return;

    auto buffer = std::make_unique<char[]>(1024);  // 构造时分配内存

    if (some_condition()) {
        return;   // 函数退出,f 和 buffer 自动析构,资源自动释放
    }

    do_something_that_may_throw(f, buffer.get());
    // 即使这里抛异常,栈展开(stack unwinding)会调用所有局部对象的析构函数
    // f 和 buffer 的资源依然保证被释放

}   // 函数正常退出,f 和 buffer 在这里自动析构

 

关键机制:C++ 保证,无论函数如何退出(正常 return、异常),所有已构造的局部对象都会按照构造的逆序被析构。 RAII 就是把资源清理挂在这个保证上。


自己实现一个 RAII 类

理解标准库之前,先看如何手写:

cpp
// 包装 FILE* 的 RAII 类
class FileHandle {
    FILE* file_ = nullptr;

public:
    // 构造:获取资源
    explicit FileHandle(const char* path, const char* mode) {
        file_ = fopen(path, mode);
        if (!file_)
            throw std::runtime_error("Failed to open file");
    }

    // 析构:释放资源(一定会被调用)
    ~FileHandle() {
        if (file_) {
            fclose(file_);
            file_ = nullptr;
        }
    }

    // 禁止拷贝:FILE* 不能有两个所有者
    FileHandle(const FileHandle&)            = delete;
    FileHandle& operator=(const FileHandle&) = delete;

    // 允许移动:转移所有权
    FileHandle(FileHandle&& other) noexcept
        : file_(other.file_) {
        other.file_ = nullptr;  // 原对象放弃所有权
    }
    FileHandle& operator=(FileHandle&& other) noexcept {
        if (this != &other) {
            if (file_) fclose(file_);  // 先释放自己的资源
            file_ = other.file_;
            other.file_ = nullptr;
        }
        return *this;
    }

    // 提供访问接口
    FILE* get() const { return file_; }
    operator bool() const { return file_ != nullptr; }
};

// 使用:
void process() {
    FileHandle f("data.bin", "rb");  // 构造即获取
    fread(buffer, 1, size, f.get());
    // 任何退出路径,f 析构时自动 fclose
}

 


Rule of Zero / Three / Five

这是 RAII 实现的核心准则,面试必考:

Rule of Zero(最优)

如果类的所有成员都是 RAII 类型(unique_ptrstringvector 等),则不需要自己写任何特殊成员函数——编译器自动生成的就是正确的:

cpp
// 完美的 Rule of Zero:什么都不写
class Player {
    std::string             name_;     // 自动管理内存
    std::unique_ptr<Mesh>   mesh_;     // 自动管理 Mesh 生命周期
    std::vector<Item>       inventory_;// 自动管理数组

    // 不需要写析构函数、拷贝/移动构造/赋值
    // 编译器生成的版本会逐个调用成员的析构/拷贝/移动
};

 

Rule of Five(需要管理原始资源时)

一旦你手动管理任何资源(裸指针、文件句柄、锁、GPU handle),就必须明确定义五个特殊函数,或者明确 delete

cpp
class Buffer {
    char*  data_;
    size_t size_;

public:
    // 1. 析构函数
    ~Buffer() { delete[] data_; }

    // 2. 拷贝构造(深拷贝)
    Buffer(const Buffer& other)
        : size_(other.size_), data_(new char[other.size_]) {
        memcpy(data_, other.data_, size_);
    }

    // 3. 拷贝赋值
    Buffer& operator=(const Buffer& other) {
        if (this == &other) return *this;
        delete[] data_;                    // 先释放旧资源
        size_ = other.size_;
        data_ = new char[size_];
        memcpy(data_, other.data_, size_);
        return *this;
    }

    // 4. 移动构造(noexcept 非常重要!)
    Buffer(Buffer&& other) noexcept
        : data_(other.data_), size_(other.size_) {
        other.data_ = nullptr;
        other.size_ = 0;
    }

    // 5. 移动赋值
    Buffer& operator=(Buffer&& other) noexcept {
        if (this == &other) return *this;
        delete[] data_;
        data_ = other.data_;
        size_ = other.size_;
        other.data_ = nullptr;
        other.size_ = 0;
        return *this;
    }
};

 

最常见的陷阱:只写了析构函数,忘了移动构造/赋值,导致 vector<Buffer> 扩容时用拷贝而不是移动,或者更糟——编译器生成的拷贝构造做浅拷贝,两个对象析构时 double free。


标准库里无处不在的 RAII

理解 RAII 之后,你会发现标准库几乎全是 RAII:

cpp
// 内存管理
std::unique_ptr<T>      // 独占所有权,析构时 delete
std::shared_ptr<T>      // 共享所有权,最后一个析构时 delete

// 文件 IO
std::ifstream / ofstream  // 析构时自动 close

// 互斥锁
std::lock_guard<std::mutex> lock(mtx);
// 构造时 lock,析构时 unlock,永远不会忘记 unlock

std::unique_lock<std::mutex> lock(mtx);
// 更灵活,支持手动 unlock、延迟 lock、转移所有权

// 线程
std::jthread t(func);   // C++20,析构时自动 join(std::thread 析构时 terminate!)

// 网络/系统资源
// 通常需要自己写 RAII wrapper

 


栈展开与异常安全

RAII 和异常的结合是它最重要的价值之一:

cpp
void game_tick() {
    std::lock_guard<std::mutex> lock(world_mutex_);  // 获取锁
    
    auto texture = load_texture("player.png");       // 可能抛异常
    auto mesh    = load_mesh("player.fbx");          // 也可能抛异常

    // 假设 load_mesh 抛出异常:
    // 1. 异常传播,开始栈展开
    // 2. texture 的析构函数被调用 → GPU 资源释放
    // 3. lock 的析构函数被调用 → mutex 被解锁
    // 不需要 try-catch,资源自动清理

    render(texture, mesh);
}

 

异常安全的三个级别

基本保证(Basic):   异常发生后,对象处于有效状态(不泄漏资源)
                      RAII 自动提供这个级别

强保证(Strong):    异常发生后,状态回滚到操作前(事务语义)
                      需要 copy-and-swap 等技术

不抛出保证(Nothrow):操作绝对不抛异常
                       移动构造/析构应该提供这个级别(加 noexcept)

 


游戏里的实际应用

游戏后端有很多需要自定义 RAII 的场景:

cpp
// GPU 资源的 RAII 管理
class GpuBuffer {
    VkBuffer       buffer_    = VK_NULL_HANDLE;
    VkDeviceMemory memory_    = VK_NULL_HANDLE;
    VkDevice       device_;

public:
    GpuBuffer(VkDevice device, size_t size, VkBufferUsageFlags usage)
        : device_(device) {
        // 创建 Vulkan buffer
        VkBufferCreateInfo info{...};
        vkCreateBuffer(device_, &info, nullptr, &buffer_);
        // 分配显存
        vkAllocateMemory(device_, &alloc_info, nullptr, &memory_);
        vkBindBufferMemory(device_, buffer_, memory_, 0);
    }

    ~GpuBuffer() {
        if (buffer_) vkDestroyBuffer(device_, buffer_, nullptr);
        if (memory_) vkFreeMemory(device_, memory_, nullptr);
    }

    GpuBuffer(const GpuBuffer&)            = delete;
    GpuBuffer& operator=(const GpuBuffer&) = delete;
    GpuBuffer(GpuBuffer&&) noexcept;       // 转移 handle 所有权
    GpuBuffer& operator=(GpuBuffer&&) noexcept;
};

// 数据库事务的 RAII
class Transaction {
    DBConnection& conn_;
    bool committed_ = false;
public:
    explicit Transaction(DBConnection& conn) : conn_(conn) {
        conn_.begin_transaction();
    }
    void commit() {
        conn_.commit();
        committed_ = true;
    }
    ~Transaction() {
        if (!committed_) conn_.rollback();  // 异常退出时自动回滚
    }
};

// 使用:
void transfer_coins(Player& from, Player& to, int amount) {
    Transaction txn(db_);
    from.coins -= amount;
    db_.update(from);         // 如果这里抛异常
    to.coins += amount;
    db_.update(to);           // 或这里抛异常
    txn.commit();             // 任何一步失败,析构自动 rollback
}

 


面试里的完整答题框架

RAII 是一道看似简单实则很有深度的题,好的回答应该覆盖:

是什么:把资源生命周期绑定到对象生命周期,构造获取,析构释放,利用 C++ 确定性析构(包括栈展开)保证资源一定被释放。

解决了什么:手动资源管理在多 return 路径和异常路径下极易泄漏。

Rule of Five:手动管理资源时五个特殊函数要么全写要么全 delete,析构不加 noexcept 会影响 vector 扩容策略。

标准库体现unique_ptrlock_guardfstream 都是 RAII,游戏里 GPU 资源、数据库事务、分布式锁也应该用 RAII 包装。

Rule of Zero:优先用已有的 RAII 类型组合,而不是手写资源管理,编译器生成的特殊函数往往就是正确的。

发表回复