第29章:C++中的智能指针与设计原则——shared_ptr/unique_ptr与资源所有权,weak_ptr打破循环引用

智能指针这个话题,说实话,我每次面试新人时都会问。为什么?因为它太能反映一个人对C++资源管理的理解了。很多人会用智能指针,但真问到「为什么这里用shared_ptr而不是unique_ptr」时,就卡住了。

这一章,我们就来聊聊智能指针背后的设计原则。说白了,就是「谁该拥有这个资源」的问题。

29.1 资源所有权:一个古老又核心的问题

在C++里,资源管理一直是个头疼事。你new了一个对象,就得记得delete。但现实是——异常、提前返回、复杂的控制流……任何一个环节出问题,资源就泄漏了。

我早期做嵌入式开发时,就吃过这个亏。一个网络模块里new了缓冲区,结果中间某个分支抛了异常,delete没走到。那段时间我天天盯着内存泄漏报告,头都大了。

后来我明白了:资源所有权必须明确,而且最好自动化。这就是智能指针诞生的根本原因。

核心思想:每个资源(堆内存、文件句柄、数据库连接)都必须有一个明确的「所有者」。所有者负责释放资源。智能指针就是把这个「所有者」的概念用代码表达出来。

29.2 unique_ptr:独占所有权,轻量高效

unique_ptr 的设计意图很明确:一个资源只能有一个所有者。你不能拷贝它,只能移动它。

为什么这样设计?你想想看,如果两个指针同时指向同一块内存,谁该负责释放?搞不好就double free了。unique_ptr 直接把这个可能性掐死在编译期。

#include <memory>

class Resource {
public:
    Resource() { /* 打开文件、分配内存等 */ }
    ~Resource() { /* 释放资源 */ }
};

void process() {
    // 独占所有权
    std::unique_ptr<Resource> ptr = std::make_unique<Resource>();
    
    // 错误:不能拷贝
    // std::unique_ptr<Resource> ptr2 = ptr;  // 编译错误
    
    // 正确:转移所有权
    std::unique_ptr<Resource> ptr2 = std::move(ptr);
    // 此时 ptr 为空,ptr2 拥有资源
    
    // 离开作用域时,ptr2 自动释放资源
}

我在项目中习惯用 unique_ptr 作为函数返回值。这样调用者拿到资源后,所有权就转移过去了,清晰明了。

个人建议:能用 unique_ptr 就别用 shared_ptr。unique_ptr 没有引用计数的开销,性能更好,语义也更简单。我见过太多人一上来就用 shared_ptr,结果引用计数成了性能瓶颈。

29.3 shared_ptr:共享所有权,引用计数

有些场景下,一个资源确实需要被多个对象共享。比如一个配置对象,多个模块都要读它。这时候就需要 shared_ptr

shared_ptr 内部维护一个引用计数。每次拷贝,计数加1;每次析构,计数减1。当计数降到0时,资源被释放。

#include <memory>
#include <vector>

class Config {
public:
    std::string getValue(const std::string& key) { /* ... */ }
};

void worker(std::shared_ptr<Config> cfg) {
    // 多个worker共享同一个Config对象
    auto val = cfg->getValue("timeout");
}

int main() {
    auto config = std::make_shared<Config>();
    
    std::vector<std::thread> threads;
    for (int i = 0; i < 4; ++i) {
        threads.emplace_back(worker, config);
    }
    // 所有线程共享config,最后一个线程结束时释放
}

嗯,这里要注意:引用计数是线程安全的,但指向的对象本身不是。如果你在多线程环境下修改对象内容,还是需要加锁。

29.4 循环引用:shared_ptr 的阿克琉斯之踵

shared_ptr 有个大坑——循环引用。两个对象互相持有对方的 shared_ptr,结果引用计数永远降不到0,资源就泄漏了。

我曾经在一个图形引擎里遇到过这个问题。场景节点和渲染节点互相引用,程序跑着跑着内存就爆了。查了两天才发现是循环引用。

#include <memory>

class B; // 前向声明

class A {
public:
    std::shared_ptr<B> b_ptr;
    ~A() { /* 释放资源 */ }
};

class B {
public:
    std::shared_ptr<A> a_ptr;
    ~B() { /* 释放资源 */ }
};

void create_cycle() {
    auto a = std::make_shared<A>();
    auto b = std::make_shared<B>();
    
    a->b_ptr = b;
    b->a_ptr = a;
    
    // 离开作用域时,a和b的引用计数都是2
    // 析构时各减1,还剩1,永远不会释放
    // 内存泄漏!
}

为什么会这样?因为 shared_ptr 的引用计数只关心「有多少个指针指向我」,它不关心「我指向谁」。两个对象互相指着,谁也跑不掉。

29.5 weak_ptr:打破循环引用的钥匙

解决循环引用的办法就是 weak_ptr。weak_ptr 是一种「弱引用」,它指向一个对象,但不增加引用计数。

你可以把 weak_ptr 想象成一个「观察者」。它能看到对象,但不能阻止对象被销毁。如果你想使用它指向的对象,需要先通过 lock() 方法提升为 shared_ptr。

#include <memory>

class B; // 前向声明

class A {
public:
    std::shared_ptr<B> b_ptr;
    ~A() { /* 释放资源 */ }
};

class B {
public:
    std::weak_ptr<A> a_ptr;  // 改为 weak_ptr
    ~B() { /* 释放资源 */ }
};

void break_cycle() {
    auto a = std::make_shared<A>();
    auto b = std::make_shared<B>();
    
    a->b_ptr = b;
    b->a_ptr = a;  // weak_ptr 不增加引用计数
    
    // 离开作用域时,a的引用计数为1,b的引用计数为1
    // a析构,引用计数变0,释放
    // b析构,引用计数变0,释放
    // 完美!
}

void use_weak_ptr(std::weak_ptr<A> wp) {
    // 使用前必须提升为 shared_ptr
    if (auto sp = wp.lock()) {
        // 对象还存在,可以安全使用
        // sp->doSomething();
    } else {
        // 对象已经被销毁了
    }
}

我曾经踩过的坑:用 weak_ptr 时忘记检查 lock() 的返回值。如果对象已经被销毁,lock() 返回空指针,直接解引用就崩了。所以每次使用 weak_ptr 前,一定要做空指针检查。

29.6 设计原则:谁拥有,谁负责

讲到这里,我想总结一下智能指针背后的设计原则:

  • 明确所有权:每个资源只有一个所有者(unique_ptr),或者明确共享(shared_ptr)
  • 避免循环:父子关系中,父用 shared_ptr 指向子,子用 weak_ptr 指向父
  • 最小化共享:能用 unique_ptr 就别用 shared_ptr,减少心智负担
  • 工厂函数优先:用 std::make_uniquestd::make_shared,而不是直接 new

我个人的习惯是:先问自己「这个资源需要被共享吗?」。如果不需要,直接上 unique_ptr。如果需要共享,再考虑 shared_ptr + weak_ptr 的组合。

29.7 知识结构图

下面这张图展示了智能指针的核心关系:

智能指针与资源所有权 unique_ptr 独占所有权 不可拷贝,只能移动 shared_ptr 共享所有权 引用计数管理 weak_ptr 弱引用观察者 不增加引用计数 循环引用问题 两个 shared_ptr 互相引用 → 引用计数永不归零 → 内存泄漏 解决方案:weak_ptr 打破循环 父→子:shared_ptr,子→父:weak_ptr 性能更优 打破循环

29.8 实战建议

最后,分享几个我在实际项目中总结的经验:

  1. 优先使用 make_shared 和 make_unique:它们更安全,异常发生时不会泄漏。而且 make_shared 能把引用计数和对象分配在同一块内存上,减少一次分配开销。
  2. 不要裸 new:我见过有人写了 shared_ptr<T>(new T),然后中间抛异常,new 出来的对象就没人管了。用 make_shared 就没这问题。
  3. weak_ptr 不是万能的:它只能解决循环引用,不能解决所有资源管理问题。如果设计上能避免循环,就别用 weak_ptr。
  4. 注意线程安全:shared_ptr 的引用计数操作是原子的,但对象本身的读写不是。多线程环境下,该加锁还得加锁。

一个小技巧:如果你不确定该用哪种智能指针,先问自己三个问题:1) 这个资源需要被共享吗?2) 如果共享,会有循环引用吗?3) 性能要求高吗?回答完这三个问题,答案就出来了。

好了,这一章的内容就到这里。智能指针看似简单,但背后涉及的设计原则其实很深。理解了「资源所有权」这个概念,你写出来的代码会更安全、更清晰。

专注资料整理