6、智能指针(上):std::unique_ptr 详解、std::shared_ptr 详解、std::weak_ptr 详解、循环引用的解决方案

说到C++11最让我兴奋的特性,智能指针绝对排前三。在它出现之前,我几乎每个项目都要花大量时间调试内存泄漏和悬空指针。说实话,那段日子真不好过。后来智能指针来了,嗯,世界清静多了。

今天咱们就好好聊聊三种智能指针:unique_ptrshared_ptrweak_ptr。它们各自解决什么问题?怎么用?有什么坑?我把自己踩过的坑都翻出来给你看。

核心要点:智能指针的本质是 RAII(资源获取即初始化)思想在动态内存管理上的应用。说白了,就是让对象的生命周期与指针对象的生命周期绑定,自动释放资源。

智能指针知识体系 std::unique_ptr 独占所有权,轻量高效 std::shared_ptr 共享所有权,引用计数 std::weak_ptr 弱引用,打破循环 move 语义转移 自定义删除器 引用计数原理 make_shared 优化 lock() 提升 expired() 检查 循环引用 → weak_ptr 破局 父子节点、观察者模式等场景

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

unique_ptr 是我用得最多的智能指针。为什么?因为它零开销。你想想看,它和裸指针大小一样,性能也一样,但多了一个「独占所有权」的语义。

什么叫独占所有权?就是同一时刻只能有一个 unique_ptr 指向某个对象。不能拷贝,只能移动。这设计太妙了——从根源上杜绝了悬空指针的问题。

#include <memory>

std::unique_ptr<int> p1 = std::make_unique<int>(42);
// std::unique_ptr<int> p2 = p1;  // 编译错误!不能拷贝
std::unique_ptr<int> p2 = std::move(p1);  // 可以移动,p1 变为空

if (p1) {
    // 不会执行,p1 已经被转移
}
if (p2) {
    std::cout << *p2 << std::endl;  // 输出 42
}

我的习惯:能用 unique_ptr 的地方绝不用 shared_ptr。性能是一方面,更重要的是语义清晰——看到 unique_ptr 就知道这个对象只有一个拥有者,代码意图一目了然。

自定义删除器是 unique_ptr 的一个强大特性。我在项目中遇到过需要管理文件句柄的场景,用自定义删除器就特别顺手:

auto file_deleter = [](FILE* f) {
    if (f) {
        fclose(f);
        std::cout << "文件已关闭" << std::endl;
    }
};

std::unique_ptr<FILE, decltype(file_deleter)> 
    file_ptr(fopen("test.txt", "r"), file_deleter);

// 离开作用域时自动调用 fclose

注意:自定义删除器会改变 unique_ptr 的大小。如果删除器是无状态的函数对象(比如 lambda 无捕获),则大小不变;如果是有状态的,大小会增加。我曾经因为没注意这一点,在嵌入式项目里踩了坑。

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

shared_ptr 解决的是「多个所有者」的问题。它内部维护一个引用计数,当最后一个 shared_ptr 被销毁时,才释放管理的对象。

引用计数的实现很有意思。每个 shared_ptr 内部有两个指针:一个指向管理的对象,一个指向控制块(包含引用计数)。控制块是线程安全的,所以 shared_ptr 的拷贝和析构都是原子操作。

std::shared_ptr<int> sp1 = std::make_shared<int>(100);
std::shared_ptr<int> sp2 = sp1;  // 引用计数变为 2

std::cout << sp1.use_count() << std::endl;  // 输出 2

sp1.reset();  // sp1 置空,引用计数变为 1
sp2.reset();  // sp2 置空,引用计数变为 0,对象被释放

性能对比make_shared 比直接 new 再构造 shared_ptr 更高效。它只分配一次内存(对象和控制块一起),而分开做需要两次分配。我在性能敏感的项目中,一律使用 make_shared

不过 shared_ptr 也不是没有代价。引用计数的原子操作有开销,而且控制块本身也占用内存。如果你只是简单传递所有权,用 unique_ptr 更合适。

6.3 std::weak_ptr:弱引用,打破循环

weak_ptr 是个「旁观者」。它指向 shared_ptr 管理的对象,但不增加引用计数。它不能直接访问对象,必须通过 lock() 方法提升为 shared_ptr 才能使用。

你可能会问:这有什么用?嗯,它的核心价值在于解决循环引用问题。

std::shared_ptr<int> sp = std::make_shared<int>(200);
std::weak_ptr<int> wp = sp;  // 引用计数仍为 1

// 使用前必须 lock()
if (auto locked = wp.lock()) {
    std::cout << *locked << std::endl;  // 输出 200
} else {
    std::cout << "对象已被释放" << std::endl;
}

sp.reset();  // 对象被释放
// wp.expired() 返回 true

使用场景weak_ptr 特别适合观察者模式、缓存系统、以及树形结构中父节点指向子节点的反向引用。我习惯用它来避免「强引用环」。

6.4 循环引用的解决方案

循环引用是 shared_ptr 最经典的坑。两个对象互相持有对方的 shared_ptr,导致引用计数永远降不到零,内存泄漏就这么静悄悄地发生了。

我曾经在一个图形引擎项目里遇到过这个问题。场景节点和子节点互相引用,结果整个场景树释放不掉,内存占用越来越高。排查了好久才发现是循环引用。

// 错误的做法:循环引用
struct Node {
    std::shared_ptr<Node> parent;
    std::vector<std::shared_ptr<Node>> children;
    ~Node() { std::cout << "Node 析构" << std::endl; }
};

{
    auto root = std::make_shared<Node>();
    auto child = std::make_shared<Node>();
    root->children.push_back(child);
    child->parent = root;  // 循环引用!
}
// 两个 Node 都不会析构,内存泄漏

解决方案很简单:把其中一个方向的引用改为 weak_ptr。通常的做法是「父强子弱」——父节点用 shared_ptr 持有子节点,子节点用 weak_ptr 指向父节点。

// 正确的做法:使用 weak_ptr 打破循环
struct Node {
    std::weak_ptr<Node> parent;  // 弱引用,不增加计数
    std::vector<std::shared_ptr<Node>> children;
    ~Node() { std::cout << "Node 析构" << std::endl; }
};

{
    auto root = std::make_shared<Node>();
    auto child = std::make_shared<Node>();
    root->children.push_back(child);
    child->parent = root;  // weak_ptr,不增加引用计数
}
// 两个 Node 都能正确析构

避坑指南:我曾经在代码审查时发现有人把父子节点都用了 shared_ptr,还信誓旦旦说「没问题」。结果一跑压力测试,内存直接爆了。记住:只要出现双向引用,至少有一边要用 weak_ptr

还有一个常见的循环引用场景是回调函数。如果 lambda 捕获了 shared_ptr,而 lambda 又被这个对象持有,就会形成闭环。解决方案是用 weak_ptr 捕获,在回调里 lock() 提升。

class Timer {
public:
    void setCallback(std::function<void()> cb) { callback_ = cb; }
    void fire() { if (callback_) callback_(); }
private:
    std::function<void()> callback_;
};

class Widget {
public:
    void startTimer() {
        auto weak_this = std::weak_ptr<Widget>(shared_from_this());
        timer_.setCallback([weak_this]() {
            if (auto self = weak_this.lock()) {
                self->onTimer();
            }
        });
    }
    void onTimer() { /* 处理定时器 */ }
private:
    Timer timer_;
};

总结一下unique_ptr 是默认选择,shared_ptr 用于共享所有权,weak_ptr 专门解决循环引用。记住这个原则,大部分场景都不会出错。

嗯,智能指针的内容其实还有很多细节,比如别名构造、数组支持、线程安全边界等等。不过今天咱们先把核心概念理清楚,后面再深入那些进阶话题。


公众号:蓝海资料掘金营,微信 deep3321