
我第一次真正意识到裸指针的问题是我帮同事排查一个服务端模块的内存泄漏。那个模块里到处是new出来的对象析构函数里漏了一半delete还有几处在异常分支上直接 return连清理的机会都没有。当时我一边用 Valgrind 刷满屏的 definitely lost一边想这种问题是 C 天生的吗是也不是。C 是少数把内存管理完全交给开发者的主流语言这门语言给你绝对的控制力代价是你得保证每一个new都有对应的delete不管代码路径怎么走。智能指针就是来解这个问题的——它不是什么黑魔法而是把 RAII 思想落到指针这一个具体场景上。这篇的内容就是围绕 C 智能指针展开的包括unique_ptr、shared_ptr、weak_ptr的机制和源码级原理、引用计数和循环引用这些经典问题、工程上怎么选型、以及我在实际项目里踩过和看见别人踩过的坑。适合正在学 C 的、准备面试的、以及写了几年代码但每次用智能指针还是有点心虚的朋友。1. 从裸指针到智能指针核心思路不止是自动释放1.1 手动管理内存的三个痛点先说说裸指针为什么难。不是难在new和delete本身的写法而是难在保证这两个字。实际代码里delete被跳过的路径太多了函数中间某个条件分支提前 return后面的清理代码没执行构造了两个资源第二个抛出异常第一个没人管对象被多个模块共享你不知道哪个模块应该负责释放于是谁都不释放删除某个资源时另一个模块还握着一个已释放的指针产生悬空指针。这四个问题用一句话概括就是所有权不明确。资源是我的还是你的我该不该释放释放了别人还能不能用裸指针本身回答不了这些问题所有的约定都只能靠开发者自觉、靠注释、靠 review 去维护。注释靠不住人更靠不住。打个比方。裸指针就像是办公室门口挂了一串钥匙谁都可以拿去开门但没人规定用完要放回来也没人统计有多少把钥匙在外面。一开始钥匙够用后来有的人拿走了不还有的人开门开一半走了门锁还坏了最后谁都不知道这间办公室还能不能正常用。而智能指针做的就是把谁拿着钥匙离开后要不要锁门这件事用规则固定下来。1.2 RAII 思想才是根本很多人学智能指针第一反应是哦就是让指针自动 delete 自己这个理解没错但太浅了。智能指针背后的东西叫 RAII——Resource Acquisition Is Initialization资源获取即初始化。这个思想比智能指针本身重要得多。它的核心逻辑是把资源的生命周期绑定到一个栈对象的生命周期上。在构造函数里获取资源在析构函数里释放资源。因为 C 保证栈对象在离开作用域时一定会执行析构函数——无论是正常 return、还是 throw 异常导致的栈展开析构函数都会被执行。于是资源的释放就变成了一件编译器帮你保证的事情而不是你记得做的事情。class FileGuard { public: explicit FileGuard(const std::string path) : fp_(std::fopen(path.c_str(), r)) { if (!fp_) throw std::runtime_error(open failed); } ~FileGuard() { if (fp_) std::fclose(fp_); } // 禁止拷贝保留移动 private: std::FILE* fp_; }; void process() { FileGuard fg(/tmp/data.txt); // 无论这里做什么离开作用域时 fg 析构文件一定被关闭 }注意 RAII 和 GC垃圾回收的区别。Java、Python 的 GC 是不确定的自动你没法精确控制对象在哪个时刻被回收。而 RAII 是确定的自动——析构的时机就是离开作用域的瞬间。这个区别非常重要因为 C 的智能指针深度依赖这种确定性当你拿到一个shared_ptr时资源还在当你把它置空时如果这是最后一个持有者资源立刻被释放。下一节会讲这个立刻为什么是好事。2. unique_ptr所有权唯一C 里最该常用的指针2.1 基本用法和移动语义如果让我给刚学智能指针的人一个建议那就是默认优先用std::unique_ptr。这句话我后面还会重复几次。unique_ptr的语义用一句话讲就是独占所有权。同一时刻只有一个unique_ptr拥有这个资源它不允许拷贝只允许移动。std::unique_ptrint p1 std::make_uniqueint(42); std::unique_ptrint p2 p1; // 编译错误不允许拷贝 std::unique_ptrint p3 std::move(p1); // OK转移所有权 // 此时 p1 变成空指针p3 持有资源编译期禁止拷贝这是unique_ptr的设计精髓。它把这个错误从运行时提前到了编译期从难以排查的内存问题变成了红波浪线。你在一开始写代码的时候就不得不思考这个指针真的只有一个所有者吗移动语义在这里也扮演了关键角色。std::move做的事情是所有权转让转让后原指针不再持有资源。这跟拷贝完全不同相当于我把钥匙给你我自己不再保管。2.2 自定义删除器不止处理 new 出来的对象很多人在unique_ptr上只用到make_unique但其实自定义删除器是一个非常实用的能力它可以接管任何用完需要清理的资源不只是堆内存。比如文件句柄、socket、GDI 对象、管道、互斥锁等等。只要你想在作用域结束时自动释放就可以用unique_ptr包一层。// 自定义删除器关闭文件 auto fileDel [](std::FILE* f) { if (f) std::fclose(f); }; std::unique_ptrstd::FILE, decltype(fileDel) filePtr( std::fopen(a.txt, r), fileDel); // 自定义删除器释放 GDI 句柄 auto gdiDel [](HGDIOBJ h) { if (h) DeleteObject(h); }; std::unique_ptrHGDIOBJ, decltype(gdiDel) pen( (HGDIOBJ)CreatePen(PS_SOLID, 1, RGB(0,0,0)), gdiDel);这里有个细节当使用默认删除器时unique_ptr的大小和裸指针一样零开销。可一旦用了自定义删除器如果删除器是无状态的比如 lambdaunique_ptr仍然保持裸指针大小如果删除器是有状态的才会增加额外的存储开销。这个特性叫 empty base optimization / 压缩空成员优化。理解这一点你在性能敏感的代码里用自定义删除器时才心里有底。2.3 数组和make_uniqueunique_ptrT[]专门用于管理动态数组会在析构时调用delete[]而不是delete。这一点如果你手动管理很容易写错new int[10]必须配delete[] intPtr。有了unique_ptrT[]就不需要再担心这个。std::unique_ptrint[] arr std::make_uniqueint[](10);关于make_uniqueC14 才加入标准库。它的价值在于只使用裸new时的异常安全问题。考虑这种情况foo(std::unique_ptrT(new T), std::unique_ptrU(new U))。如果T构造成功而U构造抛出异常C 标准没有保证第一个已经构造的unique_ptr一定先于异常被处理在 C17 之前子表达式的求值顺序未指定。用make_unique就不会有这个隐患因为每个对象都在独立的表达式内被创建并立即被智能指针接管。所以结论极简单能用make_unique就别用new unique_ptr。3. shared_ptr 控制块理解引用计数才有资格谈优化3.1 引用计数怎么工作unique_ptr解决的是一个人的资源但现实里总有多个模块需要共享同一个对象——比如多个观察者订阅同一个数据源。这时候需要用shared_ptr它允许多个指针共享一个对象并且保证最后一个持有者析构时才会真正释放资源。这个最后一个持有者释放的机制是引用计数。每个shared_ptr指向对象时控制块里维护一个use_count强引用计数。每当一个新的shared_ptr接管这个对象计数加一每当一个shared_ptr析构或重置计数减一计数降到零对象被销毁控制块会被进一步处理弱引用计数也为零时才整体释放。std::shared_ptrData a std::make_sharedData(); std::shared_ptrData b a; // use_count 2 { std::shared_ptrData c a; // use_count 3 } // c 析构use_count 2 b.reset(); // use_count 1 // a 最后一个持有者析构时 Data 被释放这里有个关键点值得细品计数增减是原子操作所以同一个shared_ptr的多个拷贝在不同线程间是安全的。但引用计数安全不等于对象安全这个问题我放在后面专门讲。3.2 make_shared 的好处和坑对于shared_ptr我几乎无条件推荐std::make_shared原因有两个第一它只做一次内存分配。如果写std::shared_ptrT(new T)实际会发生两件事new T分配对象内存然后shared_ptr构造函数再分配一个控制块内存。而make_shared会在一块内存里放对象和控制块减少一次堆分配对性能有实际意义。第二异常安全。和unique_ptr同样的问题多个参数的构造顺序可能失控make_shared从源头避免了裸new的存在。但make_shared有一个副作用很多人不清楚由于对象和控制块是同一块内存只要控制块还在对象内存就不会被释放。控制块什么时候还在如果存在weak_ptr指向这个块控制块就会一直存活。于是可能出现一种情况明明所有shared_ptr都释放了weak_ptr还活着导致对象所在的整块内存包括对象本身迟迟不归还。这个影响在对象特别大、又长期挂着weak_ptr时会很明显。实测中如果对象很大且需要和weak_ptr长期共存可以考虑改用分离分配的方式// 自己构造删除器单独分配对象和控制块 class Data { public: char payload[1024 * 1024]; }; auto raw new Data(); std::shared_ptrData sp(raw, [](Data* p){ p-~Data(); ::operator delete(p); // 对象自定义分配/释放 });这是一个取舍问题。不是要你一律不用make_shared而是要知道它背后的内存布局以便在资源敏感场景做出选择。3.3 shared_ptr 的数组支持C17 之前shared_ptrT[]不能直接用make_sharedT[]。Boost 提供了自己的写法标准库在 C17 才支持shared_ptrT建立数组。到了 C17 之后这样写std::shared_ptrint[] sp std::make_sharedint[](10);这里的删除器会自动推断为delete[]不需要手动指定。如果用的是 C11/14只能这样std::shared_ptrint sp(new int[10], std::default_deleteint[]()); // 或者更简洁std::shared_ptrint sp(new int[10], [](int* p){ delete[] p; });这就是为什么很多老项目里能看到给shared_ptr手动塞删除器的代码——其实是语言能力不足时的替代方案。4. weak_ptr不做伪装成智能指针的裸指针4.1 循环引用最大的坑shared_ptr最经典的坑是循环引用。看这个例子struct Node { std::shared_ptrNode next; ~Node() { std::cout destroyed\n; } }; void makeCircle() { auto n1 std::make_sharedNode(); auto n2 std::make_sharedNode(); n1-next n2; n2-next n1; // 互相引用形成环 } // 离开作用域时 n1, n2 各自析构但 use_count 仍为 1资源不释放运行这段代码你会发现析构函数永远没有被调用。n1和n2互相持有对方各自的use_count都是 1谁都不会先归零。这就是内存泄漏而且比忘了写delete更难发现——因为它不是一个明显的遗漏而是一个相互纠缠的引用环。这个问题的根源在于shared_ptr的设计假设是对象图是有向无环的一旦出现环引用计数就失效了。环上任何一个节点都无法成为最后一个释放者。解决办法是weak_ptr——它不会增加对象的强引用计数。把上面的next改成std::weak_ptrNode环就断了struct Node { std::weak_ptrNode next; ~Node() { std::cout destroyed\n; } };这里n2被n1强引用而n1对n2只是弱引用。当离开作用域时n1和n2的强引用计数都能归零对象正常析构。4.2 weak_ptr 的正确姿态weak_ptr是一个观察者它不拥有资源只是想使用但不想负责释放。但是从它拿到资源的过程要小心。最直观的写法是std::weak_ptrData wp ...; if (auto sp wp.lock()) { // lock 返回 shared_ptr如果对象已释放则返回空 sp-doSomething(); }注意expired()的坑。wp.expired()可能返回 false对象还活着但等你拿到锁之前对象可能被别的线程释放了。所以正确做法是直接用lock()它在 尝试获取强引用 这一操作内保证原子性比先expired()再lock()更安全。另外weak_ptr并不完全等价于缓存裸指针。它比裸指针安全的地方在于当所有强引用都消失后它会变成 expired后续lock()会返回空指针你不会去访问已经释放的内存。裸指针做不到这一点——它永远不知道自己指向的对象已经没了。4.3 enable_shared_from_this从 this 安全获取 shared_ptr还有一个经常把人绕晕的场景类内部需要把自己传递给某个接受shared_ptr的函数。直接这样做是错误的class Foo { public: void register() { auto sp std::shared_ptrFoo(this); // 危险this 已经是某个 shared_ptr 管理的对象 // 相当于再创建第二个控制块两个独立控制块都会尝试释放 this } };这会带来双重重释放的灾难。正确做法是继承std::enable_shared_from_thisTclass Foo : public std::enable_shared_from_thisFoo { public: void register() { auto sp shared_from_this(); // 安全地返回一个与已有控制块共享的 shared_ptr } }; auto foo std::make_sharedFoo(); foo-register();原理是enable_shared_from_this内部持有一个weak_ptr在创建第一个shared_ptr时自动初始化。之后shared_from_this()通过这个weak_ptr去lock()拿到与现有shared_ptr同一控制块的强引用而不是另起炉灶。使用前提是这个对象确实已经被某个shared_ptr管理。如果你在栈上直接Foo f; f.register();shared_from_this()会抛bad_weak_ptr。这是个容易忽略的用法约束。5. 工程实践指针选型和实际遇到的坑5.1 选型决策表在我带的项目里新代码的指针使用策略基本就一张表场景推荐工具理由大多数对象所有权unique_ptr零额外开销语义清晰需要在函数间转移所有权unique_ptrstd::move明确表达所有权转移多个模块共享、没有清晰归属shared_ptr引用计数管理生命周期只观察/缓存不负责释放weak_ptr不延长生命周期避免循环引用不需要拥有资源和生命周期管理裸指针 / 引用不表达所有权只做访问这里我需要强调一个观念裸指针不是不能用。作为函数参数传递一个我只想读一下你的对象裸指针或引用仍然合理而且它比shared_ptr更轻。关键是别用来表达所有权。如果函数内部要把对象存下来或者转移所有权就别用裸指针。这个边界意识是最值钱的经验。5.2 shared_ptr 的线程安全边界这是面试和实际开发都很容易混淆的点。引用计数本身是线程安全的。多个shared_ptr可以安全地在不同线程里拷贝和析构因为计数器的加减是原子的。所以这样写没问题std::shared_ptrFoo global_sp; // 线程 Astd::shared_ptrFoo local global_sp; // 原子增加计数安全 // 线程 Bglobal_sp.reset(); // 原子减少计数安全但不代表对象本身线程安全。两个线程同时通过shared_ptr调用Foo的非 const 成员函数、修改同一个字段照样是数据竞争。引用计数解决的只是谁还活着的记账问题它不会替你把对象内部的并发问题包圆。想要多线程安全地访问同一份数据你仍需要互斥锁或者把对象设计成线程安全的。5.3 性能和额外开销的账不少人担心智能指针拖慢性能这里说一点实测感受。unique_ptr默认情况下的内存布局就是一个裸指针通常零开销。shared_ptr在两个地方有成本多一个控制块多一次内存分配以及相关字段计数器的原子操作。原子操作在单线程场景其实没那么贵真的是微秒级别。真正贵的是频繁无谓地拷贝同一个shared_ptr每次都触发原子增减。每次拷贝都去大批量创建和销毁shared_ptr的场景可以想想是否真的需要共享所有权。如果在高频路径里确实需要优先用shared_ptr按引用传递不拷贝、只读而不是传值。我之前优化过一个日志聚合模块里面有大量函数接受std::shared_ptrLogMsg按值传递光拷贝的开销占了 CPU 的 8%。改成 constshared_ptrLogMsg之后立刻降了下来。这不算大优化但值得有意识。5.4 常见误用记录列几个我 review 代码时反复看到的函数返回值直接造新对象返回 shared_ptr 的裸 new 替代 make_shared既有安全问题又浪费分配。在析构函数里给shared_ptr赋值可能引发连锁析构导致难以预料的循环析构。涉及删除器的执行顺序要格外小心。把this转成shared_ptr没有继承enable_shared_from_this就直接std::shared_ptrFoo(this)导致两套控制块同时接管同一个对象直接双 free。weak_ptr缓存裸指针并长期保存虽然weak_ptr::lock()能确保使用瞬间安全但如果你先wp.lock()拿到sp再在sp生命周期结束后用旧的裸指针去访问对象一样悬空。这一条本质上还是裸指针职责不清的问题。6. 面试和进阶从会用到能讲清楚智能指针几乎是 C 面试必考的内容。很多候选人会背unique_ptr、shared_ptr、weak_ptr的区别但我更希望听到他们能讲清楚背后的设计和取舍。这里列一些我实际问过的问题和参考答法。6.1 为什么需要智能指针能不能不用参考答法因为手动管理new/delete时异常安全、所有权归属、悬空指针三类问题很难保证。智能指针将生命周期管理交给 RAII 机制由编译器和语言规则在作用域退出时自动释放资源把错误从运行时转移到了编译期。但这不是银弹它只是把手动管理抽象成了所有权策略管理你仍然需要选对策略。6.2 shared_ptr 的内部实现要点参考答法shared_ptr有两个核心部分一个指向对象本身一个指向控制块。控制块包含强引用计数、弱引用计数和删除器。每次拷贝强引用计数加一析构减一减到零析构对象弱引用计数不为零时控制块不释放。引用计数的变化是原子操作因此多个shared_ptr的拷贝和析构可以跨线程安全但对象本身的访问需要额外同步。追问一个常见的为什么用make_shared能分别从内存分配次数和异常安全两个角度回答说明你真的写过。6.3 循环引用怎么产生、怎么解决参考答法当两个或多个shared_ptr互相引用时强引用计数永远无法归零对象无法析构。解决方式是把其中一个方向的持有改为weak_ptr它不增加强引用计数从而打破环。一个经典案例是父子节点父亲持有孩子的shared_ptr孩子持有父亲的weak_ptr。这样在析构时不会互相拖住。6.4 智能指针和裸指针可以混用吗参考答法可以但要明确边界。函数参数层面的借用用裸指针/引用所有权转移用unique_ptr共享所有权用shared_ptr。禁止用裸指针保存一个由智能指针管理的对象那个裸指针无法感知对象是否已释放必然产生悬空。还有一个小面试题常配weak_ptr::lock()和expired()的原子性问题。答案就是之前说的直接用lock()别先expired()再取。最后按我个人的实践经验说一句如果你是新手一开始别在shared_ptr上停留太久先用好unique_ptr和 RAII 思路顺手理解引用计数然后再啃weak_ptr和enable_shared_from_this。智能指针不是让你可以不管资源生命周期了而是让你更精确地表达你到底想怎么管。把所有权语义想清楚比记住每个 API 的拼写重要得多。