C++ 面向对象中的 shared_ptr 与 weak_ptr
09 shared_ptr 和 weak_ptr 解决共享,也带来代价
工程中有一类需求是 unique_ptr 从设计上就无法覆盖的:一个堆对象需要被多个互不统属的模块同时持有,每个模块都可能在不确定的时间点独立结束自己的生命周期,而该堆对象的真正销毁必须等到所有持有者全部离开之后才能发生。图像引擎里的一块纹理资源被三个渲染管线各自引用,网络模块里的一个连接状态对象被两个线程池和一个日志器同时关注,语法分析器中的一颗抽象语法树被多个优化通道各自遍历——这些场景的共同特征是在编译期无法确定"最后一个持有者是谁",因为持有者的消亡次序由运行时行为决定。
std::shared_ptr<T> 是 C++ 标准库为这一类需求提供的答案。它不要求你在代码中指定哪个持有者对销毁负责——它用引用计数把这套决策变成了运行时的自动行为:当最后一个 shared_ptr 离开作用域或被重置时,计数归零,托管的对象立即被销毁。这层便利不是免费的——控制块的动态分配、引用计数的原子操作、循环引用带来的泄漏风险,以及 shared_ptr 比裸指针和 unique_ptr 更重的内存和性能开销,都是共享所有权必须支付的代价。本章的目标就是把 shared_ptr 的机制、weak_ptr 的观察角色、循环引用如何产生和如何被打断,以及共享所有权在工程中应该被审慎使用的原因,逐层展开。
共享所有权是"最后一个持有者说了算"
当你写下 auto sp = std::make_shared<Widget>("demo") 时,底层发生的事比 make_unique 多了一层结构。make_shared 在自由存储区申请了一块足以同时容纳 Widget 对象和引用计数控制块的内存,在这块内存上构造 Widget 和初始化控制块,然后将 Widget 的地址和控制块的地址分别存入 shared_ptr 的两个内部指针字段。此后,任何一个通过拷贝构造或拷贝赋值从 sp 获得的 shared_ptr<Widget> 都共享着同一块控制块,它们的引用计数也随之递增——每多一个 shared_ptr,控制块里记录的强引用计数值就加一;每有一个 shared_ptr 被析构或被重置,强引用计数就减一。当减到零的那一刻,控制块触发 Widget 的析构函数销毁对象,并在此后归还连同控制块在内的整块内存(如果最初是 make_shared 分配的)。
区分"对象"和"控制块"在理解 shared_ptr 的每一个关键行为上都至关重要。很多人在第一次接触到 shared_ptr 时,会误以为对象内部有一个计数器记录着"有几个 shared_ptr 指向我"——这是对实现结构的根本误解。对象本身不知道它被多少个 shared_ptr 管理,它的内存布局里没有引用计数字段,它的构造函数和析构函数不对任何计数做操作。控制块是外部的、独立的、在 shared_ptr 创建时被分配的一块记账内存,它和托管对象的唯一联系就是控制块里存储的那个删除器知道如何销毁对象。
控制块的内部结构在任何主流标准库实现中都包含了四个核心字段:强引用计数(跟踪还有多少个 shared_ptr 存活)、弱引用计数(跟踪还有多少个 weak_ptr 存活)、删除器(知道如何销毁托管对象,默认是 delete)、以及分配器(知道如何释放控制块自身的内存)。这四个字段中,前两个用原子操作维护以支持多线程安全地拷贝和析构 shared_ptr,后两个是在控制块创建时通过类型擦除绑定进去的可调用对象——一旦绑定,无论后续 shared_ptr 通过什么静态类型的指针去传递,控制块里的删除器始终知道对象的真实动态类型,从而保证正确的析构。
"对象和控制块分离"这一事实对 shared_ptr 的别名构造函数场景有一个特别重要的含义。如果 shared_ptr<T> 管理着一个类型为 D 的对象(D 是 T 的派生类),而控制块里记录的删除器仍然正确地知道该对象是 D 类型(因此析构时会调用 ~D()),那么即使后续你从这个 shared_ptr<T> 拷贝出一个 shared_ptr<T>——甚至通过 std::static_pointer_cast 转换到 shared_ptr<Base>——控制块里的删除器始终是当初创建时绑定的那一个,不会因为 shared_ptr 的静态类型发生了变化而丢失 D 类型的析构信息。这意味着 shared_ptr 天然支持多态删除——即使 T 的析构函数不是虚函数,只要控制块里记录的删除器是正确的,对象就能被正确析构。这一点和裸指针完全不同:如果对一个裸的 Base* 指向 Derived 对象执行 delete,而 Base 的析构函数不是 virtual,Derived 的析构函数就不会被调用,派生类成员的资源就泄漏了。shared_ptr 通过将删除器绑定到控制块上,把"多态删除"这件事从依赖于虚析构的运行时虚函数分发,变成了控制块构造时刻的类型擦除——一次决定,终身正确,无论后续你通过什么静态类型的 shared_ptr 去拷贝和传递。
这种分离结构的其中一个直接后果是:shared_ptr<T> 的大小通常是裸指针的两倍。它内部需要存储两个指针——一个指向托管对象(用于解引用和访问成员),一个指向控制块(用于引用计数的增减和最终的销毁触发)。这与 unique_ptr<T> 默认情况下只存一个指针形成了对比,也是 shared_ptr 比 unique_ptr 更"重"的第一个维度——不仅是控制块的分配开销,就连 shared_ptr 本身的拷贝和传递也比 unique_ptr 多了一次指针间接访问。
make_shared 对这个开销做了重要的优化。如果你用 std::shared_ptr<T>(new T(...)) 创建,底层会发生两次独立的堆分配——一次分配 T 对象,一次分配控制块——两次分配之间出现异常可能导致 T 对象已分配但控制块未建立,引用计数体系无法回滚已分配的 T 内存。make_shared 把两次分配合并成一次:计算出 T 对象和控制块需要的总连续内存大小,一次性申请,在这块连续内存的前段构造控制块、后段构造 T 对象,两个区域的指针在同一个 shared_ptr 初始化中被同时设置完毕。由此不仅是少了一次分配、少了一次释放调用,也因为 T 对象和控制块在物理上相邻,带来了更好的缓存局部性——访问 shared_ptr 里的引用计数信息和访问 T 对象的数据很可能落在同一条缓存线里。
make_shared 的唯一可见代价是:它分配的那一整块连续内存必须等到弱引用计数也归零之后才能整体归还——即 weak_ptr 仍然指向控制块时,由 make_shared 合并的 T 对象内存也不能提前归还给分配器。但这个代价在绝大多数场景中远远小于"两次独立分配"带来的开销和碎片,除非你创建了大量生命周期极度短暂、频繁被 weak_ptr 观察的 shared_ptr——且这类模式在常规 C++ 项目中非常罕见。另一个 make_shared 隐藏的效率收益是它减少了分配器的碎片化。两次独立分配不仅意味着双倍的分配调用开销,还意味着堆上多了一组不连续的控制块-对象对,这些独立控制块在大量 shared_ptr 被频繁创建和销毁的系统中会在堆上留下比 make_shared 版本更多的碎片间隙。所以和 make_unique 之于 unique_ptr 一样,make_shared 应该是创建 shared_ptr 的默认路径,裸 new 只在需要自定义删除器、已有裸指针需要封装、或者使用 enable_shared_from_this 等少数特定场景中出现。
引用计数不等于"对象记得自己被几个指针指着"
控制块里记录着两种引用计数:强引用计数(strong reference count)和弱引用计数(weak reference count)。强引用计数跟踪的是有多少个 shared_ptr 正在共享这个对象,当它降到零时,对象被销毁——但控制块还可以继续存活,只要弱引用计数还没有归零。弱引用计数跟踪的是有多少个 weak_ptr 正在观察这个控制块。当两者全部归零时,控制块本身才被销毁,所占用的内存才归还给系统。
强引用计数的增减是 shared_ptr 所有操作中最频繁的步骤,因此标准库对其做了细致的优化。在常见的实现中(libstdc++、libc++),引用计数的增减使用原子操作——std::atomic<int> 或等价的平台原生指令——因为 shared_ptr 的拷贝可能发生在不同线程之间,必须保证计数的线程安全。这意味着每一次 shared_ptr 的拷贝和析构都涉及至少一次原子递增或原子递减,而原子操作比普通的整数加减慢了若干数量级(取决于平台,通常在数倍到数十倍之间,且涉及缓存同步开销)。这不是说 shared_ptr 是一个"慢"的类型——在绝大多数业务逻辑中,这个开销在整个调用路径上占比微乎其微——但它确实意味着你不应该在一个热路径内循环里密集拷贝 shared_ptr,或者把 shared_ptr 当值传递穿过多层调用链的每一层。
这里有一个必须精确区分的关键事实:控制块的引用计数的原子性只保证"引用计数本身的增减是线程安全的"——即多个线程可以同时拷贝和析构同一个 shared_ptr 的不同拷贝而不会破坏引用计数的数值。但这绝不意味着"通过不同的 shared_ptr 在不同线程里同时访问托管对象本身是线程安全的"。托管对象的线程安全性完全取决于对象自身的实现——如果两个线程各自持有一份 shared_ptr 的拷贝同时调用同一个 Widget 的非线程安全方法,数据竞争照常发生。控制块上的原子操作保护的是所有权机制本身,不是托管对象的业务逻辑。把这两者混为一谈是 shared_ptr 使用中最常见的误判之一:认为"用了 shared_ptr 就是线程安全的"。用了 shared_ptr,对象的生命周期在所有持有者离开之前不会被提前终止——这是生命周期安全;但多线程同时操作对象本身是否安全,是由互斥锁或对象自身的并发设计决定的,shared_ptr 不提供这个层面的保护。
原子计数的代价也解释了为什么 shared_ptr 不适合在不需要共享的场景中替代 unique_ptr——不仅是空间上的两倍指针开销、不仅是控制块分配的一次额外内存申请,也是每一次传递和每一次离开作用域都在执行的一次原子操作。单个 shared_ptr 的这些开销在绝对数值上很小,但如果在大型项目中将所有原本用 unique_ptr 就能处理的动态对象全部默认选用 shared_ptr,累积效应会在内存分配次数、原子操作耗时、缓存失效频率三个维度上同时显现。程序设计的一条普遍经验法则是:把 shared_ptr 的使用限制在确实存在多持有者的那部分场景中,不要在不确定未来是否需要共享的情况下"以防万一"地选择 shared_ptr——万一没发生,你支付了全部代价;万一发生了,你仍然可以通过 unique_ptr 向 shared_ptr 的单向升级来应付。
关于 use_count() 还有一点工程上的提醒:它公开可用,但在业务逻辑中不应该被用来做复杂条件判断——例如 if (sp.use_count() == 1) { ... } 这种写法把程序的正确性绑死在了特定时间点上的持有者数量上,而持有者数量在并发或多模块环境中可能在你读取 use_count() 之后的下一行代码里就已经改变了。它存在的主要用途是调试——确认引用计数是否符合预期、检测在重构中是否意外引入了新的持有者——而不是作为设计上的控制信号来驱动业务分支。
从同一个裸指针构造两个 shared_ptr 是这个体系下最经典的致命错误。假设你从 C 接口或一段遗留代码中拿到了一个 Widget* raw,然后在模块 A 里写了 std::shared_ptr<Widget> sp1(raw),同时在模块 B 里写了 std::shared_ptr<Widget> sp2(raw)——编译器不会阻止你这么做,因为对 shared_ptr 的构造函数而言,raw 就是一个普通裸指针。但底层的后果是:两个构造调用各自在自由存储区创建了一个独立的控制块,各自记录着 use_count = 1,各自认为自己是唯一合法的管理者。当 sp1 析构时,它的控制块计数归零,触发 delete raw——Widget 被释放。随后 sp2 析构时,它的控制块也计数归零,对同一个地址再次执行 delete——双重释放。
这条错误路径的隐蔽性在于,sp1 和 sp2 在代码中是完全合法的两个 shared_ptr,各自的 use_count() 都返回 1——从各自的视角看,一切正常。没有任何机制在构造时去检查"这个裸指针是否已经被另一个控制块管理"——因为裸指针上没有标记,控制块之间也不共享任何协议。避免这条错误的唯一方式是永远不从裸指针构造 shared_ptr——如果有人给你裸指针,要么在它的唯一构造点立刻用 make_shared 创建并从此只通过 shared_ptr 的拷贝传播,要么如果裸指针必须被保存,就通过 enable_shared_from_this 来安全地获得管理它的 shared_ptr。
enable_shared_from_this:当对象需要安全地交出自己的 shared_ptr
上一节末尾提到的"从裸指针构造 shared_ptr 导致多控制块"的致命错误,在一种场景下特别容易出现:一个成员函数需要返回指向自己的 shared_ptr。这种需求在实际工程中普遍存在——一个异步任务需要持有"自己"的 shared_ptr 来保证在处理期间不被销毁,一个回调注册函数需要把当前对象加入一个由 shared_ptr 管理的回调列表中。初学者最直接的本能反应是:
class Widget {
public:
std::shared_ptr<Widget> GetShared() {
return std::shared_ptr<Widget>(this); // 错!创建新控制块
}
};
这行代码让 this 这个裸指针进入了 shared_ptr 的构造函数——一个新的控制块被创建,它不知道外面可能已经有一个(或多个)shared_ptr 在管理同一个 Widget。如果外面确实有一个 shared_ptr<Widget> sp = std::make_shared<Widget>() 存在,现在 Widget 就有了两个互相不知道对方存在的控制块,双双准备在各自计数归零时执行 delete——双重释放的路径完整铺设完毕。
std::enable_shared_from_this<T> 是标准库为消灭这条错误路径而设计的机制。让 Widget 继承自 enable_shared_from_this<Widget>,然后调用 shared_from_this() 方法——这个方法不创建新控制块,而是从对象内部存储的一个 weak_ptr 出发安全地 lock() 出一个与已有控制块关联的 shared_ptr:
class Widget : public std::enable_shared_from_this<Widget> {
public:
std::shared_ptr<Widget> GetShared() {
return shared_from_this(); // 正确:与已有的 shared_ptr 共享控制块
}
};
enable_shared_from_this 的实现原理是:类内部持有一个 weak_ptr<T> 成员(通常是私有继承而来)。当第一个 shared_ptr 被创建来管理一个 Widget 对象时,shared_ptr 的构造函数会检测到 Widget 继承自 enable_shared_from_this<Widget>,于是将这个新创建的 shared_ptr 的控制块信息注入 Widget 内部的那个 weak_ptr 中。此后调用 shared_from_this() 时,它从这个预先注入的 weak_ptr 出发执行 lock(),返回的 shared_ptr 与最初那个共享同一控制块——引用计数正确递增,永远不会产生第二个控制块。
这条机制有一个硬性前提:调用 shared_from_this() 的瞬间,对象必须至少被一个 shared_ptr 管理者。如果对一个栈对象、一个仅由 unique_ptr 管理的对象、或者一个刚 new 出来还没有交给任何 shared_ptr 的对象调用 shared_from_this(),行为未定义(在常见实现中会抛出 std::bad_weak_ptr 异常)。这个前提反过来迫使你在设计上做出一个明确的决策:任何继承自 enable_shared_from_this 的类,其实例化路径上必须从第一个 shared_ptr 的创建开始就进入共享所有权的管理——不能先裸指针创建再用 shared_ptr 事后接管,也不能把这类对象放在栈上。这条约束看起来是限制,但实际上正是类型系统在帮你消除隐患:它让你不可能"不小心"对一个没有被 shared_ptr 管理的对象使用 shared_from_this(),从而把"对象在共享所有权框架下的合法性"从一个约定变成了一个可以通过代码结构机械执行的事实。
weak_ptr 观察但不拥有
在共享所有权的体系里,weak_ptr 扮演的角色是观察者——它指向一个由 shared_ptr 群体管理的对象,但不参与所有权的决策。创建一个 weak_ptr 不会增加强引用计数,因此不管有多少个 weak_ptr 在观察,当最后一个 shared_ptr 离开时,托管对象都会被销毁。weak_ptr 的存在对对象的生命周期没有任何延长效应——它的唯一作用是在对象还活着的时候提供一个安全的访问窗口,在对象已经死亡的时候返回"访问失败"而不是让你踩到悬空内存。
创建 weak_ptr 的典型入口是从 shared_ptr 赋值:std::weak_ptr<Widget> weak = sp;。这条赋值语句让 weak 的内部结构指向 sp 所指向的同一个控制块,但只增加控制块中的弱引用计数,不增加强引用计数。此时 sp.use_count() 的值不变,反映出"对象的所有权仍然只由 sp 持有"的事实。weak_ptr 也可以从另一个 weak_ptr 拷贝构造出来——拷贝的是对同一个控制块的弱引用观察关系,同样只影响弱引用计数。weak_ptr 本身没有析构托管对象的能力,它甚至不能解引用——它的接口上不提供 operator* 和 operator->,因为一个观察者不应该在不确认对象是否还活着的情况下直接访问对象。
访问 weak_ptr 所观察的对象需要通过 lock() 方法。auto locked = weak.lock() 会检查控制块中的强引用计数:如果强引用计数大于零(说明至少还有一个 shared_ptr 持有对象),lock() 返回一个指向同一对象的临时 shared_ptr——这个临时 shared_ptr 在当前表达式的后续使用期间保证了对象不会被销毁;如果强引用计数已经是零(对象已经析构了),lock() 返回一个空的 shared_ptr,if (locked) 判断为假,程序安全地得知"对象已不存在"。lock() 是 weak_ptr 提供安全访问的唯一途径——直接解引用 weak_ptr 是不允许的,也没有任何方式可以从 weak_ptr 拿到裸指针而不经过 lock() 的检查。
expired() 方法可以检查 weak_ptr 是否已经失效(即托管对象是否已被销毁),但一般而言,不应该在工程代码里用 expired() 做"先检查再使用"的模式:if (!weak.expired()) { auto sp = weak.lock(); ... } 这种写法中,expired() 检查完毕和 lock() 调用之间可能发生对象的销毁(在并发场景下,另一个线程可能恰好在 expired() 返回 false 之后、lock() 调用之前释放了对象),导致 lock() 仍然返回空。正确的方式是直接调用 lock()——如果成功,返回的临时 shared_ptr 持有对象,从而保证在当前作用域内对象不会被其他线程销毁;如果返回空,对象已不存在。一步到位,不存在中间窗口。
weak_ptr 存在的一个副产品是控制块的生命周期被延长。即使强引用计数已经归零、托管对象已经被销毁,只要还有一个 weak_ptr 观察着这个控制块,控制块本身就不会被释放——它需要继续保留弱引用计数和可能的删除器信息,直到所有 weak_ptr 也析构之后控制块才归还。这也是之前提到的 make_shared 的内存延迟释放问题的来源:由 make_shared 分配的对象和控制块连在一起,在弱引用计数尚未归零时,对象占用的那块内存也不能单独归还。在大多数应用场景中这个延迟无关紧要,但如果你的程序创建了大量短期存活的 shared_ptr 且同时有长久存活的 weak_ptr 在观察它们,就应该留意这种底层行为的差异——它可能导致由 make_shared 分配的内存中,对象部分的析构函数已被调用(逻辑上对象已死),但占用的物理内存因为弱引用计数的存在而迟迟不能归还给分配器,内存曲线看上去就像发生了泄漏。
weak_ptr 的另一个高频工程模式是用作缓存索引。在 C++ 标准委员会成员 Scott Meyers 描述的一种典型模式中,一个工厂函数将最近创建的对象的 weak_ptr 存入一个按 ID 建索引的哈希表中,后续对同一 ID 的请求先查缓存——如果 weak_ptr 未失效(说明对象被某处持有),lock() 出临时 shared_ptr 返回即可,省掉重新创建的开销;如果 weak_ptr 已失效(说明对象在上一次使用完成后已被销毁),则照常创建新对象,并更新缓存中的 weak_ptr 引用。这个缓存层不延长任何对象的生命周期——对象被所有真正的"业务持有者"释放后自动清理,缓存只提供了一个"如果它还活着就用它"的快速路径。这个模式把"缓存层是否应该阻止对象销毁"的决策从实现细节中移除了——weak_ptr 在类型上回答了这个问题:不阻止。
循环引用是共享所有权状态机中的死锁
共享所有权的引用计数模型在逻辑上非常直接——计数器从构造点的 1 开始,每次拷贝加一,每次析构减一,归零时销毁。但它在一个特定的图结构中会出现不可解的僵局:当两个或更多对象通过 shared_ptr 互相对持时,形成有向图中的环,环上每个节点的引用计数都至少为 1(因为被环上的另一个节点持有),但这些"至少为 1"的计数永远不会归零——因为它们都来自环结构内部,而不是来自环结构外部的拥有者。
最直白的例子是两个对象 A 和 B 各持对方的一个 shared_ptr。A 内部的 shared_ptr<B> 增加了 B 的引用计数,B 内部的 shared_ptr<A> 增加了 A 的引用计数。外部的局部 shared_ptr<A> 和 shared_ptr<B> 分别离开作用域之后,A 的引用计数变成了 1(仅剩 B 内部的),B 的引用计数也变成了 1(仅剩 A 内部的)。两者在谁先归零的问题上互相等待:A 要等到 B 析构才能递减那最后一次引用计数,B 要等到 A 析构——但 A 和 B 都不会析构,因为各自的引用计数都还没归零。循环锁死,两者一同泄漏。
这个僵局在对象图是有向无环结构时不会出现——只要所有权关系是单向的(比如父持有子,子不持有父),引用计数就能在外部拥有者有序析构时安全归零。问题只出现在双向引用、或者说存在环的引用结构中。在实际工程中,这类结构并不少见:GUI 控件树中的父子互相引用、观察者模式中被观察对象和观察者互持、图数据结构中的节点互相连接、缓存系统中的 LRU 链表节点互相指向。不是每一种双向引用都需要被打断——如果两组引用都表达的是"所有权"关系,那循环引用本身就是设计上的所有权矛盾:A 拥有 B 意味着 A 决定 B 的生命周期,B 也拥有 A 意味着 B 也决定 A 的生命周期——这两条规则在逻辑上本来就是不可同时成立的。
weak_ptr 在这个问题上的角色是单向打破其中的一条所有权链。把环上的其中一条引用从 shared_ptr 换成 weak_ptr——通常是从孩子指向父亲的那一条,或从观察者指向被观察对象的那一条——引用计数体系就不再形成闭环,外部拥有者的正常析构就能沿着没有环的方向归拢到零。在上面的 A-B 两节点例子中,如果把 B 内部的 shared_ptr<A> 改成 weak_ptr<A>,则 A 的引用计数只计外部拥有者的那部分——外部 shared_ptr<A> 离开时 A 析构,A 析构时释放自己内部的 shared_ptr<B>,B 的引用计数随之归零,B 析构——整条链干净收束,没有残留。
循环引用在被引入时往往不是以"两个对象互相持有 shared_ptr"这么直白的形态出现的,而是穿透多层结构悄悄埋下的。一个观察者持有被观察对象的 shared_ptr(为了确保观察期间对象存活),被观察对象通过某个回调或事件链间接持有了观察者的 shared_ptr——中间可能隔着 lambda 捕获、事件队列、异步回调——等到最后发现泄漏时,要追溯整条循环链上的所有权关系需要跨越多个模块的代码。这种跨越式的循环引用在设计和 code review 阶段就应当被系统性地防范,而不是等到内存采样工具报出异常之后再回头追溯。一条在团队中可以执行的简单规则是:每一条 shared_ptr 的持有关系都必须能回答"持有方向是否在当前对象图中形成了环?"——如果一个新引入的 shared_ptr 成员变量让你无法确定这个问题的答案,那很可能设计上已经有了不确定的所有权方向。
在真实工程中选择哪条引用来打破循环,通常遵循所有权方向:谁拥有谁,谁就被 shared_ptr 持有;谁被拥有但需要反向导航到拥有者,就用 weak_ptr。一个树结构中的父节点用 shared_ptr 持有子节点(父拥有子),子节点用 weak_ptr 反向引用父节点(子不拥有父,但需要能访问父),这是 weak_ptr 最经典的使用模式。weak_ptr 在代码中在此处不仅是一个避免泄漏的工具——它是"这段引用不表达所有权"这条设计决策的类型表达,编译器不会帮你执行这个设计决策,但阅读代码的人看到 weak_ptr 这个类型就知道这个引用的方向是"观察"而不是"拥有"。
还有一个常被忽视但非常实用的告警信号:如果你在代码里发现一个类同时持有 shared_ptr 成员(指向外部对象)而又被另一个外部对象以 shared_ptr 反向持有——无论中间隔着多少层抽象——环就已经在所有权图中形成了。这类结构在引入阶段的自我检查方法是:从任意一个持有 shared_ptr 成员的类出发,沿着它的 shared_ptr 成员所指的方向遍历所有权图,如果沿着任何一条路径能走回起点,环就存在。这个遍历不需要在运行时做——它是在设计阶段或代码审查时在脑中(或在白板上)完成的静态推理,shared_ptr 成员变量就是推理的路径节点。发现环之后,解法永远是指定一个单一的所有权方向,反向用 weak_ptr——这和数据库中通过给外键关系指定主从方向来消除循环依赖是同构的设计操作。
共享所有权不是无代价的默认选择
前面八个章节——从对象的存储位置开始,经过生命周期线、new/delete 的四步机制、特殊成员函数的联动、RAII 框架,到 unique_ptr 的独占所有权——所有分析和结论叠加在一起,对于 shared_ptr 的选择给出了一个明确的工程立场:独占所有权优先于共享所有权。当一个堆对象确实只有一个拥有者时,unique_ptr 的语义更精确(独占)、开销更小(零额外空间、无原子操作)、接口约束更强(编译期禁止拷贝);把这种场景升级到 shared_ptr 不会带来任何收益,只会引入不必要的控制块分配、不必要的原子操作成本,以及不必要的"可能有多个持有者"的语义模糊——而后一种模糊会让后续维护者在面对这个 shared_ptr 时无法立刻判断它的共享是否真的有必要。
将控制块和对象的分配、引用计数的原子操作、缓存同步成本、以及两倍指针大小全部计入后,一个 shared_ptr 和一个 unique_ptr 在绝对数值上的差异对于单个对象而言微乎其微——大约是数十到上百纳秒级别的额外操作、额外的几十字节堆分配、以及一次缓存行加载的额外开销。但在以下三个具体场景中,这些差异会从"绝对微小"变成"可观测的累积效应":第一,在每帧渲染或每个网络包处理的循环中频繁拷贝 shared_ptr——每次拷贝触发一次原子加法,每次析构触发一次原子减法,在每秒几十万次的频率下累积到一个可被性能采样器捕捉的比例;第二,在大量对象使用 shared_ptr 管理的系统中,控制块的堆分配次数翻倍(相对于 unique_ptr 的无额外分配),分配器碎片化程度随之升高;第三,shared_ptr 的两倍指针大小使得在容器(如 std::vector)中大量存储它们时,缓存命中率下降——同样的缓存行能容纳的 shared_ptr 数量只有 unique_ptr(或裸指针)的一半。
升级路径是单向的——你可以随时通过 std::shared_ptr<Widget> sp = std::move(up) 把 unique_ptr 的所有权转移进 shared_ptr 的控制块体系——这行代码零拷贝、高效、安全,且让所有权模型从独占变成了共享。但反方向的降级——从 shared_ptr 回到 unique_ptr——不是标准库支持的操作,因为共享所有权无法在运行时可靠地转化为独占所有权:你永远不知道在代码库的哪个角落还隐藏着另一个 shared_ptr 的拷贝。单向升级路径的存在意味着:在设计阶段,当你还不确定一个对象未来是否会被共享时,最优策略是先用 unique_ptr 把独占约束锁紧——独占性在类型上会被编译期强制,不会从任何执行路径上被突破。将来确有共享需求时,一行代码升级即可。反过来,一开始就用 shared_ptr 就当于是放弃了编译期对独占性的保护,就算后来发现实际用不到共享,也无法在零代价的前提下把保护重新找回来。
shared_ptr 和 unique_ptr 之间的选择本质上不是在选语法——它是在选"当前模块和其他模块在这个对象的生命周期上存在什么样的权力关系"。只有一个模块需要管理它的生命周期时,不需要共享——用 unique_ptr。多个模块各有一段生命周期需要在同一对象上交叠,且每个模块都不能在自身结束之前看到该对象被毁——用 shared_ptr。被管理的对象之间的引用不需要所有权时——用 weak_ptr。观察者、缓存索引、回指引用、临时的访问窗口都属于这一类。三种智能指针和三套所有权语义(独占、共享、观察)构成的接口体系,加上第 2 章定义的"借用"(裸指针或引用),恰好覆盖了动态对象在跨模块传递时的全部合法所有权关系。
有一个实践中容易被低估的使用模式:当一个对象既需要被外部以 shared_ptr 共享,又需要在内部用 weak_ptr 做缓存或索引时,控制块的弱引用计数可能长期保持在一个非零值上。这意味着即使所有业务上的持有者都已经析构了各自的 shared_ptr,只要缓存系统中的 weak_ptr 还没有被清理,由 make_shared 分配的对象内存和控制块内存就都无法归还。在长期运行的服务进程中,这种"逻辑上已销毁但物理上未归还"的内存累积可以在监控面板上呈现出一种假性内存泄漏的曲线——RSS 始终不降,但 shared_ptr 的 use_count 报告早已归零。解决这类问题的工程手段通常包括:在缓存驱逐策略中显式清理失效的 weak_ptr 条目,或者对于生命周期极其短暂的对象放弃 make_shared 转而用两次独立分配(让 T 对象在强引用归零时即可释放,控制块等待弱引用归零)。这不是推荐在日常场景中放弃 make_shared——而是说在你观察到"已析构对象的内存迟迟不回收"的症状时,能够把病因锁定到 make_shared 的合并分配和弱引用计数的交互上,而不是盲目地怀疑泄漏。
在这个体系中,shared_ptr 的使用在工程上应当是慎重的、有明确理由的、且经过"是否可以用 unique_ptr + 借用替代"的自检的。"共享所有权"在代码里最容易被滥用的方式是:把 shared_ptr 作为一个"方便传对象"的工具来使用——对象需要跨两个模块传递,不想去分析谁拥有谁、不想去确定借用的生命周期约束,于是直接把对象塞进 shared_ptr 两边各持一份。"方便"在当下是真实的——不需要分析所有权关系,不需要对模块生命周期做对齐设计——但"方便"的代价是失去了类型系统对所有权的精确表达,后续的维护者无法从这个 shared_ptr 上看出对象在不同模块之间的真实耦合关系,也无法判断这条 shared_ptr 的拷贝链是否在不经意间形成了循环引用。unique_ptr 不给"方便"——它强制你只能通过移动或借用传递对象,而恰好是这种强制,让你在任何一条所有权路径上都躲不开"谁拥有它、谁借用它"的分析。在 C++ 的类型世界里,约束越强的类型,给出的所有权信号越清晰,留给模糊地带的余地越小。
另一个容易被忽略的 shared_ptr 特质是它对 lambda 捕获行为的影响。在一个异步回调中使用 [sp] 按值捕获 shared_ptr 时,sp 被拷贝进 lambda 的闭包对象,引用计数增加,托管对象的生命周期被延长到闭包对象被销毁为止。这个行为在某些场景下是正确且必需的——异步任务在处理期间需要保证对象存活——但在另一些场景下它是无意的生命周期延长:开发者只是想"在回调里用一下对象",却不知不觉让对象活到了回调被执行的遥远时刻(甚至回调永远不会被执行,导致永久泄漏)。在这种路径上,用 weak_ptr 捕获并通过 lock() 检查是更精确的选择:如果对象已经被销毁,回调安全跳过;如果对象还活着,临时 shared_ptr 在回调执行期间保护它。[weak = std::weak_ptr<T>(sp)] { if (auto sp = weak.lock()) sp->do_work(); } 这条模式在回调场景中把"是否延长生命周期"的决策从 lambda 的捕获方式上显式化了——shared_ptr 延长,weak_ptr 不延长。
下一章,本系列的最后一章——要做的就是把四种关系(借用、独占、共享、观察)映射到函数参数类型、返回类型和成员变量的完整接口设计决策中。当你在项目里看到 const T&、T*、unique_ptr<T>、shared_ptr<T>、weak_ptr<T> 这五种类型签名时,不应该再去查阅接口文档来判断各自的使用语义——类型本身应该能回答"我是否拥有这个对象""我是否应该释放它""我持有它多久"。这是所有权接口设计的终端目标,也是这一整个系列在前九章的机制分析之后,可以交付的最后一张拼图。
阅读导航




