资讯动态

C++智能指针全解析:unique_ptr、shared_ptr、weak_ptr实战与避坑指南

发布时间:2026/10/14 21:12:19 来源:尧图企业网站定制
如果你写过一阵子C大概率见过这种场面程序连续跑几天内存占用像一条斜线往上走你盯着进程监视器怀疑人生却不知道到底是哪块内存在泄漏或者更惨一点某处delete了之后还有代码继续访问程序在用户机器上崩溃等你去查调用现场早就没了。这些问题我在用裸指针的阶段几乎全都遇到过。后来转向智能指针不是因为它更现代这种理由而是因为它把谁来释放内存这件事从人脑记忆变成了类型系统的一部分——看到类型你就知道所有权归属这让代码的可维护性好了一个数量级。这篇文章就把我对unique_ptr、shared_ptr、weak_ptr的理解和实际踩坑经验完整梳理一遍适合已经能熟练写C但还没系统用过智能指针的人也适合用了不少但总在某些边界场景上懵一下的老手。1. 从裸指针到智能指针我们到底在解决什么问题1.1 三种最常见的指针事故先别急着看语法你得先理解智能指针到底想消灭什么。我总结下来手动管理内存时翻车就翻在下面三种情况。第一是泄漏。你分配了一块堆内存但因为某个分支提前return、或者异常被抛出、或者干脆是忘了写delete这块内存再也没有被释放。void load() { auto* cfg new Config(); if (checkSomething()) { return; // cfg 这份内存再也没人管了 } delete cfg; }这种问题最阴险的地方在于它不报错。程序还能跑只是内存缓慢增长等到你发现的时候往往已经找不到是哪次调用漏的。第二是重复释放。两个模块都觉得自己对这块内存有释放责任。void consume(Config* cfg) { delete cfg; // 函数内释放 } void demo() { auto* cfg new Config(); consume(cfg); delete cfg; // 二次释放行为未定义 }二次delete可能当场崩溃也可能不崩——因为那部分内存可能已经被操作系统重新分配给别人了你实际上删除了别人的数据。第三是悬空指针。你保存了一个指针但对象已经销毁了后面再用就是访问野内存。Config* g_cfg nullptr; void init() { Config cfg; // 栈上对象 g_cfg cfg; } // cfg 析构 void use() { g_cfg-name; // 访问已销毁对象 }这三种事故里泄漏最隐蔽重复释放和悬空最致命。用裸指针的时候你只能靠自律来避免它们而自律在大型项目里是最靠不住的。1.2 RAII智能指针的底层逻辑智能指针的底层支撑是C里被说得最多的那个概念RAII——Resource Acquisition Is Initialization资源获取即初始化。这句话的翻译一直有争议容易把人绕晕。落到智能指针上其实就一句话当智能指针对象被创建时它接管一份堆内存当它走出作用域、析构函数被调用时它自动释放这份内存。你不需要记着哪一行该 delete编译器在你代码的每一个退出路径上都替你记住了。这里要强调每一个退出路径这六个字。return、异常、break、continue任何跳出作用域的方式析构函数都会被调用。这跟手动管理完全不同——手动管理你得在每个退出点都补一个delete漏一个就泄漏。1.3 所有权思维从什么时候释放到谁拥有用裸指针的时候你脑子里得维护一张隐式的表这块内存是谁申请的、谁负责释放、有没有共享、共享的话最后一个用完的人是谁。人脑维护这种表非常不可靠尤其在函数跨类、跨模块调用的时候几个new和delete隔着十万八千里出错是迟早的事。智能指针做的改变是把所有权显式地写进类型里unique_ptr表示独占所有权一份资源同时只有一个持有者shared_ptr表示共享所有权多个持有者共同拥有最后一个析构的人负责释放weak_ptr表示观察但不拥有你只是看着它不想延长它的生命。写下代码的那一刻所有权规则就通过类型传达出来了。别人看你的接口声明不用翻实现就知道谁该释放内存。这才是智能指针真正的价值它把运行时才暴露的错误提前到了编译期和类型检查这一层。2. unique_ptr默认选择为什么是它2.1 独占所有权与移动语义先说结论在80%以上的场景里你的第一选择应该是unique_ptr而不是shared_ptr。很多从其他语言转过来的朋友容易一上来就共享因为在很多语言里所有对象引用天然是共享的但在C里默认应该是独占只有确实需要多处访问同一份数据时才升级成shared_ptr。unique_ptr的语义是独占所有权。它的使用方式非常直接#include memory #include string struct Config { std::string name; int timeout_ms 0; }; std::unique_ptrConfig createConfig(const std::string name) { auto cfg std::make_uniqueConfig(); cfg-name name; cfg-timeout_ms 3000; return cfg; // 移动不拷贝 }为什么它禁止拷贝试想如果允许拷贝那同一块内存就有了两个 owner两个 owner 各自的析构函数都会去delete这就变成二次释放。所以unique_ptr的拷贝构造和拷贝赋值是被显式删除的只保留移动构造和移动赋值。移动的语义你可以理解成把一线管理权递出去你把这块内存的钥匙交接给别人自己手里的指针变空。这个交接是零开销的只是两个指针值的交换。我第一次用unique_ptr时有个疑惑为什么return local unique_ptr能编译通过因为C标准对局部对象返回有特殊规定——即使没有显式写std::move编译器也会把它当作右值来处理在无法省略拷贝时会自动选择移动构造。所以你只管写return cfg;不用担心拷贝被禁止。2.2 make_unique 与自定义删除器虽然unique_ptrT(new T(...))也能用但我强烈建议一律使用std::make_unique。auto cfg std::make_uniqueConfig();原因有两个。第一是代码简短类型只写一次不用重复Config。第二是它能避免一种极端但真实的泄漏场景假设有f(make_uniqueA(), make_uniqueB())两个实参的求值顺序在一个函数调用里是不确定的如果第一个make_unique构造成功后第二个在new B时抛了异常那第一个就已经分配的那块内存就会泄漏。make_unique把构造和创建动作合成一次调用从根上消除了这个窗口。unique_ptr还有一个容易被低估的点删除器是可以定制的。默认删除器是delete ptr对普通new出来的对象刚好合适。但有些资源不是new出来的比如文件句柄、操作系统句柄就需要你告诉它正确释放方式。auto file_deleter [](FILE* f) { if (f) fclose(f); }; std::unique_ptrFILE, decltype(file_deleter) file(fopen(log.txt, w), file_deleter);注意一旦自定义删除器unique_ptr的模板参数里会多出删除器类型。如果删除器是无捕获的 lambda编译器通常能优化为零额外空间如果是有状态函数对象它会作为成员存储占用一些空间。这点跟shared_ptr不同——shared_ptr的删除器是保存在控制块里的不随指针对象本体占空间。2.3 函数返回值与容器元素移动语义的典型舞台unique_ptr最舒服的用法之一就是放进容器。C11 以后所有标准容器都支持移动语义于是你可以写出这样的代码std::vectorstd::unique_ptrTask tasks; tasks.push_back(std::make_uniqueTask(1)); tasks.push_back(std::make_uniqueTask(2)); for (auto task : tasks) { task-run(); }注意循环里用的是auto不是auto。如果你写for (auto task : tasks)编译器会尝试拷贝每个元素而unique_ptr禁止拷贝于是直接编译失败。这个错误其实是一件好事——它在编译期就阻止了你无意间复制所有权。工厂函数返回unique_ptr也是业界标准姿势。调用者从函数签名就能看出这个对象归我管而且只有我能管。所有权转移清晰明了不会像返回裸指针那样还要靠注释去约定谁来释放。我的体会是unique_ptr应当成为你写出new字样的默认方式。哪天你发现自己写了new先问一句这里能不能换成make_unique然后写shared_ptr之前再问一句这里真的需要共享吗。这两句反问能挡掉大部分设计问题。3. shared_ptr引用计数背后的控制块设计3.1 引用计数原理每份拷贝都在续命shared_ptr允许真正共享所有权。它的原理是引用计数每多一份拷贝计数加一每销毁一份计数减一。当计数归零时最后一个持有者负责释放对象。auto sp std::make_sharedWidget(); auto sp2 sp; // 引用计数 2 { auto sp3 sp2; // 引用计数 3 } // sp3 离开作用域计数变回 2只要还有一份shared_ptr活着对象就不会被销毁。这个机制对付多个模块都要访问同一个配置对象多个回调都要持有同一个上下文这类场景特别好用。但引用计数并不是没有代价的。我这里要展开一个很多人没注意的细节shared_ptr的管理信息不是存在对象自己内部的。标准库实现里每个shared_ptr对象通常保存两个指针一个指向被管理的对象一个指向控制块。控制块里放着引用计数、弱引用计数、删除器等数据。控制块本身是堆分配的。也就是说跟unique_ptr相比shared_ptr多一次堆分配、多一层间接寻址。这是它贵的第一个来源。3.2 make_shared 的隐蔽优势与那一个例外所以就有了std::make_shared存在的意义。它会把对象本身和控制块放进同一块连续内存里一次性分配。auto sp std::make_sharedWidget(args...);对比一下std::shared_ptrWidget sp(new Widget(args...));后者要做两次堆分配一次给Widget一次给控制块。前者只做一次。在大量创建shared_ptr的热路径上这个差距非常明显缓存局部性也更好。但make_shared有一个容易被忽视的副作用对象和控制块在同一个内存块里意味着对象内存的归还时间要等所有weak_ptr也销毁之后。如果有一个weak_ptr活了很久而你恰恰对内存释放时机极其敏感那这部分内存就会比预期晚释放。这种场景比较少见但如果你维护的是长时间运行的后端服务又确实存在活着但不使用的weak_ptr可以留意一下。真遇到这种极端情况可以退回到shared_ptrWidget(new Widget(...))这种分开分配的方式。3.3 线程安全边界计数安全不等于对象安全shared_ptr线程安全吗这个问题几乎每次培训都会被问到。标准答案分两层第一层引用计数的增减是线程安全的。标准库保证计数的操作是原子操作多个线程各自持有同一个对象的shared_ptr拷贝时一个线程析构不会影响另一个线程的计数。计数归零后也不会出现两个线程同时delete的情况。第二层这不代表被指向的对象本身线程安全。如果两个线程同时在同一个对象上调用非 const 方法那仍然是数据竞争shared_ptr完全不负责这个。很多刚接触的人会误以为用了shared_ptr就不用加锁了这个误解能在生产环境引发非常难查的崩溃。还有一种情况需要特别提醒同一个shared_ptr对象实例被多个线程同时读写是不安全的。比如一个全局shared_ptr变量一个线程在给它重新赋值另一个线程在读它那也得加锁。正确做法是每个线程各自持有同一对象的一份shared_ptr拷贝而不是共享同一个shared_ptr对象。4. weak_ptr打破循环引用的关键工具4.1 循环引用是怎么形成的shared_ptr引用计数看起来很美好但它有一个著名的坑循环引用。当一个对象被子对象持有、子对象又反过来持有父对象时两个对象的引用计数都永远不可能归零析构函数永远不会执行这就是一种特殊的内存泄漏。最常见的例子是链表里的双向指针struct Node { std::shared_ptrNode next; std::shared_ptrNode prev; // 问题就在这里 ~Node() { std::cout destroyed\n; } }; void buildRing() { auto a std::make_sharedNode(); auto b std::make_sharedNode(); a-next b; b-prev a; // a 被 b 持有b 也被 a 持有 } // 函数结束后两块内存都无法释放节点 A 被 B 的prev持有一份引用节点 B 被 A 的next持有一份引用谁也无法把计数减到零destroyed 永远不会被打印。这种问题最气人的地方在于它运行时完全正常没有报错只是内存只增不减。你排查泄漏时如果只盯着new和delete很难想到是智能指针自己造成的循环。解决办法很简单把从属关系那一侧的指针换成weak_ptr。父持有子用shared_ptr子回指父用weak_ptr。struct Node { std::shared_ptrNode next; std::weak_ptrNode prev; // 打破了循环 ~Node() { std::cout destroyed\n; } };这样 B 的prev不会增加 A 的计数。A 的计数完全由外部持有者控制外部一切都销毁时A 就可以正常析构B 也随之析构。4.2 lock() 与 expired() 的正确姿势weak_ptr本身不拥有对象所以你不能直接通过它访问目标。要访问必须调用lock()它会原子地检查目标是否存活活着就返回一个有效的shared_ptr已经销毁就返回一个空的shared_ptr。std::shared_ptrNode getPrev(const Node node) { if (auto sp node.prev.lock()) { return sp; // 对象还活着 } return nullptr; // 已经销毁 }注意weak_ptr提供了一个expired()方法用来查询是否销毁。但我的建议是不要先expired()再lock()。因为这两个调用之间对象可能刚好被其他线程销毁了你拿到的lock()结果还是空的等于白查一次还多了一个竞态窗口。直接lock()然后判断返回值是否为 null一步到位。weak_ptr自身还有一个小知识点它保存的指针大小和shared_ptr一样都是两个指针一个指向对象一个指向控制块。所以你不要以为不拥有就意味着零开销。创建weak_ptr会原子地增加弱引用计数销毁时也会原子地减少。只不过它不会触发对象的析构。4.3 缓存、观察者与其他典型场景除了打破循环引用weak_ptr还有两个非常经典的应用场景。第一个是缓存。缓存里保存weak_ptr当外部还有强引用时直接从缓存取当外部都释放了缓存条目自动失效下次再生成一个。class RecordCache { std::mapstd::string, std::weak_ptrRecord entries; public: std::shared_ptrRecord get(const std::string key) { auto it entries.find(key); if (it ! entries.end()) { if (auto sp it-second.lock()) { return sp; // 命中且对象存活 } } auto sp std::make_sharedRecord(key); entries[key] sp; return sp; } };这个模式完美避免了缓存锁住了对象生命周期的问题。如果用shared_ptr做缓存即使业务方都已经不用了缓存还强引用着对象就永远无法释放。第二个是观察者模式。被观察者持有一堆观察者的weak_ptr而不是shared_ptr。这样观察者销毁后被观察者不会积累悬空的强引用也不用专门写反注册逻辑。每次广播事件时用lock()判断一下观察者还在不在。一句话总结只想看一眼而不想负责时就用weak_ptr。5. 误用场景排查那些让我半夜改代码的问题5.1 把 get() 的返回值长期保存shared_ptr和unique_ptr都提供了get()用来返回原始指针。它的存在是为了跟老式 C 接口、或者需要裸指针的第三方库对接。但get()返回的裸指针有一个隐含约定它只在智能指针存活期间有效。你不能把它保存起来过夜。std::shared_ptrLogger sp std::make_sharedLogger(); g_logger sp.get(); // 危险全局裸指针长期挂着 // sp 析构之后g_logger 就是悬空指针我见过不止一次因为get()临时指针被存进全局变量、成员变量然后智能指针析构后还用导致神秘的偶发崩溃。排查这类问题时最有效的办法是全局搜索get()一个个确认返回值有没有被存储。5.2 this 转 shared_ptr 的两条错误路径与正确姿势在类的成员函数里想返回自己的shared_ptr这是高频需求。最常见的错误是直接构造class A { public: std::shared_ptrA getShared() { return std::shared_ptrA(this); // 错误会多出一个控制块 } };如果你在外面已经有一个shared_ptrA管理这个对象再通过this构造一个就会产生两个相互不知道对方的控制块。两个控制块的引用计数各计各的最后谁都认为自己该delete于是二次释放。这种 bug 往往要等两个引用都销毁时才触发非常难复现。正确的做法是继承std::enable_shared_from_thisclass A : public std::enable_shared_from_thisA { public: std::shared_ptrA getShared() { return shared_from_this(); } };但这里有个容易忽略的前提shared_from_this()只能在对象已经由一个shared_ptr管理时调用。如果你在栈上创建了 A没经过shared_ptr就直接调用shared_from_this()标准库会抛出std::bad_weak_ptr异常。还有一个细节继承enable_shared_from_this必须用公有继承。在某些实现里私有继承会导致编译错误或运行期问题。我刚学的时候就因为这个栽过跟头。5.3 同一个裸指针构造两个 shared_ptr这个错误跟上面类似但发生得更隐蔽auto* raw new Widget(); std::shared_ptrWidget a(raw); std::shared_ptrWidget b(raw); // 两个控制块双重释放两个shared_ptr各自以为自己是唯一 owner控制块相互独立。当它们都析构时同一块内存被delete两次。这种写法的根源是先发明了一个裸指针再分发给多个共享指针。正确做法是从一开始就用make_shared创建第一份之后的所有共享都通过拷贝shared_ptr本身来完成而不是重新用裸指针构造。顺带一提如果你拿到了一个shared_ptrDerived想把它转成shared_ptrBase用于多态存储直接用拷贝构造或赋值即可C 标准库会正确处理并保持引用计数一致。不要企图先get()出来再转那是自己给自己挖坑。5.4 传参方式值传递、引用传递与性能陷阱shared_ptr作为函数参数时写法直接决定了调用开销。void process(std::shared_ptrWidget sp); // 值传递计数加一又减一两次原子操作 void process(const std::shared_ptrWidget sp); // 引用传递不改变计数零原子操作如果函数只是借用这个对象调用期间不会持有一份拷贝那就用const shared_ptrT。如果函数需要把这份引用存起来或者放到异步任务里那就必须值传递因为你需要用拷贝保证对象在异步期间活着。这里还有一个更极端的建议如果函数根本不关心共享语义只关心我要用一下这个对象那直接用const Widget不是更好吗很多函数接收shared_ptr其实只是顺手真正需要的是对象本身。用const Widget就完全绕开了引用计数调用方也不用非得持有shared_ptr。判断标准很简单你在这个函数里有没有把参数转成shared_ptr存下来如果没有就别用shared_ptr参数改传引用或裸指针。C 的shared_ptr还支持从Derived到Base的转换并且会保留正确的删除行为。但反过来——把unique_ptr转成shared_ptr是允许的且推荐的把shared_ptr转回unique_ptr则意味着要重新管理所有权除非你能保证没有其他地方持有引用否则不要这么干。6. 性能、选型与团队约定智能指针不是银弹6.1 三种智能指针的开销对比既然要做选型就得把账算清楚。下面这张表是我在 64 位 Linux 环境下实测得出的典型数据不同标准库实现略有差异但数量级不会变。指针类型单指针大小创建开销拷贝/析构开销适用场景裸指针8 字节无额外开销纯复制地址非拥有观察者、老 C 接口unique_ptr8 字节默认删除器无额外开销禁止拷贝移动近乎免费独占所有权默认首选shared_ptr16 字节控制块堆分配原子计数 1/-1真正需要共享所有权weak_ptr16 字节随控制块原子弱计数 1/-1打破循环、缓存、观察者unique_ptr的近乎零开销是它成为默认选择的最大底气。它的尺寸跟裸指针一样大释放逻辑在编译期就确定了运行时不比裸指针多花一分钱。shared_ptr的成本要具体算。拷贝一次就是一次原子递增析构一次一次原子递减。在单线程程序里原子操作还有额外的屏障开销。更不用说每次make_shared的那一次堆分配。如果在高频路径上频繁拷贝shared_ptr这部分开销是真实可测的。weak_ptr的成本也不可忽略。每次lock()都是一次原子读加一次条件判断多数情况下还会顺带增加强计数。所以缓存热点路径上weak_ptr::lock并不便宜。6.2 什么时候仍然该用裸指针听了这么多智能指针的好处我得泼一盆冷静的水裸指针在C里没有被完全淘汰也不应该被完全淘汰。合法的裸指针使用场景有几个。第一非拥有的借用指针借用期间智能指针的生命周期被调用栈明确保证这时候用裸指针最轻便。第二跟 C 风格接口互操作很多库的函数签名就是T*你躲不开。第三性能敏感的极热路径上实测确认shared_ptr拷贝是瓶颈且对象生命周期有严格作用域保证可以退回到手动管理——但这个决定应该由性能剖析数据来驱动而不是凭感觉。真正要拒绝的是用裸指针存储所有权的旧代码模式。一个成员变量是Widget*却不知道谁释放这就是事故现场。团队约定里必须有一条代码库中的裸指针默认是非拥有的。这样看到裸指针时读者的第一反应才是哦这里只是借看一下而不是这块内存到底谁管。6.3 调试与验证工具让问题在测试环境现形智能指针能解决大部分问题但解决不了所有问题。剩下的那些问题靠人眼很难看出来得靠工具。我最推荐的是 Sanitizer 系列。编译时加上这几个选项g -fsanitizeaddress -fsanitizeleak -g -O0 main.cpp -o mainAddressSanitizer 能在运行期检测到堆缓冲区溢出、栈缓冲区溢出、悬空指针访问LeakSanitizer 能检测内存泄漏包括shared_ptr循环引用造成的泄漏。注意 LeakSanitizer 默认随 AddressSanitizer 一起开启它会报告所有未释放但仍被引用的地方循环引用的对象就会在这里现形。Valgrind 也很经典但开销大得多适合测试环境跑慢速复现。静态分析工具则可以在编译期就提示可疑的shared_ptr用法比如把get()的返回值赋给全局变量。我在实际排查循环引用时经常用一招给关键类的析构函数加日志然后跑一遍业务流程看哪些析构函数没有输出。没有输出的类就是被循环引用卡住的地方。6.4 团队规范的几条约定最后说几条我在实际项目里验证过的团队规范不一定适合所有团队但可以作为参考。接口层面工厂函数统一返回智能指针且能用unique_ptr就绝不返回shared_ptr。调用方如果需要共享自己再转换。参数层面借用别所有权就传const T或裸指针真的需要存一份再传shared_ptr。存储层面成员变量用智能指针声明所有权裸指针成员变量一票否决——除非你有强烈的性能理由并写清楚注释。新增代码里尽量不再出现 barenew所有动态对象从make_unique或make_shared起步。这一套落地之后我最大的感受是代码审查时的讨论重心从这里要不要 delete这个指针传给谁会释放变成了这里的所有权边界设计是否合理。后者比前者有价值得多。智能指针不是银弹它不会替你设计架构但当你用法正确的时候它能把你的注意力从内存管理的泥潭里解放出来放到真正需要思考的业务逻辑上去。最后再分享一个我自己的习惯写新模块时先全部用智能指针把所有权关系理清楚跑通功能之后如果剖析发现某处确实是性能瓶颈再针对那一小段做优化甚至局部退回裸指针但每一次退回都要用注释说明为什么这个位置可以安全地使用裸指针。这样做既能保住代码的长期可维护性又不会被性能两个字绑架。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑