本节摘要: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",坏账由记账制度自身制造。

坏账的根源是关系不平等却用了平等的记账方式。节点 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 表达会让对象意外提前销亡。先定所有权,再选指针。
到这里,第 2 章把销账责任从人工彻底移交给类型系统。下一章转入账目流转的微观世界:引用与指针怎么选、const 冻结什么、移动语义为什么是过户——对象在函数之间的每一次传递,都有一笔可审计的账。