C++智能指针实战:unique_ptr、shared_ptr、weak_ptr用法与性能取舍

发布时间:2026/9/13 3:46:20
C++智能指针实战:unique_ptr、shared_ptr、weak_ptr用法与性能取舍 我接手过一个让人头大的老项目一个跑了几年的后台服务内存越吃越多每隔两三天就得重启一次。用工具一查十几个地方发生了泄漏有的指针被拷贝得满天飞谁也不知道该在哪儿delete。那时候我最想做的一件事就是把所有裸指针全部换掉。那段时间我系统整理过C智能指针unique_ptr、shared_ptr、weak_ptr的用法和底层原理后来发现大部分人对智能指针的误解和误用远远比想象中多。这篇东西我不打算写成API文档而是想把“为什么这么用”“哪里容易翻车”“性能到底损失多少”这些真实经验讲透。不管你是刚学C的新手还是准备面试的应届生或者正在接盘老项目的社畜这篇都能给你一些参考。1. 智能指针到底解决了什么问题1.1 裸指针的三宗罪裸指针是C里最锋利也最容易伤到自己的工具。第一宗罪是内存泄漏new出来的对象一旦后续分支提前return或者中间抛了异常delete那行代码根本执行不到内存就成了孤儿。第二宗罪是悬垂指针一个对象被delete之后其他地方还握着指向它的指针再用就是未定义行为表现可能是随机崩溃也可能是数据被悄无声息地改坏。第三宗罪是双重释放多个地方都认为“该我来delete”结果同一个地址被释放两次堆管理器直接给你一个abort。有人会说我小心一点不就行了。问题是人总会大意项目一大光靠纪律解决不了系统性问题。智能指针的思路很朴素把“释放”这个脏活交给一个对象来管理这个对象在生命周期结束时自动干活不需要你人工记住delete。1.2 RAII智能指针的设计地基智能指针不是从天而降的发明它的根在C的RAII技术里。RAII全称是Resource Acquisition Is Initialization翻译成人话就是资源在构造时获取在析构时释放。用栈对象的生命周期来绑定资源的生命周期栈对象一销毁析构函数必然执行资源必然被释放。这就把“手动释放”变成了“按生命期自动释放”异常安全也跟着解决了——栈展开时析构函数一样会被调用。智能指针就是RAII思想在内存管理上的标准化落地。unique_ptr、shared_ptr、weak_ptr这三个类内部各自维护不同的所有权语义但共同点是它们都是栈上的对象都利用析构函数来释放资源。理解了RAII你就理解了智能指针为什么能解决裸指针的三宗罪——不是靠魔法而是靠编译器保证的析构时机。1.3 三个成员的分工概览unique_ptr独占所有权资源只有一个拥有者。拷贝被禁止但可以move移动把所有权转移给别人。零额外开销和裸指针大小一样。shared_ptr共享所有权引用计数记录有多少个shared_ptr指向同一个对象。计数降到0时析构对象并释放内存。代价是控制块和原子操作性能比裸指针慢。weak_ptr不拥有资源只“观察”资源。它不增加引用计数用来打破shared_ptr的循环引用或者表达“我只是看看别指着我续命”的语义。2. unique_ptr资源专属权与零成本抽象2.1 基础用法与所有权转移unique_ptr的设计哲学很明确一个对象在同一时刻只能有一个“主人”。拷贝构造函数和拷贝赋值运算符都被删除掉了你想复制一个unique_ptr编译器直接报错。但移动语义是允许的std::move可以把所有权从一个unique_ptr转移到另一个unique_ptr。一个容易让新手迷惑的点是unique_ptr在函数参数和返回值里怎么用。参数按值传递不行因为要拷贝比较规范的做法是传引用或者如果函数不需要接管所有权直接传裸指针也行。返回值则非常推荐直接返回unique_ptrC11之后有RVO和移动语义加持不会产生额外的拷贝开销。#include memory struct BigObject { int data[1024]; }; std::unique_ptrBigObject createObject() { return std::make_uniqueBigObject(); } int main() { auto obj createObject(); // 直接拿到所有权 auto another std::move(obj); // 所有权转移obj变为空 if (!obj) { // obj已经不再持有任何资源 } return 0; }这里必须提一个最重要的习惯创建unique_ptr时优先使用std::make_unique而不是new。make_unique有两个好处第一是代码更简洁第二是异常安全。在C17之前如果函数参数求值顺序导致new先执行、另一个参数抛出了异常就可能泄漏。make_unique把new和内存管理封装在函数内部彻底避开这种问题。2.2 动态数组与char*能不能转搜索引擎里有个问题出现频率特别高——用unique_ptr生成动态char数组能给char类型吗答案是可以但要分清楚“给”的含义。如果你是想让unique_ptr把数组管理起来同时需要把底层缓冲区传给一个C接口这个接口的参数是char那直接用get()方法就能拿到裸指针。auto buffer std::make_uniquechar[](1024); strcpy(buffer.get(), hello); // C接口示例processBuffer(buffer.get(), 1024);这里的核心问题是unique_ptr管理的是数组数组内部分量是char可以传给char*但只能用于读写这个数组的内容不能拿这个指针去delete。正确写法是unique_ptrchar[]也就是用了数组特化版本。如果你写的是unique_ptr 它只会释放一个char的空间在析构时调用delete而不是delete[]行为就是未定义的。还有一点值得注意unique_ptrT[]重载了operator[]所以访问元素要用buffer[i]而不是用*和-。取地址用get()不要用buffer[0]这种绕弯的方式虽然效果一样但代码意图不够清楚。2.3 自定义删除器与常踩的坑unique_ptr默认通过delete数组版delete[]释放资源但真实项目中管理的资源远不止new出来的对象。最常见的场景是文件和socketfopen返回FILE*关闭要用fclose而不是deletesocketfd也不是delete能处理的。这时就需要自定义删除器。std::unique_ptrFILE, decltype(fclose) filePtr(std::fopen(test.txt, r), fclose);这里有个极其经典的坑decltype(fclose)和原始函数指针类型是等价的写成decltype(fclose)完全没问题。但如果你图省事想用lambda捕获某些外部状态那么删除器类型就不能再简单地声明成函数指针而应该直接写成一个可调用对象。auto deleter [](FILE* f) { std::cout closing file std::endl; std::fclose(f); }; std::unique_ptrFILE, decltype(deleter) filePtr(std::fopen(a.txt, r), deleter);这种情况下unique_ptr的大小会发生变化。默认unique_ptr只保存一个指针自定义删除器如果带有lambda捕获有非静态数据成员那么unique_ptr对象本身也会变大。删除器如果是不捕获任何东西的空lambda则可以利用空基类优化EBO对象大小不变。这是在用自定义删除器时要记住的隐性成本。实操心得我一般只在跨C接口边界时才用自定义删除器。日常内存管理用make_unique就够别为了“看起来高级”引入lambda删除器过度设计反而让代码难读。3. shared_ptr与weak_ptr引用计数的爱与恨3.1 控制块与引用计数的真相shared_ptr的工作原理是每个被管理的对象旁边有一个控制块控制块里存着引用计数、弱引用计数、分配器、删除器等信息。每次拷贝shared_ptr引用计数加一每次析构引用计数减一。减到0时析构对象并释放内存控制块也跟着销毁。控制块是个容易被忽视的重点。shared_ptr的大小是裸指针的两倍为什么因为shared_ptr内部至少存着两个指针一个指向被管理的对象一个指向控制块。你用sizeof(std::shared_ptr )一看就知道了绝大多数平台上都是16字节。对照一下unique_ptr只占8字节。引用计数本身是原子的意即多个线程同时拷贝、析构同一个shared_ptr不会导致计数错乱。这里要明确这个保证仅限于引用计数本身。shared_ptr指向的对象如果被多个线程同时修改该加的锁还是得加。很多人一听到“shared_ptr线程安全”就以为可以直接跨线程共享数据了这是最危险的误解。3.2 make_shared的优化与代价创建shared_ptr同样推荐优先用std::make_shared。原因有两个一次性分配内存、异常安全。一次性分配是make_shared最大的性能优点——它把对象和控制块放在同一块内存里只做一次堆分配。常规写法是std::shared_ptrBigObject(new BigObject)会先new对象再分配控制块两次堆分配慢不说内存碎片也多。但make_shared有一个副作用经常被忽略因为对象和控制块在同一个内存块里对象必须等控制块销毁之后才能真正释放。控制块在什么时候销毁引用计数为0且弱引用计数也为0。这意味着哪怕所有shared_ptr都已经析构了只要还有一个weak_ptr存活这个shared_ptr对象的析构函数会被调用析构函数里的清理逻辑会执行但operator delete不会执行内存还挂在控制块上。对大部分场景来说这个延迟释放的代价可以忽略。但如果你的对象非常大而且系统里偏偏长时间留着weak_ptr那内存占用就会居高不下。这种“对象已经析构内存还没释放”的状态在很多内存敏感的项目里是要命的。你如果遇到这种场景就要回到传统写法用new单独分配让对象和控制在完全独立的生命周期。// 推荐性能和安全性都好 auto sp std::make_sharedBigObject(); // 当有long-lived weak_ptr时按需选择传统写法 std::shared_ptrBigObject sp2(new BigObject());3.3 循环引用问题与weak_ptr的正确用法shared_ptr最臭名昭著的问题就是循环引用。两个对象互相持有对方的shared_ptr导致引用计数永远不为0对象永远不会释放。经典的例子是链表或树的双向关系父子节点互相持有。struct Node { std::shared_ptrNode next; std::shared_ptrNode prev; int value; }; std::shared_ptrNode a std::make_sharedNode(); std::shared_ptrNode b std::make_sharedNode(); a-next b; b-prev a; // 出作用域时a和b内部的引用仍然互相牵扯内存泄漏破局的办法是引入weak_ptr。weak_ptr也指向同一个控制块但不增加引用计数。它的作用是“观察”对象是否还活着。判断是否还活着用expired()想要真正安全地访问对象调用lock()。lock()会尝试从weak_ptr创建一个shared_ptr如果对象已经被释放lock()返回一个空的shared_ptr否则引用计数加一这个shared_ptr在访问期间保证对象不会被释放。struct Node { std::shared_ptrNode next; std::weak_ptrNode prev; // 用weak_ptr打破循环 int value; };我把weak_ptr看作“不续命的引用”。它不参与对象的生命周期决策只负责让使用者知道“这个东西还在不在”。除了打破循环引用weak_ptr还有一个很好用的场景缓存。比如资源管理器里缓存了一批加载过的对象缓存本身用unordered_map存weak_ptr外部使用者持有shared_ptr。当外部没人使用时缓存不会因为自己存着指针而让对象活着最自然地把“弱缓存”语义表达出来了。4. 工程实战中的边界问题与性能实测4.1 不能被智能指针覆盖的场景先泼一盆冷水智能指针不是万能的有几个场景必须要用裸指针。第一非拥有关系。一个类方法需要调用一个子对象的方法但这个类完全不负责子对象的生命周期子对象是由外部传入并管理的。这种情况下传裸指针或者在类里存裸指针都完全合理。这不是“坏味道”而是正确表达了语义。第二栈上对象。绝对不要对一个栈对象做std::shared_ptrObj(obj)。一旦函数结束栈对象自动析构而智能指针的析构函数还会尝试再次释放等于double free。第三个场景值得专门讲this指针不能直接包装成shared_ptr。如果某个类想让外部通过shared_ptr来管理自己内部又把this返回出去包成shared_ptr会导致控制块不一致最轻是引用计数错误最重是双重释放。正确的做法是继承std::enable_shared_from_this然后用shared_from_this()获取对应的shared_ptr。#include memory class Task : public std::enable_shared_from_thisTask { public: void schedule() { // 从内部安全地获取指向自己的shared_ptr std::shared_ptrTask self shared_from_this(); // 使用self…… } };注意shared_from_this()只有在对象已经被某个shared_ptr管理时才可以使用。如果对象是栈上实例或者new出来的裸指针调用它会抛std::bad_weak_ptr。这也是不少人在实际项目中踩到过的坑。4.2 接口边界上的裸指针与智能指针交接老项目里最常见的痛点是智能指针和C风格API之间的转换。一个背景C库的函数接受char或FILE你手里只有unique_ptr。这种场景在搜索引擎里高频出现热搜词里那一句“c 用unique_ptr智能指针生成 动态char数组能用char*类型吗”其实碰到的就是这个问题。答案可以分成三层来理解。第一访问型需要C接口需要读缓冲区内容比如write(fd, buf, len)。这时用get()拿裸指针传进去调用结束后屏蔽。唯一的要求是在调用过程中不能析构unique_ptr否则裸指针会悬垂。显然正常函数调用内不会出这种事。第二接管型需求C接口内部会保存这个指针之后由它负责释放。这种情形在C库中比较少见如果碰到你应该显式地release()把所有权交出去。release之后unique_ptr不再拥有资源不会在析构时释放。第三受让型需求C接口返回了一个堆指针由你来释放。这种情况C接口功能一般会在文档中提到“caller must free”。拿到手之后用reset或直接用unique_ptr构造函数接管就好// 假设C接口返回一个malloc分配的缓冲区 unique_ptrchar, decltype(std::free) buf(static_castchar*(c_api_get_bytes()), std::free);实操心得跨语言或跨C接口边界一定要在代码注释里写清楚“谁负责释放”。这是所有内存问题的根源比选哪个智能指针重要得多。我见过太多拿shared_ptr管理malloc内存导致崩溃的例子问题不在于智能指针而在于释放函数一开始就选错了。4.3 性能与安全之间的取舍关于智能指针性能的迷思我用一个简单场景测过在开O2优化、循环一亿次拷贝shared_ptr的测试里shared_ptr的拷贝比裸指针拷贝慢大概两倍到四倍。原因是每次拷贝都需要在原子变量上做自增多线程下原子操作可能会触发缓存同步和总线争用开销不稳定。unique_ptr则完全零开销——只要不做自定义删除器它的操作就是裸指针操作连及性能损耗都没有。但性能排序不能直接转换成“能不用智能指针就不用”的结论。实际工程中大部分对象的创建和销毁频率远远没有拷贝指针的频率高。一个函数调用里拷贝几次shared_ptr对整体性能的影响微乎其微。真正要担心性能的是高频创建销毁的场景例如每秒创建销毁几十万个轻量对象那么make_shared的一次性内存分配反而帮你省了一次堆分配的开销。再量化一下一次堆分配大概几十纳秒到几百纳秒原子操作自增自减大概十几纳秒到几十纳秒。而一次系统调用级别的函数调用都不止这个量级。所以智能指针的损耗归根结底是“纳秒级别”的在绝大多数业务代码里根本不需要优化。如果你真的在热路径里测出了智能指针瓶颈那就值得检查一下是不是在循环里反复通过shared_ptr传递大对象而不是一上来就把智能指针判死刑。5. 高频面试题与问题排查速查5.1 面试官最爱问的几个问题C面试里智能指针几乎是必考的环节。我整理了几个高频问题顺便给出我认为合适的回答思路。Q1unique_ptr能不能拷贝为什么不直接设计成可拷贝的不能拷贝拷贝会导致两个unique_ptr同时管理同一个对象析构时double free。设计上删除拷贝构造函数和拷贝赋值运算符但允许移动。移动把所有权转移后源对象变为空所有权语义就清晰了。Q2shared_ptr怎么实现线程安全的引用计数引用计数使用原子操作。C标准库保证shared_ptr的引用计数操作是原子的但被管理的对象本身不自动线程安全。多线程共享同一个shared_ptr时如果涉及写操作还是要加锁。Q3循环引用怎么解决用weak_ptr。在一个方向上用weak_ptr打破环或者重构设计让父子关系的生命周期明确。原则是能不用shared_ptr建立“互相拥有”的关系就尽量不用。Q4make_shared和new的区别。make_shared只做一次内存分配对象和控制块放在一起性能好且异常安全。但make_shared创建的对象被shared_ptr或weak_ptr共同持有的控制块延迟释放若对象很大且weak_ptr长期存活会导致内存迟迟不归还给系统。这些问题的核心目的不是考你背没背过定义而是确认你是否真的理解了所有权语义。面试时最好的状态是先回答结论然后说清楚底层的控制块和引用计数发生了什么变化。一上来就背概念很容易被追问到底层细节后卡壳。5.2 智能指针泄漏排查思路有时候代码里明明用了shared_ptr内存占用还是一路攀升。排查思路值得单独写一段。首先要区分“内存泄漏”和“对象没有及时释放”。前者是资源从来没被回收过后者是指针还活着但已经不再被需要。shared_ptr出现类似泄漏的表现十有八九是循环引用或者全局缓存持有过多shared_ptr。排查工具用Valgrind的massif看内存曲线用ASANAddressSanitizer抓堆泄漏这两件套能解决绝大多数问题。其次可以在代码里临时打印use_count()。在析构函数里加一行日志观察对象到底什么时候被释放。如果析构函数一直没被调用就说明有引用没释放干净。再配合日志定位到持有时刻基本能找到问题点。实操心得遇到shared_ptr泄漏我通常先搜索代码里的“this”相关shared_ptr构造比如std::shared_ptrFoo(this)。这种情况十有八九有问题改用enable_shared_from_this才是正解。第二个常用搜索点是“-shared_from_this()”这个函数在栈对象上调用也会出问题要多留意。5.3 新老项目中的迁移建议如果是维护老项目想逐步引入智能指针我的建议是不要一次性“大清洗”。分批迁移每批只解决一类问题。顺序一般是先处理明确的泄漏点把裸指针改成unique_ptr再处理跨模块传出的裸指针改成unique_ptr或shared_ptr最后才处理复杂的对象图和循环引用这种工作留给弱相关的设计重构。老项目里很容易遇到一种情况函数接收裸指针参数但这个裸指针的生命周期其实由调用方管理。改用一个params对象传unique_ptr会牵动一大片调用代码。这种场景我建议参数保持裸指针但在注释里写明生命周期协议。用更克制的方式使用智能指针比到处滥用强得多。迁移时还有个小技巧把编译选项里的-Wdelete-non-virtual-dtor打开它可以提示你可能存在的错误delete非虚析构基类指针的问题。再利用-Wdeprecated-copy等警告把隐患提前暴露出来。智能指针不是绝对的“银弹”但配合良好的代码规范它可以大大减少你和内存问题搏斗的时间。我在实际项目中得到的最大体会是智能指针解决的是“所有权归属”问题而不是“指针”问题。想清楚每个对象的生命周期由谁负责比背下三种指针的全部方法都要重要。最后分享一个小技巧我写代码时会给每个构造出的智能指针起一个能表明语义的名字比如owner、observer、cacheEntry。这样过两个月再回来看不需要读完整段逻辑光看名字就能弄清楚它到底是拥有这个对象还是仅仅在看热闹。这个习惯比任何工具都省心。