RAII

RAII|资源获取即初始化

C++,资源管理,异常安全,析构顺序。

编译环境:本地 MinGW-w64 g++ 13.1.0(x86_64-w64-mingw32),-std=gnu++17 -O2。文中每个例子的输出都是在这个配置下跑出来的。

一个资源跟着一个对象走

RAII 的全称是 Resource Acquisition Is Initialization,即资源获取即初始化。资源在这里指用完必须还回去的东西:堆内存、文件句柄、互斥锁、套接字、数据库连接。手写的形式一般是:

std::FILE* fp = std::fopen(path, "wb");
if (!fp) {
    return false;
}
// 后面还有若干出错分支
std::fclose(fp);

漏一个 return 前的 fclose 就漏一次,加一个分支就多一份责任。RAII 把释放动作放进析构函数:资源在构造时拿到、在析构时还回去,释放点由作用域决定,不由调用者的记性决定。cppreference 上的定义是,把必须先获取才能使用的资源,其生命期绑定到对象的生命期上 [1] 。

一个最小例子,顺便看清释放顺序:

struct Handle {
    const char* name;
    explicit Handle(const char* n) : name(n) { std::printf("acquire %s\n", name); }
    ~Handle() { std::printf("release %s\n", name); }
};

struct Session {
    Handle first;
    Handle second;
    Session() : first("first"), second("second") { std::printf("Session body\n"); }
    ~Session() { std::printf("release Session body\n"); }
};

main 里先构造一个外层对象,再进一层作用域构造 Session:

int main() {
    Handle outer("outer");
    {
        Session s;
        std::printf("--- leaving scope ---\n");
    }
    std::printf("--- end of main ---\n");
    return 0;
}

输出是:

acquire outer
acquire first
acquire second
Session body
--- leaving scope ---
release Session body
release second
release first
--- end of main ---
release outer

成员按声明顺序构造,析构按构造的逆序进行;Session 自己的析构体先跑完,才轮到 second 和 first。cppreference 的 RAII 条目写的也是这个:资源在对象生命期结束时按获取的逆序释放 [2] 。

顺序不是讲究。两个资源之间有依赖的时候,先打开的文件先关、多把锁按相反的次序放,把次序交给成员声明顺序表达,比在注释里写一句「注意先关 A」可靠。

构造没成功的那半个对象

析构函数只在对象构造完成之后才被调用。构造函数中途抛异常时,对象本体不存在,它的析构函数不会跑;但已经完整构造出来的成员和基类子对象是存在的,要按初始化逆序清理。

struct Res {
    const char* name;
    explicit Res(const char* n) : name(n) { std::printf("acquire %s\n", name); }
    ~Res() { std::printf("release %s\n", name); }
};

struct Widget : Res {
    Res m1;
    Res m2;
    Widget() : Res("base"), m1("member-1"), m2("member-2") {
        std::printf("Widget body\n");
        throw std::runtime_error("ctor failed");
    }
    ~Widget() { std::printf("~Widget body (never runs for a failed ctor)\n"); }
};

在主函数里接住这个异常,输出是:

acquire base
acquire member-1
acquire member-2
Widget body
release member-2
release member-1
release base
caught: ctor failed
--- end of main ---

包含 ~Widget body 的那一行没有出现。base 在 m2、m1 之后才释放。cppreference 的 RAII 条目里写着,构造函数以异常退出时,所有已完整构造的成员和基类子对象按初始化逆序释放 [3] 。

这条规则直接影响构造函数怎么写:第二个资源要等前一个资源已经交给成员持有之后再去获取。先拿两个裸句柄、最后一起赋给成员,中途抛出时还没交出去的那个就漏了。

析构函数不能抛出来

C++11 之后析构函数默认是 noexcept。在析构里抛出,编译器直接给警告:

warning: 'throw' will always call 'terminate' [-Wterminate]
note: in C++11 destructors default to 'noexcept'

运行到那里就是 terminate:

--- leaving try block ---
terminate called after throwing an instance of 'std::runtime_error'
  what():  thrown from a destructor

进程退出码是 \(3\),外面的 catch 没接到异常。崩溃消息的格式和退出码是这套运行时与这台机器上的结果,换个平台会变。

栈展开期间再抛更直接。把析构声明成 noexcept(false),在已经有一个异常往上走的时候抛出,标准同样要求 terminate [4] 。实测里能看到的输出还跟优化级别有关。

-O0 下先跑完析构里的清理,再 terminate,报告的是第二个异常:

cleanup
terminate called after throwing an instance of 'std::runtime_error'
  what():  thrown while unwinding

-O2 下清理那一行没有了,报告的是第一个异常:

terminate called after throwing an instance of 'std::runtime_error'
  what():  first exception

看 work 函数的汇编能解释这个差别。-O2 下它只剩下分配异常对象、构造 runtime_error 和 __cxa_throw 这几步,没有调用 ~Bad。Bad 的默认构造没有副作用,编译器把抛出挪到了对象存在之前,析构自然没被调用。所以拿 terminate 的报告反推析构跑没跑并不可靠。

需要知道当前是不是正在展开时,用 std::uncaught_exceptions():它返回当前线程中已经抛出或重抛、还没进入匹配 catch 的异常个数 [5] 。析构里比较这个计数和构造时记下的值,就能区分正常退出与异常退出。

提交还是回滚

写盘这类操作需要一个「中途出错就退回去」的清理器,正常走到末尾则是提交。

点击查看代码
#include <cstdio>
#include <exception>
#include <stdexcept>

// 作用域退出时执行的清理器:正常退出时不回滚,异常退出时回滚
class Rollback {
public:
    // 是否需要回滚只由异常计数决定,不额外记录提交状态
    Rollback() : m_onExit(std::uncaught_exceptions()) {}
    ~Rollback() {
        // 析构被调用时若异常计数比构造时多,说明正在栈展开,必须回滚
        if (std::uncaught_exceptions() > m_onExit) {
            std::printf("rollback\n");
        }
    }
private:
    int m_onExit;
};

struct Store {
    void write(int v) { std::printf("write %d\n", v); }
};

void commitPath() {
    Rollback guard;
    Store s;
    s.write(1);
    std::printf("commit\n");
}

void abortPath() {
    Rollback guard;
    Store s;
    s.write(2);
    throw std::runtime_error("storage full");
}

int main() {
    commitPath();
    try {
        abortPath();
    } catch (const std::exception& e) {
        std::printf("caught: %s\n", e.what());
    }
    std::printf("--- end of main ---\n");
    return 0;
}

两条路径的输出:

write 1
commit
write 2
rollback
caught: storage full
--- end of main ---

第一条走到末尾正常返回,异常计数没变,析构什么都不做;第二条在 write 之后抛出,计数变大,析构回滚。把提交与清理压在一个对象里,靠计数判断方向,比在每条错误分支上各写一遍回滚少一些遗漏的机会。

把所有权写进类型

自动存储期对象离开作用域就析构,所以「只允许有一个所有者」的语义可以直接写进类型:拷贝删掉,移动把源对象置空。

struct FileGuard {
    std::FILE* fp;
    explicit FileGuard(const char* path) : fp(std::fopen(path, "w")) {}
    ~FileGuard() {
        if (fp) {
            std::fclose(fp);
        }
    }
    FileGuard(const FileGuard&) = delete;             // 句柄同一时刻只有一个所有者
    FileGuard& operator=(const FileGuard&) = delete;
    FileGuard(FileGuard&& o) noexcept : fp(o.fp) { o.fp = nullptr; }  // 移动后原对象置空
};

移动之后原对象为空,两个对象的析构不会重复关闭:

open: ok
moved from: original is empty
close
--- end of main ---

标准库里有现成的:lock_guard 在构造时持有 mutex、析构时解锁、不可拷贝 [6] ;unique_ptr 在生命期结束或 reset 时用绑定的 deleter 释放对象 [7] 。要管的不是内存时,把删除器换掉就能复用 unique_ptr 的所有权语义:

struct FreeDeleter {
    void operator()(void* p) const noexcept {
        std::printf("custom deleter frees %p\n", p);
        std::free(p);
    }
};

std::unique_ptr<void, FreeDeleter> buf(std::malloc(64));

输出里的地址每次运行都不同:

owned pointer: 000002e11b661e10
custom deleter frees 000002e11b661e10
--- end of main ---

移动构造标上 noexcept

把 \(1000\) 个元素 emplace_back 进 vector,比较移动构造有没有 noexcept。

点击查看代码
struct MoveThrows {
    int id;
    static int copies;
    static int moves;
    explicit MoveThrows(int i) : id(i) {}
    MoveThrows(const MoveThrows& o) : id(o.id) { ++copies; }
    MoveThrows(MoveThrows&& o) : id(o.id) { ++moves; }   // 没有 noexcept
};

struct MoveNoexcept {
    int id;
    static int copies;
    static int moves;
    explicit MoveNoexcept(int i) : id(i) {}
    MoveNoexcept(const MoveNoexcept& o) : id(o.id) { ++copies; }
    MoveNoexcept(MoveNoexcept&& o) noexcept : id(o.id) { ++moves; }
};

// 两类各插入 1000 次,这里只写其中一段
std::vector<MoveNoexcept> v;
for (int i = 0; i < 1000; ++i) {
    v.emplace_back(i);
}

输出:

move ctor without noexcept: copies=1023 moves=0
move ctor with noexcept:    copies=0 moves=1023

差别来自 vector 扩容时怎么把元素搬到新缓冲区。它用 move_if_noexcept 在移动和拷贝之间选:移动构造可能抛异常、类型同时可拷贝时选拷贝,因为拷贝中途失败还能退回旧缓冲区,原来的元素还在;移动途中失败就没法收拾。给移动构造标上 noexcept,移动才会被选中。

\(1023\) 是扩容搬迁的累计次数:

\[1 + 2 + 4 + \cdots + 512 = 1023 \]

第一次插入分配容量 \(1\) 时没有元素要搬,之后容量按 \(2\) 的幂翻倍,每次翻倍把已有元素搬一遍。

代价在哪里

同一份逻辑写两遍,一遍用 RAII 封装、一遍手动 fopen 和 fclose,两个函数都加 noinline,在 -O2 下反汇编。objdump 出来的指令序列逐条一致:sub 调整栈、lea 取模式串、call fopen、保存返回值、test 判空、je 跳过关闭、call fclose、返回 \(1\)、收栈、ret。只有符号名不同。

正常路径上没有额外指令,析构被展开成了和手写释放一样的代码。

代价落在异常路径和展开信息上。同一个程序,默认开异常和加 -fno-exceptions 各编一份:可执行文件 \(81618\) 字节对 \(80686\) 字节,.text 段 0x81d8 对 0x8128,差 \(932\) 字节,约 \(1\) 个百分点。这点差异来自异常处理相关代码,与资源管理本身无关。

这份对比里两个可执行文件都没有 .eh_frame 段,展开信息走的是另一套结构,所以别用段名去判断有没有为异常付代价。

什么时候不合适

  • 资源生命期和对象生命期对不上。连接池里的连接要反复借出归还,跨对象共享的资源由谁持有也得想清楚。包成 RAII 对象意味着不能在作用域中途提前交还,除非对象另外提供 reset 或 close。
  • 释放的结果需要上报。析构不能抛,关闭和提交的返回值只能丢掉。要处理失败就得显式提供 close(),让析构去调用它并忽略结果。
  • 所有权不明。从 C 接口拿回来的裸指针、回调里传出去的指针,先弄清谁负责释放,再决定包不包。包错方向比不包更糟。
  • 跨编译单元的静态对象。析构顺序没有保证,别在一个静态对象的析构里依赖另一个静态对象还活着。
  • 作用域比资源实际使用区间大。构造析构本身没有额外指令,但资源会一直占着,这种时候要手动提前释放。

完整实现

完整的文件句柄与作用域退出实现:

点击查看代码
#include <cstdio>
#include <utility>

/**
 * @brief 独占文件句柄的极简 RAII 封装
 * @note 不可拷贝,可移动;析构时自动关闭,析构函数不抛异常
 */
class FileHandle {
public:
    FileHandle() noexcept = default;

    /**
     * @brief 打开文件并接管句柄
     * @param path 文件路径
     * @param mode 与 std::fopen 相同的模式串
     */
    FileHandle(const char* path, const char* mode) noexcept : m_fp(std::fopen(path, mode)) {}

    ~FileHandle() { (void)close(); }

    FileHandle(const FileHandle&) = delete;
    FileHandle& operator=(const FileHandle&) = delete;

    FileHandle(FileHandle&& other) noexcept : m_fp(other.m_fp) {
        // 移动后原对象不再持有句柄,析构时不会重复关闭
        other.m_fp = nullptr;
    }

    FileHandle& operator=(FileHandle&& other) noexcept {
        if (this != &other) {
            (void)close();
            m_fp = other.m_fp;
            other.m_fp = nullptr;
        }
        return *this;
    }

    /**
     * @brief 显式关闭,需要知道关闭结果时使用
     * @return 关闭成功或本来就没有句柄时返回 true
     * @note 析构函数也调用它,只不过丢弃返回值
     */
    bool close() noexcept {
        if (!m_fp) {
            return true;
        }
        std::FILE* fp = m_fp;
        m_fp = nullptr;
        return std::fclose(fp) == 0;
    }

    bool isOpen() const noexcept { return m_fp != nullptr; }
    std::FILE* get() const noexcept { return m_fp; }

private:
    std::FILE* m_fp = nullptr;
};

/**
 * @brief 作用域退出时执行一次回调
 * @tparam F 可调用的清理动作
 */
template <class F>
class ScopeExit {
public:
    explicit ScopeExit(F f) noexcept : m_func(std::move(f)) {}
    ~ScopeExit() { m_func(); }
    ScopeExit(const ScopeExit&) = delete;
    ScopeExit& operator=(const ScopeExit&) = delete;

private:
    F m_func;
};

程序的输出:

isOpen=1
moved, original isOpen=0
scope exit
--- end of main ---

  1. https://en.cppreference.com/w/cpp/language/raii ↩︎

  2. https://en.cppreference.com/w/cpp/language/raii ↩︎

  3. https://en.cppreference.com/w/cpp/language/raii ↩︎

  4. https://en.cppreference.com/w/cpp/language/destructor ↩︎

  5. https://en.cppreference.com/w/cpp/error/uncaught_exception ↩︎

  6. https://en.cppreference.com/w/cpp/thread/lock_guard ↩︎

  7. https://en.cppreference.com/w/cpp/memory/unique_ptr ↩︎

posted @ 2026-10-16 14:34  Ke_scholar  阅读(5)  评论(0)    收藏  举报