shared_ptr与unique_ptr能否互换?先理解所有权方向再动手 最近在给一个 C 项目做代码审查时又看到了这种写法函数返回shared_ptr到了调用方却被到处转成unique_ptr保存另一个模块反过来把本该独占的对象用shared_ptr包着传来传去。问当事人为什么这么写答案几乎都是同一个——“反正都是智能指针嘛差不多能换就换呗。”我在 C 里折腾智能指针这么多年只能说shared_ptr和unique_ptr都是智能指针不假但“差不多”这三个字恰恰是大多数内存问题的起点。这两个东西能不能互换答案不是简单的“能”或“不能”而是要看你的所有权方向。从unique_ptr换到shared_ptr很容易几乎零成本从shared_ptr换回unique_ptr则大概率会踩坑甚至有未定义行为。这篇文章就把两边的机制、场景和坑一次讲透顺便聊聊代码审查时我判断“该不该换”的那套标准。1. 结论先行能不能换取决于你在哪个方向上换先说结论省得你看到一半不知道我在说什么unique_ptr→shared_ptr可以直接换标准库专门提供了构造方式成本很低语义上完全说得通。shared_ptr→unique_ptr没有安全的标准转换路径。标准库没有给shared_ptr提供release()这样的方法你的所谓“转换”要么是危险的裸指针搬运要么是重新构造一个新对象本质上不是“所有权转移”。为什么会有这种现象根源在所有权语义上。unique_ptr表达的是独占所有权这个对象在同一时刻只有一个管理者它不能拷贝只能移动。移动之后原来的指针自动置空所有权干干净净地移交。shared_ptr表达的是共享所有权这个对象同一时刻可能有多个管理者内部通过引用计数来协调析构时机引用计数归零才真正释放对象。换句话说unique_ptr是“只有一个股东的公司”shared_ptr是“有多位股东的公司”。从unique_ptr转换成shared_ptr相当于把一家只有一个股东的公司改制成了股份制新老股东共享公司控制权完全 OK。反过来从shared_ptr转成unique_ptr相当于要把一个股份制公司改成一人独资——问题是其他股东手里的股份你收不回来你只能寄希望于“现在就剩我一个股东了”。可现实往往是你以为就剩你一个实际上别处还藏着几个观察者、几个引用最后照样翻车。我见过太多人在这上面栽跟头写完if (sp.use_count() 1)就以为所有权稳了然后sp.reset()再把裸指针塞进unique_ptr结果双删崩溃或者在 Debug 模式下报出一堆堆的析构断言。后面我会专门讲这个坑。2. 为什么底层模型的不同直接决定了能否互换要真正理解“能不能换”光记住结论不够还得知道两个指针底层长什么样。不用背源码关键信息就这么几条。2.1 对象模型视角的差异一个unique_ptrT本质上是T*的薄封装大多数实现里它的大小就是sizeof(T*)。如果你给了自定义删除器可能稍微大一点。它不引入任何额外的堆分配析构时直接对内部裸指针执行delete或调用删除器。一个shared_ptrT内部有两个指针一个指向管理的对象另一个指向控制块control block。控制块里放着强引用计数、弱引用计数、删除器还有其他一些内部数据。也就是说shared_ptr天生比unique_ptr多一次间接、多一次堆分配除非你用make_shared把对象和控制块一次性分配。2.2 开销的来源shared_ptr的引用计数是原子的。这意味着每次拷贝shared_ptr都是一次原子递增每次析构都是一次原子递减。在高频路径上这比unique_ptr的纯指针搬移要贵得多。举个极端例子std::shared_ptrint sp std::make_sharedint(42); for (int i 0; i 1000000; i) { auto copy sp; // 每次都是原子递增 }这个循环跑下来原子操作开销非常可观。换成unique_ptr就没有这个问题但你也压根没法这么复制拷贝。2.3 几张关键对比表维度unique_ptrshared_ptr所有权独占共享能否拷贝不能只能移动可以拷贝计数 1内部大小通常一个指针大小通常两个指针大小堆分配不额外分配额外分配控制块make_shared可合为一次析构时机自身析构时引用计数归零时线程安全天然不牵涉计数器计数原子操作计数本身线程安全但同一实例并发读写不安全能否安全转为另一种能转shared_ptr不能安全转unique_ptr还有一点和“互换”直接相关make_shared会把对象和控制块放在同一块内存里。这带来一个隐藏的延迟释放问题——如果还有weak_ptr活着即使强引用已经归零对象内存也要等到所有weak_ptr销毁才会释放。因为弱引用计数和控制块绑在一起控制块不销毁对象那块内存就动不了。类似这种事切换到unique_ptr后根本不会发生。2.4 这些差异透露出的核心信息模型差异告诉我们一个判断原则shared_ptr是为了“不确定性”设计的——你不确定对象还有没有别的使用者所以用计数来兜底unique_ptr是为了“确定性”设计的——确定只有一个管理者所以可以做到零额外成本。互换之所以不能“无脑”就是因为这两种“确定性程度”不一样。你把unique_ptr升格成shared_ptr是从确定走向不确定方向前进不破坏任何人的预期。你把shared_ptr降格成unique_ptr是从不确定强行走向确定但设计者没法保证“其他持有者真的都不存在”所以语言层面根本不提供这个口子。3.unique_ptr转shared_ptr升格操作的正确姿势和最佳时机这是两个方向里安全的那一个也是实际开发中最常见的场景。标准库在 C11 开始就支持从unique_ptr直接构造shared_ptr。3.1 标准做法std::unique_ptrWidget up std::make_uniqueWidget(); // 方式一直接构造 std::shared_ptrWidget sp1(std::move(up)); // 方式二先移动构造再赋值 std::unique_ptrWidget up2 std::make_uniqueWidget(); std::shared_ptrWidget sp2 std::move(up2);重点在于必须用std::move。因为shared_ptr的接受unique_ptr的构造函数要求右值。构造完成之后up被置空新shared_ptr的引用计数为 1控制块在这一次转换中被创建出来。整个过程不存在两个智能指针同时管理同一个裸指针的问题因为原来的unique_ptr明确放弃了所有权。提示别自己绕弯子写std::shared_ptrWidget(up.get())。get()拿到的裸指针再包进shared_ptr会创建一个新的控制块而原来的unique_ptr析构时还会删一次对象结果就是双重释放。正确做法永远是移动整个unique_ptr而不是取出裸指针再包一层。3.2 转换背后发生了什么std::shared_ptrWidget sp(std::move(up));这条语句里编译器调用shared_ptr的构造函数把up内部保存的裸指针接管过来同时创建控制块引用计数初始化为 1。之后up内部指针变为nullptr。整个过程没有对象拷贝没有额外复制开销只是多了一个控制块的分配。这里有个性能小细节如果你后续的目标就是shared_ptr那么从unique_ptr提升而来的shared_ptr是无法再享受make_shared那种“对象 控制块一次分配”的优化了。因为对象在unique_ptr里早就分配好了控制块只能在转换时单独分配。所以如果你在写工厂函数明知 100% 调用方都要用shared_ptr不如直接返回shared_ptr并用make_shared创建。如果调用方可能用unique_ptr也可能提升成shared_ptr那工厂函数应该返回unique_ptr让调用方自己决定要不要升格。3.3 最典型的应用场景工厂函数很多 C 风格指南都推荐工厂函数应返回unique_ptr而不是shared_ptr。原因很简单——函数作者不知道调用方到底要独占还是共享返回unique_ptr是最小承诺。调用方如果只想独占直接接住需要共享再升格成shared_ptr。反过来如果工厂返回shared_ptr调用方如果想独占就会发现很难受你没法安全地把shared_ptr变回unique_ptr。// 工厂函数返回 unique_ptr std::unique_ptrWidget createWidget(int id) { return std::make_uniqueWidget(id); } void demo() { // 调用方一独占使用 auto w1 createWidget(1); // 调用方二需要共享给多个模块 std::shared_ptrWidget w2 createWidget(2); // 调用方三对象挂到容器里容器类型是 shared_ptr 列表 std::vectorstd::shared_ptrWidget widgets; widgets.push_back(createWidget(3)); }你发现没有从unique_ptr到shared_ptr甚至有隐式转换——shared_ptr接受unique_ptr的构造函数不是explicit的所以std::shared_ptrWidget w2 createWidget(2);可以直接写。这也是为什么我一直说“这个方向的互换是标准库刻意留好的通道”。3.4 这个方向上的注意点转换后原unique_ptr为空不要再解引用也不要试图release()再存一份。它已经完成了历史使命。不要用shared_ptr指向栈对象除非你给了空删除器否则析构时会delete栈地址直接崩溃。这个坑不在“互换”范畴但经常和智能指针转型一起出现。升格之后的shared_ptr同样要小心循环引用问题不能说 “unique_ptr 转过来就安全了”循环引用看的是对象关系图不是指针初始来历。4.shared_ptr转unique_ptr为什么标准库不给安全通道现在说难的这一边。我先把最重要的话放前面任何“把 shared_ptr 变成 unique_ptr”的操作都不应该出现在你代码的常规路径上。如果非换不可那也一定不是传统意义上的“所有权转移”。4.1 没有release()的本质原因shared_ptr没有release()方法这是一件很耐人寻味的事。unique_ptr有release()它把内部指针吐出来并置空自己把所有权完全交给调用者。为什么shared_ptr不做同样的事因为shared_ptr必须考虑其他持有者。一个shared_ptr只是一个“股权凭证”并不是公司本身。你拿着自己的凭证说“我把公司所有权转给你了”但其他股东手里的凭证并不会消失他们依然会在某个时刻触发析构和释放。一旦释放路径和你的新unique_ptr管理路径撞上就是双重释放。除此以外shared_ptr还允许自定义删除器。就算当前use_count() 1你也无法从外部知道删除器到底干了什么。它可能删除的是一块独立内存可能是对象池中的一个槽位可能什么都不做。你贸然用一个裸指针重新构造unique_ptr等于在未知的释放逻辑边缘疯狂试探。4.2 “use_count() 1 然后转移”为什么是陷阱这是很多人的直觉反应既然只有一个持有者那不就可以放心转成unique_ptr了吗写出来大概长这样std::shared_ptrWidget sp std::make_sharedWidget(); if (sp.use_count() 1) { Widget* raw sp.get(); sp.reset(); // 对象此时被析构 std::unique_ptrWidget up(raw); // raw 已经悬空之后析构必然 UB }这里的逻辑漏洞在于reset()一旦执行shared_ptr就认为自己完成了释放职责立刻调用删除器销毁对象。raw指针马上变成悬空指针。你之后用它构造unique_ptr无非是让这个悬空指针又有了一个“未来的释放者”等到up析构时它去释放一块已经被释放的内存结果就是典型的使用已释放内存 / 双重释放错误。就算你不用make_shared而是用new创建对象让sp和裸指针之间没有同一块内存的问题也依然无法阻止reset()那一下把对象释放掉。在shared_ptr的世界里不存在一个操作叫“把所有权让渡出去并保持对象存活”——reset()只代表“我不要了”不代表“我把接力棒交给你”。这个问题在不同平台、不同优化级别下表现还不一样有时候跑得好好的有时候只在 Release 模式下崩溃是最折磨人的一类 bug。我在项目里就用 ASAN 抓过一次一个模块判断use_count() 1后把对象交给了另一个线程独占管理结果另外有个weak_ptr升级成了shared_ptr两个线程同时在析构同一个对象。4.3 唯一相对可行的路对象搬家而不是所有权转移如果你确定当前确实只有这一个shared_ptr持有对象又真的需要unique_ptr那唯一相对安全的方式是把对象内容移动到一个新的unique_ptr所管理的对象里然后让原来的shared_ptr析构掉。听起来绕代码其实不长std::shared_ptrWidget sp std::make_sharedWidget(); std::unique_ptrWidget up; if (sp.use_count() 1) { up std::make_uniqueWidget(std::move(*sp)); // 要求 Widget 可移动 sp.reset(); }注意这里的语义up管理的是一个新对象不是原来那个对象。原来sp指向的对象在reset()时被正常析构新unique_ptr管理的是通过移动构造得到的新对象。这要求Widget的移动构造是可用的而且移动之后原来的对象还能正常析构。如果Widget不可移动、不可拷贝这条路也走不通。这时候唯一正确的做法是停止互换的念头从设计上重新梳理所有权模型。我知道这话不好听但你在 C 里强行把一个共享对象变成独占对象本身就是和语言设计对着干。4.4 那个传说中的“空删除器 hack”为什么不推荐网上有一种流传的做法给shared_ptr设置一个空删除器然后get()出裸指针交给unique_ptrstd::shared_ptrWidget sp(new Widget(), [](Widget*) {}); std::unique_ptrWidget up(sp.get()); sp.reset(); // 空删除器不会 delete看起来巧妙但它有致命缺陷如果sp被拷贝过或者存在某个weak_ptr曾在某个瞬间升级成shared_ptr对象的真实所有权就变成“多头”了。只要另一个持有者用正常删除器析构up手里的指针还是悬空。如果最初的shared_ptr是用make_shared创建的对象和控制块是同一块内存空删除器也不能阻止控制块在强引用归零时回收整个分配块up拿到的裸指针照样悬空。代码可读性极差后来维护的人完全看不出这个对象的释放责任到底归谁。我在代码审查里只要见到这种写法都直接打回。理由很简单**一个需要靠 hack 来完成的操作往往意味着你的设计本来就错了。**与其想办法骗过shared_ptr的释放逻辑不如重新组织代码让所有权归属从一开始就清晰。5. 实战中的互换判断什么场景该换什么场景不该换聊完了机制回到实际项目里。我经常被问到的问题其实是这类一个函数参数到底传什么成员变量用什么类型容器里存什么这些选择背后都藏着“要不要互换”的问题。5.1 函数参数与返回值的选型基本判断标准我用一张表总结过很多次使用场景推荐类型理由只读访问对象不持有const T或裸指针不涉及所有权最小化接口依赖需要转移独占所有权unique_ptrT按值传递明确表达所有权迁移允许他人共享对象不确定生命周期shared_ptrT通过引用计数协调释放只是观察对象不想延长生命周期T*或weak_ptrT不增加所有者避免循环持有特别强调一点函数参数默认不应该用智能指针。如果一个函数只是读一下对象的字段那就传引用或裸指针没必要把shared_ptr传来传去。滥用shared_ptr参数不仅造成原子计数开销还会让调用方被迫也去构造shared_ptr无形中增加不必要的互换。unique_ptr参数也常见问题。看到void foo(std::unique_ptrWidget w);时调用方必须std::move传入这意味着函数明确夺走所有权。这本身是好的设计但有些人为了图方便传参时用.get()拆出裸指针进去之后又包一个新的unique_ptr这比直接传std::move更容易埋雷。这种“先拆后包”的操作就是我定义里的典型反模式。5.2 循环引用不是换个指针类型就能解决的这是经常被误解的一点。很多人说shared_ptr循环引用会泄漏所以要换成unique_ptr。循环引用的问题出在对象关系上不是出在指针类型上。如果A和B互相持有shared_ptr形成一个环引用计数永远不为零于是泄漏。这个环能不能靠“把其中一个shared_ptr换成unique_ptr”解决如果改成unique_ptr后B不再拥有A的所有权而仅仅需要访问A那确实没问题但如果B必须和A共享某个对象的生命周期unique_ptr根本表达不了这种关系。更合理的调整是环中有一侧改成weak_ptr而不是把shared_ptr强行变成unique_ptr。weak_ptr是专门为“观察但不拥有”设计的它不平移所有权只是引用一段可能被共享管理的对象。class A { std::shared_ptrB b_; }; class B { std::weak_ptrA a_; // 观察 A但不持有 A };这样A持有BB弱引用A引用计数不会形成环对象生命周期也能正常结束。如果你在代码审查里看到有人为了“解决循环引用”而把成员变量改成unique_ptr先别急看看他是不是真的想表达独占语义。如果只是想打破环weak_ptr才是那个工具。5.3 容器里的混用问题项目中还常见一种情况同一个对象的指针一部分放在vectorunique_ptrT里另一部分作为shared_ptrT传给其他模块。这种混用会让所有权模型彻底混乱。例如一个模块从vector中取下某个元素转成shared_ptr继续使用但实际上vector里的unique_ptr还在管理这个对象。一旦vector那个元素被移除或整个容器析构对象被释放散落在外的shared_ptr全部悬空。这种 bug 特征非常明显只在某个特定删除操作后偶现还不好复现。正确的设计思路是给容器定一个统一基调如果容器是对象的唯一仓库用vectorunique_ptrT需要共享时再从这个仓库里拷贝或升格出shared_ptr但仓库本身必须保证对象存活如果对象本来就是多模块共享的整个容器用vectorshared_ptrT而不是混搭。5.4 实际转换代码示例安全方向与危险方向对照// 安全方向unique_ptr - shared_ptr std::unique_ptrWidget up std::make_uniqueWidget(); std::shared_ptrWidget sp std::move(up); // OK // 危险方向shared_ptr - unique_ptr std::shared_ptrWidget sp2 std::make_sharedWidget(); // std::unique_ptrWidget up2 std::move(sp2); // 编译错误shared_ptr 不能移动给 unique_ptr // std::unique_ptrWidget up3(sp2.get()); // 编译能过但运行期大概率翻车 // 相对合理的方式对象搬家 if (sp2.use_count() 1) { auto up4 std::make_uniqueWidget(std::move(*sp2)); // 移动构造新对象 sp2.reset(); }6. 我在代码里如何判断“该用哪个”以及相关避坑经验最后这部分与其说是技术分析不如说是多年踩坑之后的几条私货经验。不一定能直接解决你手头的 bug但能帮你在写代码前少走很多弯路。6.1 做决定前先回答四个问题当我要在代码里放置一个智能指针时我习惯快速过四个问题这个对象可能同时被多个模块访问吗是用shared_ptr否用unique_ptr。对象的生命周期可以明确划归给某一个模块吗可以用unique_ptr不可以用shared_ptr。使用者需要长期持有还是仅临时观察长期持有根据前两点选择临时观察用裸指针或weak_ptr。这个类型将来会不会被放进shared_ptr使用如果会必要时让它继承enable_shared_from_this同时就不要再想去转换成unique_ptr了。这四个问题走完99% 的“我到底该用哪个”都能回答。剩下的 1%属于本来就有设计缺陷的代码怎么选都是坑。6.2 排查智能指针混用问题的思路如果你已经遇到了和智能指针互换相关的 bug我的排查顺序通常是这样的第一阶段查get()全项目搜索.get()的调用点尤其是把get()结果塞进另一个智能指针的地方。这是最常见的问题来源。第二阶段查use_count()搜索所有判断use_count() 1或类似的计数检查的代码。这种代码往往暗示某人想偷偷做所有权转移。第三阶段查reset()之后使用裸指针的路径搜索reset()之后有没有继续访问get()或保存过的裸指针。第四阶段检查删除器搜索自定义删除器和空删除器确认是否有人在用 hack。另外可以打开 ASAN 或 UBSan 跑一遍相关功能能很快定位双重释放或使用已释放内存的问题。其实很多“偶现崩溃”在 ASAN 底下都是秒出结果问题是你得先怀疑到“所有权转移”这个头上。6.3 几条写代码的自我约定我给自己定过几条约定这几条让我后半程的智能指针问题少了八成约定一任何智能指针拿到裸指针后只在当前函数内局部使用绝不长期保存更不传给另一个智能指针。约定二shared_ptr只能来自三种途径make_shared、容器里拷贝、其他shared_ptr的拷贝。任何从裸指针包出来的shared_ptr都要写注释说明来源。约定三如果发现代码里出现“先shared_ptr再unique_ptr”的转换需求停下来去改设计而不是想办法绕过编译器。约定四工厂函数统一返回unique_ptr需要共享的调用方自行升格。这样至少保证“向下兼容”所有权模型不会因为返回值类型把调用方逼上梁山。6.4 关于 vscode 环境下编译验证的小提醒提一个实用的小细节。很多新手在 Visual Studio Code 里配置 C/C 环境之后写智能指针相关代码时经常发现报错信息不直观比如明明写了#include memory还提示找不到shared_ptr。这种情况十有八九是编译器标准没有切到 C11 及以上。在c_cpp_properties.json或tasks.json里把标准设置成c17shared_ptr和unique_ptr就能正常识别。如果你的代码里还用了std::make_unique记得必须是 C14 及以上。排查这类环境问题的时候直接在终端用编译器手动编译一遍比在编辑器里猜更快g -stdc17 -Wall -fsanitizeaddress demo.cpp -o demo-fsanitizeaddress是排查智能指针内存问题的好帮手。从我这些年的经验看shared_ptr和unique_ptr之间的“互换”本质上是所有权模型的一次表态。unique_ptr升格成shared_ptr是安全的、常见的、被标准库鼓励的shared_ptr降格成unique_ptr则是危险的、罕见的、需要靠设计调整来规避的。如果你正在一个项目里反复纠结这两种指针该不该换我的建议是停下来问自己四个问题给对象的所有权做个清晰的归属再决定用哪个。很多时候你会发现真正的问题不是“能不能换”而是“我当初为什么要把它们混着用”。