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() 可能成功,但访问到的对象可能也不稳定。我的建议是:析构函数里不要做任何涉及智能指针的操作。
最佳实践总结
这些年用下来,我总结了几条铁律:
- 用
weak_ptr打破循环引用:父子关系、双向链表、观察者模式,反向指针一律用weak_ptr。 - 永远用
lock()访问对象:别单独用expired(),那是个竞态条件的陷阱。 - 不要持有
weak_ptr太久:弱引用适合临时观察,不适合长期存储。如果你需要长期持有,说明你应该用shared_ptr。 - 缓存场景慎用:
weak_ptr常被用来实现缓存——对象还在就用,不在就重建。但要注意,这可能导致对象频繁创建销毁,性能不一定好。
知识体系一览
下面这张图,把 weak_ptr 的核心逻辑串起来了:
你看,weak_ptr 其实不复杂。它就是一个「安全观察者」——不拥有对象,不延长生命周期,但能在你需要的时候告诉你对象还在不在。记住一点:弱引用不是用来「持有」对象的,而是用来「观察」对象的。想清楚这一点,你就不会用错了。