11. weak_ptr 详解:弱引用、expired/lock 方法、打破循环引用的最佳实践

说实话,weak_ptr 是 C++ 智能指针家族里最容易被忽视的一个。很多人学完 shared_ptr 就觉得万事大吉了,直到某天程序莫名其妙地不释放内存,或者更糟——内存泄漏到服务器挂掉。嗯,这时候你才会想起 weak_ptr

我个人习惯把 weak_ptr 比作「旁观者」。它不拥有对象,不增加引用计数,但它知道对象是否还活着。说白了,它就是一个可以安全观察 shared_ptr 管理的对象的「弱引用」。

弱引用到底是什么?

先看一个最简单的例子:

#include <memory>
#include <iostream>

int main() {
    std::shared_ptr<int> sp = std::make_shared<int>(42);
    std::weak_ptr<int> wp = sp;  // 弱引用,不增加引用计数

    std::cout << "use_count: " << sp.use_count() << std::endl;  // 输出 1

    sp.reset();  // 释放 shared_ptr

    // 此时 wp 已经 "expired"(过期)
    if (wp.expired()) {
        std::cout << "对象已被销毁" << std::endl;
    }

    return 0;
}

注意看,use_count 始终是 1,不是 2。这就是弱引用的核心:它不参与对象的生命周期管理。对象该销毁就销毁,weak_ptr 不会拦着。

核心要点weak_ptr 必须绑定到 shared_ptr 上。它不能独立存在,也不能直接解引用访问对象。想访问?你得先把它「升级」成 shared_ptr

expired 和 lock:两个最常用的方法

这两个方法,我几乎每天都在用。它们解决了一个很实际的问题:如何安全地检查一个对象是否还活着,并在活着的时候访问它

expired() —— 检查对象是否已死

expired() 返回 true 表示对象已经被销毁。用法很简单:

if (wp.expired()) {
    // 对象没了,别碰了
} else {
    // 对象还在,但注意!这里有个竞态条件
}

等等,上面这个写法其实有坑。我在项目中就踩过:expired() 返回 false 的瞬间,另一个线程可能把对象销毁了。所以 不要单独用 expired() 做判断,要用 lock()

lock() —— 安全升级为 shared_ptr

lock() 是原子操作。它尝试获取一个 shared_ptr,如果对象还活着就成功返回,否则返回空 shared_ptr。这才是正确的做法:

std::shared_ptr<int> sp = wp.lock();
if (sp) {
    // 安全使用 sp,对象一定活着
    std::cout << *sp << std::endl;
} else {
    // 对象已经销毁
}

我的习惯:永远用 lock() 代替 expired()。除非你只是想知道对象是否存活,而不打算访问它——但这种场景极少。

打破循环引用:weak_ptr 最经典的应用

循环引用是 shared_ptr 的噩梦。两个对象互相持有对方的 shared_ptr,引用计数永远降不到 0,内存泄漏就这么静悄悄地发生了。

我曾经维护过一个老项目,里面有个双向链表就是用 shared_ptr 实现的。节点互相指向,程序跑一晚上,内存涨了 2 个 G。排查了半天,最后发现就是循环引用的问题。

看一个典型的例子:

#include <memory>
#include <iostream>

struct Node {
    std::shared_ptr<Node> next;
    std::shared_ptr<Node> prev;
    ~Node() { std::cout << "Node 析构" << std::endl; }
};

int main() {
    auto n1 = std::make_shared<Node>();
    auto n2 = std::make_shared<Node>();

    n1->next = n2;
    n2->prev = n1;  // 循环引用!

    // 离开作用域,n1 和 n2 都不会析构
    // 控制台不会输出 "Node 析构"
    return 0;
}

为什么?n1 的引用计数是 2(n1 本身 + n2->prev),n2 也是 2。两个 shared_ptr 析构后,引用计数变成 1,谁也释放不了谁。

解决方案很简单:把其中一个指针改成 weak_ptr。通常的做法是,把「子指向父」或「反向指针」改成弱引用:

struct Node {
    std::shared_ptr<Node> next;
    std::weak_ptr<Node> prev;  // 改成 weak_ptr!
    ~Node() { std::cout << "Node 析构" << std::endl; }
};

int main() {
    auto n1 = std::make_shared<Node>();
    auto n2 = std::make_shared<Node>();

    n1->next = n2;
    n2->prev = n1;  // 弱引用,不增加计数

    // 离开作用域,两个 Node 都会正确析构
    // 输出 "Node 析构" 两次
    return 0;
}

访问 prev 的时候,记得用 lock()

if (auto prev = node->prev.lock()) {
    // 安全访问 prev
    std::cout << "前驱节点存在" << std::endl;
} else {
    // 前驱节点已被销毁
}

我曾经踩过的坑:在析构函数里通过 weak_ptr::lock() 访问其他对象。这很危险!因为析构时对象已经处于半销毁状态,lock() 可能成功,但访问到的对象可能也不稳定。我的建议是:析构函数里不要做任何涉及智能指针的操作

最佳实践总结

这些年用下来,我总结了几条铁律:

  1. weak_ptr 打破循环引用:父子关系、双向链表、观察者模式,反向指针一律用 weak_ptr
  2. 永远用 lock() 访问对象:别单独用 expired(),那是个竞态条件的陷阱。
  3. 不要持有 weak_ptr 太久:弱引用适合临时观察,不适合长期存储。如果你需要长期持有,说明你应该用 shared_ptr
  4. 缓存场景慎用weak_ptr 常被用来实现缓存——对象还在就用,不在就重建。但要注意,这可能导致对象频繁创建销毁,性能不一定好。

知识体系一览

下面这张图,把 weak_ptr 的核心逻辑串起来了:

weak_ptr 核心知识体系 weak_ptr 核心特性 不增加引用计数 必须绑定 shared_ptr 不能直接解引用 核心方法 expired() 检查是否存活 lock() 安全升级为 shared_ptr use_count() 查看引用计数 典型应用 打破循环引用 观察者模式 缓存实现 核心原则:用 lock() 代替 expired(),用 weak_ptr 打破循环

你看,weak_ptr 其实不复杂。它就是一个「安全观察者」——不拥有对象,不延长生命周期,但能在你需要的时候告诉你对象还在不在。记住一点:弱引用不是用来「持有」对象的,而是用来「观察」对象的。想清楚这一点,你就不会用错了。


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