Skip to content

资源与生命周期:RAII、所有权、确定性析构 ​

面试里问「C++ 怎么管理资源」,真正想听的是一整套设计思想:资源为什么必须绑到对象上、所有权如何用类型表达、为什么不必依赖 GC、对称性怎样由编译器保证、悬垂与循环引用从何而来。本文把这些问题串成一条线,每条给出「一句话 + 展开 + 小例子 + 站内回链」。

速览 ​

  • RAII:资源随对象生、随对象亡,获取与释放交给构造/析构。
  • 所有权语义:用类型表达独占、共享、借用,谁持有谁负责。
  • 确定性析构:作用域结束即回收,不依赖 GC 择机运行。
  • 对称性:加锁↔解锁、open↔close 等必须成对且逆序。
  • Rule of 0/3/5:特殊成员要么全由编译器生成,要么全手写。
  • 构造即有效:构造成功即不变量成立,失败则抛、不留半成品。
  • 生命周期与悬垂:引用寿命不得超越被引用对象。
  • 值语义优先:拷贝/移动成本显式可见,减少别名耦合。

1. RAII ​

一句话:把资源的获取与释放分别绑进对象的构造与析构,让生命周期由编译器自动管理。

展开:手写 new/delete、open/close 极易在异常路径和提前 return 处漏掉配对操作,造成泄漏。RAII 的解法是「资源即对象」——构造函数获取资源、拿不到就抛异常,析构函数释放资源,而 C++ 保证栈对象离开作用域必被析构(包括异常传播时的栈展开)。边界:它管理的是「有明确释放动作」的东西,裸指针本身不是资源,必须包进 RAII 类型才安全。

小例子
cpp
class FileHandle {
    std::FILE* fp_;
public:
    explicit FileHandle(const char* path)
        : fp_(std::fopen(path, "r")) {
        if (!fp_) throw std::runtime_error("open failed"); // 失败即抛
    }
    ~FileHandle() noexcept { if (fp_) std::fclose(fp_); }   // 作用域结束自动关
    FileHandle(const FileHandle&) = delete;                 // 独占,禁拷贝
    FileHandle& operator=(const FileHandle&) = delete;
};

void read() {
    FileHandle f("data.txt");   // 构造即打开
    if (bad()) throw std::runtime_error("err");
    // 无论正常返回还是抛异常,f 都会析构 → 文件必然关闭
}

演示了「获取/释放」如何随对象生死自动成对发生,异常路径也不漏。

回链:RAII:资源获取即初始化、内存泄漏排查

2. 所有权语义 ​

一句话:明确「谁持有、谁负责释放」,并用类型把独占、共享、借用这三种关系写出来。

展开:资源管理的混乱多半源于所有权不清——两个指针都以为自己拥有同一块内存会 double free,没人觉得自己拥有则泄漏。现代 C++ 用类型编码所有权:unique_ptr 独占(不可拷贝、只能移动)、shared_ptr 共享(引用计数)、weak_ptr/裸引用/span 借用(不负责释放)。代价:共享所有权带来自增/自减的原子计数开销与循环引用风险;借用则要求被借对象的寿命覆盖使用者的寿命。

小例子
cpp
#include <memory>

std::unique_ptr<Widget> make() {          // 独占:返回即转移所有权
    return std::make_unique<Widget>();
}

void use() {
    auto a = make();                      // a 独占
    auto b = std::move(a);                // 所有权移给 b,a 置空
    // a->foo();                          // 危险:a 已不持有

    std::shared_ptr<Widget> s = b ? nullptr : nullptr;
    s = std::shared_ptr<Widget>(std::move(b)); // 共享:交出独占
    std::weak_ptr<Widget> w = s;          // 借用:不增加强引用计数
}

同一份资源分别用独占、共享、借用三种方式表达「谁负责释放」。

回链:智能指针总览、unique_ptr

3. 确定性析构(vs GC) ​

一句话:C++ 在作用域结束的确定时刻回收资源,不依赖垃圾回收器「有空时」再跑。

展开:GC 语言的内存由回收器在不确定的将来释放,且非内存资源(文件、锁、socket)仍需 finally/using 兜底;C++ 的析构时机确定——作用域结束、异常展开、显式销毁——因此内存与句柄能在同一时刻一并归还,延迟可预测,没有 Stop-the-World。代价:程序员要自己保证不泄漏(循环引用、遗忘释放、裸指针误用),并承担手动管理的复杂度;换来确定性、低开销与对实时/嵌入式友好。

小例子
cpp
void scoped() {
    std::vector<int> v(1000);        // 作用域开始即分配
    std::lock_guard<std::mutex> lk(mu); // 作用域内持锁
    // ... 做实事
}   // 离开作用域:lk 先解锁,v 后释放 → 顺序确定、时机确定

// 对比:GC 语言里 v 何时被回收不可预测,
// 但 lk 仍必须靠 finally 之类显式释放。

说明内存与锁都在同一确定时刻回收,而非等待回收器择机处理。

回链:虚拟内存、RAII:资源获取即初始化

4. 对称性 ​

一句话:每一对「获取/释放」必须严格配对,且释放顺序与获取顺序相反。

展开:构造↔析构、加锁↔解锁、open↔close、acquire↔release、new↔delete、malloc↔free、fopen↔fclose、begin↔end、push↔pop——对称性是资源与协议的基本约束。不对称是泄漏与死锁的头号来源:提前 return 漏了解锁、异常跳过了 close。根本解法都是把一头绑进构造、另一头绑进析构(即 RAII),让配对由编译器而非人保证。边界:跨模块、跨进程的配对(引用计数、连接池借还、请求/响应)需靠约定、协议或静态工具来检查。

小例子
cpp
// 反面:提前返回漏解锁
void bad(std::mutex& m) {
    m.lock();
    if (cond()) return;   // ⚠️ 死锁:忘了 unlock
    m.unlock();
}

// 正面:配对交给 lock_guard
void good(std::mutex& m) {
    std::lock_guard<std::mutex> lk(m); // 构造加锁
    if (cond()) return;                // 任何退出路径都解锁
}

同样的控制流,把配对交给 RAII 后,所有退出路径都自动对称。

回链:并发编程总览、同步原语

5. Rule of 0/3/5 ​

一句话:要么完全不手写特殊成员函数,要么析构、拷贝、移动这五个一起管好。

展开:一旦类需要自定义析构(说明它在管资源),编译器隐式生成的拷贝往往是浅拷贝,会 double free。Rule of Three 说析构、拷贝构造、拷贝赋值要一起定义;C++11 加移动构造、移动赋值成为 Rule of Five。最佳实践是 Rule of Zero:用成员(std::string、std::vector、unique_ptr)自带的语义拼装,自己不写任何特殊成员,编译器生成的全都正确。边界:Rule of Zero 要求所有成员都守 RAII;持有多态基类指针时还需虚析构。

小例子
cpp
// Rule of Five:手写析构 → 五个都得管
class Buffer {
    char* data_;
public:
    explicit Buffer(size_t n) : data_(new char[n]) {}
    ~Buffer() { delete[] data_; }
    Buffer(const Buffer& o) : data_(new char[o.n]) { /* 深拷贝 */ }
    Buffer& operator=(const Buffer& o) { /* 拷贝并交换 */ return *this; }
    Buffer(Buffer&& o) noexcept : data_(o.data_) { o.data_ = nullptr; }
    Buffer& operator=(Buffer&&) noexcept { /* ... */ return *this; }
private:
    size_t n{0};
};

// Rule of Zero:完全不管,全对
class Text {
    std::string s_;      // 成员自带正确语义
    std::vector<int> v_;
};

对比了「被迫手写五个」与「一个都不写」两种极端,后者才是首选。

回链:面向对象总览、RAII:资源获取即初始化

6. 构造即有效 / 不变量 ​

一句话:对象一旦构造成功,其全部不变量就必须成立;拿不到资源就抛,绝不留半成品。

展开:若构造函数中途失败却不抛异常,会留下「半初始化」对象,之后任何成员函数都可能在违反不变量的状态上运行。正确姿势是——构造完成即代表对象合法,任何成员函数都可放心假设不变量成立;构造中拿不到资源就抛异常,此时对象尚未构造完成,析构不会被调用,已获取的资源由 RAII 成员自动回收。代价:构造函数不宜做大量「可能失败且部分可见」的工作,复杂初始化可拆成两段式或工厂函数。

小例子
cpp
class Ratio {
    int num_, den_;                  // 不变量:den_ > 0,且已约分
    int gcd(int a, int b) { return b ? gcd(b, a % b) : a; }
public:
    Ratio(int n, int d) {
        if (d == 0) throw std::invalid_argument("zero denom"); // 失败即抛
        int g = gcd(n < 0 ? -n : n, d < 0 ? -d : d);
        num_ = n / g;
        den_ = d / g;
        if (den_ < 0) { num_ = -num_; den_ = -den_; }          // 归一化
    }
    // 此后每个成员函数都可假设 den_ > 0 —— 不变量成立
};

构造结束即保证不变量成立,任何方法无需再防御「分母为 0」。

回链:面向对象总览、面向对象核心概念

7. 生命周期与悬垂 ​

一句话:引用的寿命不得超过被引用对象,否则悬垂;返回引用、视图、循环引用是常见陷阱。

展开:悬垂都源于「借用的对象先死了」:返回局部变量的引用、string_view/span 指向已销毁的容器、shared_ptr 互相引用导致谁都不释放。防御手段——只返回指向更长寿对象的引用或视图(成员、静态、传入参数),循环引用处把一方改为 weak_ptr,并把 span/string_view 明确当作「只是借看、不负责存活」的非拥有视图,在调用边界处约定生命周期。代价:非拥有视图零拷贝、很快,但安全依赖程序员对寿命的推理。工程上的一条经验是——凡是「不拥有」的类型(T*、引用、span、string_view、迭代器、weak_ptr),都要能在代码里一眼看出它借用的对象活到什么时候;看不出,就说明寿命约定没写清楚。

小例子
cpp
#include <string>
#include <string_view>

std::string_view bad() {
    std::string s = "temporary";
    return s;                       // ⚠️ 返回时 s 已销毁 → 悬垂视图
}

std::string_view ok(const std::string& s, size_t from) {
    return std::string_view(s).substr(from);  // 借用调用者传进来的 s
}

struct Node {                                   // 循环引用
    std::shared_ptr<Node> next;                 // 双方都强引用 → 永不释放
    std::weak_ptr<Node>    prev;                // 反向改弱引用 → 破环
};

对比了「返回临时量的悬垂视图」与「借用外部生命周期」及「弱引用破环」三种情形。

回链:RAII:资源获取即初始化、std::string 深入

8. 值语义优先 ​

一句话:优先用值语义表达「独立拥有一份数据」,让拷贝与移动的成本显式可见。

展开:值语义的对象拷贝即独立副本、移动即转移资源,语义与成本都写在类型上(是否可拷贝、可移动、是否 noexcept),编译器据此做 move_if_noexcept、NRVO 与返回优化。相比到处传裸指针或共享引用,值语义减少了别名导致的无意修改与生命周期耦合,也让并发下的数据归属更清楚。代价:大对象按值传参有拷贝开销,应改用 const& 或移动传入;但「默认值语义、必要时才引用或共享」通常是更稳的默认。

小例子
cpp
#include <string>
#include <utility>

std::string build() {          // NRVO:直接构造在调用方位置
    std::string s = "hello";
    s += " world";
    return s;                  // 无拷贝
}

void consume(std::string s) {  // 按值接收:可拷贝可移动
    // ...
}

int main() {
    std::string a = build();
    consume(a);                // 拷贝一份,a 不受影响
    consume(std::move(a));     // 移走,零拷贝,a 变为有效但未指定
}

同一接口既能拷贝也能移动,成本随调用方式显式变化,无需靠裸指针共享。

回链:移动语义、std::vector

相关 ​

糌粑的面经笔记 · VitePress 版