2.4 weak_ptr:旁观者与循环引用坏账


2.4 weak_ptr:旁观者与循环引用坏账

本节摘要:shared_ptr 的引用计数有一个制度盲区:互相持有的两个对象把对方的计数永久锁在一以上,谁也销不了账——这就是循环引用坏账。weak_ptr 是不担责的旁观者:指向共享对象却不入计数,用时临时申请短期户主资格(lock),对象已亡则明确告知。本节先复现坏账,再动手拆解。

本章的账户体系已立起独占与共享两本账,还剩最后一角:共享账户的盲区。2.3 说计数归零就销账,可如果两个对象互相把对方记在自己名下,计数永远减不到零。本节先让这笔坏账在眼前发生,再上 weak_ptr 这味药。它同时补齐另一个需求:想"观察"某个共享对象却不想延长它的寿命——缓存、观察者模式都靠它。

先复现坏账

两个人互相在对方账本上记了名,谁也无法先退出:

#include <cstdio> #include <memory> struct Node { int id; std::shared_ptr<Node> partner; // 共享持有对方:入计数 explicit Node(int i) : id(i) { std::printf(" 开户 节点 %d\n", id); } ~Node() { std::printf(" 销账 节点 %d\n", id); } }; int main() { auto a = std::make_shared<Node>(1); auto b = std::make_shared<Node>(2); a->partner = b; // b 计数:1 → 2 b->partner = a; // a 计数:1 → 2 std::printf("互记完成后,a 计数 %ld,b 计数 %ld\n", static_cast<long>(a.use_count()), static_cast<long>(b.use_count())); return 0; } // a、b 离开作用域:计数都只从 2 减到 1,销账条件永不满足

输出(注意结尾——没有销账记录):

开户 节点 1 开户 节点 2 互记完成后,a 计数 2,b 计数 2

main 返回后什么都没打印。两个节点各自还剩一名户主(对方),计数归零的条件永远不成立,两块内存连同它们的一切资源整体锁死——这是第 1 章四类坏账之外的第五类:制度性泄漏,代码里没有任何一行"忘了 delete",坏账由记账制度自身制造。

图 2-4 循环引用:计数锁死与旁观者拆解

图 2-4 循环引用:计数锁死与旁观者拆解

一、拆解:把单向关系改成旁观

坏账的根源是关系不平等却用了平等的记账方式。节点 A 与 B 的关系里总有一方是"主"、一方是"从":父子树里父拥有子,缓存里缓存拥有数据、使用者只观察。审计口诀是:拥有方用 shared_ptr,被指但不拥有的一方用 weak_ptr。改造上面的坏账:

#include <cstdio> #include <memory> struct Node { int id; std::shared_ptr<Node> partner; // 拥有关系:入计数 std::weak_ptr<Node> observer; // 观察关系:不入计数 explicit Node(int i) : id(i) { std::printf(" 开户 节点 %d\n", id); } ~Node() { std::printf(" 销账 节点 %d\n", id); } }; int main() { auto a = std::make_shared<Node>(1); auto b = std::make_shared<Node>(2); a->partner = b; // B 被 A 拥有:计数 2 b->observer = a; // A 仅被 B 观察:计数保持 1 std::printf("a 计数 %ld,b 计数 %ld\n", static_cast<long>(a.use_count()), static_cast<long>(b.use_count())); if (auto locked = b->observer.lock()) { // 临时申请户主资格 std::printf("旁观者读到节点 %d\n", locked->id); } return 0; } // a 计数 1 → 0 销账,b 随之销账

输出确定:

开户 节点 1 开户 节点 2 a 计数 1,b 计数 2 旁观者读到节点 1 销账 节点 1 销账 节点 2

两行销账如约出现。weak_ptr 的精髓在 lock() 那两行:旁观者想用对象,必须临时申请短期户主资格;申请成功拿到一个 shared_ptr(用完即还),申请失败拿到空指针——对象已亡,走到安全分支。这个"检查加使用"是原子动作,不存在查完那刻对象刚好销账的竞态,比裸指针"先判断后使用"的原始做法可靠得多。

二、案例:缓存失效的账目审计

背景:ProfileService 的会话缓存把用户会话对象存进 map,客户端断开后会话理应销账,但内存曲线显示会话对象持续累积。

操作:先查计数。给会话类挂上 use_count 日志,发现每个会话除了缓存本账外,还被"回调上下文"持有一条 shared_ptr——客户端断开后回调上下文没清,会话计数停在 1。梳理所有权后重构:缓存持有会话(shared_ptr),回调上下文改为 weak_ptr 观察;回调触发时先 lock,锁不到说明会话已亡,直接走重连分支。

结果:会话对象随客户端断开准时销账;回调持有陈旧会话引发的另一处逻辑错误(给已亡会话发消息)也一并消失——lock 返回空让这条路径显式可见。

解读:这单坏账和 1.4 的泄漏同症不同根:那次是控制流甩掉销账,这次是所有权定义模糊——"回调算不算户主"没在设计里回答,代码就默认答了"算"。weak_ptr 的价值正是把"不算户主"变成类型里可声明的选项。审计方法也值得记:怀疑制度性泄漏时,把 use_count 当账单读,找出每一条"多余的户主"。

变式:观察者模式是 weak_ptr 的主场——主题持有观察者列表用 weak_ptr,观察者销亡后主题侧 lock 失败即剔除,无需显式注销。树结构里"子指向父"的回边、协程与句柄互相引用,都是同一药方。判别式始终一条:这条引用销账吗?不销账就 weak。

⚠️ 常见坑:把 weak_ptr 当"不会悬垂的裸指针"到处替换。weak_ptr 不入计数意味着每次使用都要 lock,高频热路径上反复 lock/unlock 是可见开销;更关键的是它改变了语义——如果你本该拥有(比如父节点对子节点),用 weak 表达会让对象意外提前销亡。先定所有权,再选指针。

本节要点回顾

  • 循环引用是制度性泄漏:互相持有的 shared_ptr 把计数锁在一以上,代码无一行错误却整体锁死。
  • 审计口诀:拥有入账(shared_ptr),观察旁观(weak_ptr);画所有权图先分主从。
  • lock 是标准动作:临时申请短期户主资格,检查与使用原子完成,失败返回空。
  • 销账时点重归可控:旁观者不延长寿命,对象用完即走,缓存与观察者场景的正解。
  • use_count 当账单读:怀疑共享账户泄漏时,逐条核对户主,揪出"多余的那一位"。
  • weak_ptr 不是免费的裸指针:每次使用需 lock,高频路径与拥有语义场景都不适用。

到这里,第 2 章把销账责任从人工彻底移交给类型系统。下一章转入账目流转的微观世界:引用与指针怎么选、const 冻结什么、移动语义为什么是过户——对象在函数之间的每一次传递,都有一笔可审计的账。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U