资讯详情

资讯详情

C++智能指针全解析:从RAII原理到unique_ptr/shared_ptr选型与实战避坑

1. 智能指针到底在解决什么问题一次线上OOM的血泪复盘1.1 裸指针的两宗罪内存泄漏与悬垂引用先讲个真事。有一年我在维护一个运行了四五年的后台服务内存曲线像定时的潮汐白天缓涨、晚上大促时直接冲到OOM。一开始我怀疑是某个第三方库的缓存没清理后来把所有可疑缓存都关了内存还是涨。用valgrind跑一轮报告里密密麻麻全是“definitely lost”翻了几页发现大部分泄漏集中在同一个小模块原因只有一个某段代码里new出来的对象在几个分支里没有被delete而且因为有异常路径其中一条分支直接提前 return 了于是对象就成了孤儿。这是裸指针的第一宗罪泄漏。你可能觉得自己已经很小心了但代码只要复杂到一定程度——循环里有分支、分支里有提前返回、中间还夹着异常——纯靠人眼追踪每一条new/delete配对几乎是不可能完成的任务。人脑的栈深度是有限的而程序的控制流是无限的。第二宗罪比泄漏更隐蔽也更致命悬垂引用。简单说就是对象已经被delete了但还有指针指向这块已经释放的内存。内存被释放后不一定立刻被改写所以你的程序可能看起来“正常”运行很久然后在某个随机时刻崩溃崩溃的堆栈还特别难查因为它跟真正的问题代码可能隔了十万八千里。更阴险的是如果这块内存被另一个对象复用你对旧指针的写入会悄无声息地篡改别人的数据这种bug甚至会让你怀疑编译器的优化有毛病。我在实际项目里排过这种问题最后定位到的是一个存在了三年的老代码A模块持有B模块对象的裸指针B模块在某种配置下会提前析构A模块之后还在用。三年都没事是因为B对象的析构时机会触发那个bug的配置直到那一年才被打开。这两宗罪追根溯源都指向同一件事C让对象生命周期完全暴露给程序员而人类处理复杂状态的能力是有上限的。1.2 RAII智能指针背后的真正基石理解了要解决的问题再来看智能指针你就会发现它并不是什么黑魔法它只是RAII思想的一次完美落地。RAII全称是Resource Acquisition Is Initialization中文通常翻译成“资源获取即初始化”。这个名字起得挺绕我更喜欢把它理解成“用局部对象的析构函数来释放资源”。C有一个其他语言羡慕不来的特性局部对象在离开作用域时它的析构函数一定会被调用。无论你是正常走到花括号末尾还是中途return还是抛出异常导致栈展开析构函数都会一个不落地执行。这个保证是语言层面写死的比任何手动delete的纪律都可靠。智能指针的思路就是我把你的裸指针拿过来包在一个类里这个类的析构函数里负责delete这块内存。那么只要这个智能指针对象本身是一个局部对象它的析构就一定会执行于是被它包裹的内存也就一定会被释放。你不需要在每一个分支里手动写delete你只需要保证智能指针本身是按RAII方式创建和使用的。我经常用一句话跟刚入门的朋友解释裸指针是你自己管孩子你得时刻盯着他别摔着智能指针是你请了个保姆保姆的劳动合同就写了一条“到点必须交接死活都算你的”。这个“到点”就是作用域结束。你唯一要做的是确保保姆本人没丢也就是智能指针本身的生命周期是对的。1.3 异常安全很多人忽略的第二个隐藏价值除了防泄漏和防悬垂智能指针还有一个很多人没意识到的好处异常安全。传统的写法里如果你写了类似这样的代码void process() { Resource* r new Resource(); doSomething(); // 如果这里抛出异常 delete r; // 这一行就永远不会执行 }一旦doSomething()抛了异常r指向的内存就成了泄漏。你当然可以用try/catch把delete包进去但代码会变得很难看而且异常的种类和路径一多你根本写不全。智能指针不需要你操心这件事因为异常导致的栈展开也会触发析构函数。所以上面的代码只要换成void process() { std::unique_ptrResource r std::make_uniqueResource(); doSomething(); }不管doSomething()是正常返回还是抛异常r的析构都会执行。这一步可以说让C代码的健壮性提升了一个量级。我见过很多项目号称“不抛异常”但第三方库、系统调用、分配失败这些照样可能抛等到线上出问题才慌不如一开始就用智能指针把异常路径的泄漏问题堵死。2. 三种智能指针选型先搞清楚每种的定位与代价2.1 unique_ptr独占所有权零额外开销std::unique_ptr是C11引入的智能指针思路很直接这个指针独占它指向的对象不允许拷贝只能移动。这意味着同一时刻只有一个unique_ptr拥有这块资源所有权可以转移但不能复制。你可能会问不能拷贝是不是很麻烦恰恰相反这是它的优点。因为语义简单编译器能完全确定这块内存被谁管着所以unique_ptr的成本理论上和裸指针一模一样不增加任何额外内存也不增加运行时开销。它的使用场景非常广。工厂函数返回新对象、容器里存多态对象、类成员持有某个独占资源……凡是以前写“我new了一个对象所有权归我别人要用我借给你”的地方都可以换成unique_ptr。std::unique_ptrConfig loadConfig(const std::string path) { auto cfg std::make_uniqueConfig(); cfg-read(path); return cfg; // 移动语义所有权转移给调用方 }unique_ptr可以转成shared_ptr因为独占所有权天然可以升级为共享所有权反过来不行共享的所有权没法降级回独占。这个不对称关系在选型时很有用你写函数接口时可以放心声明参数为unique_ptr因为调用的地方如果需要共享持有它自己会先转成shared_ptr但反过来就尴尬了。在绝大多数项目里我的原则是默认用unique_ptr只有明确需要共享所有权时才换shared_ptr。很多人一上来就习惯打开IDE自动补全写shared_ptr其实先跑一段你会发现大部分资源根本不需要共享unique_ptr又轻又稳。2.2 shared_ptr共享所有权的便利与成本std::shared_ptr走的是一条更重的路。它允许多个智能指针共享同一个对象内部用引用计数来跟踪到底还有多少个shared_ptr活着。计数归零时最后一个shared_ptr负责析构对象并释放内存。便利是显而易见的你不用再花心思判断“谁最后离开谁负责delete”每个持有者都拿一份引用最后一个离开的自动善后。代价也随之而来每个shared_ptr内部除了一个指向对象的裸指针还有一个指向“控制块”的指针控制块里存着强引用计数、弱引用计数、删除器等元数据。也就是说一个shared_ptr的大小通常是一个裸指针的两倍并且读改写计数需要原子操作拷贝和析构的成本明显高于unique_ptr。std::shared_ptrLogger logger std::make_sharedLogger(); std::shared_ptrLogger backup logger; // 引用计数 1多线程环境下这个差异更明显。控制块里的计数是原子的意味着每次拷贝、析构都要走一遍原子的递增递减可能触发缓存行竞争。高并发热点路径上如果频繁拷贝shared_ptr性能开销是实打实的这时候我一般会建议改成const shared_ptr传参或者干脆用裸指针表示“借用、不拥有”。2.3 weak_ptr观察者模式与循环引用的解药std::weak_ptr是一个辅助性的指针它不参与对象的强引用计数也就是不影响对象的生命周期但可以观察到对象是否还活着。它指向的对象不能被直接访问你需要调用lock()来尝试获得一个shared_ptr如果对象已经被释放lock()返回一个空的shared_ptr。std::shared_ptrCache cache std::make_sharedCache(); std::weak_ptrCache observer cache; if (auto sp observer.lock()) { sp-hit(); } else { // 对象已经没了走替代逻辑 }weak_ptr最典型的两个用途第一是打断循环引用第二是实现观测/缓存场景比如一个全局的缓存表持有对象的弱引用对象还活着就直接拿对象死了就重新构建不用傻傻地一直持有一份强引用。2.4 选型对照表从语义出发而不是从习惯出发维度unique_ptrshared_ptrweak_ptr所有权独占共享不拥有大小同裸指针约两个裸指针约两个裸指针拷贝禁止仅移动计数1仅观察典型开销接近零原子计数访问需lock适用场景工厂返回、容器元素、类成员多个持有者共享生命周期缓存、打破循环引用这套选型逻辑我强调一点不要因为“顺手”选型号要从所有权语义出发。问问自己这个对象到底归谁管要不要共享生命周期的控制权如果只是借用一下传裸指针或引用就够了根本不需要智能指针。3. 把shared_ptr的底层拆开控制块、原子计数与性能3.1 控制块里到底存了什么很多人都知道shared_ptr有引用计数但问到“引用计数存在哪”就有些模糊了。答案是存在一个堆上分配的控制块里。整个shared_ptr由两部分组成一个指向用户对象的指针一个指向控制块的指针。控制块里至少包含三个东西强引用计数当前还有多少个shared_ptr指向这个对象弱引用计数当前还有多少个weak_ptr在观察它删除器销毁对象时具体调用哪个函数为什么弱引用计数也要记因为weak_ptr需要知道自己还有没有可能lock()成功。弱引用计数不为零说明至少还有观察者在控制块就不能释放弱引用计数归零且强引用计数归零时控制块也一起释放。有个细节很容易被忽略强引用计数归零时用户对象会被析构但控制块不一定会立刻释放它还要等弱引用计数也归零。也就是说即使你只剩一个weak_ptr对象内存已经释放了控制块的内存还会多活一阵。这个机制也解释了为什么shared_ptr不能直接从原始指针互相构造。如果两个独立的shared_ptr各自用同一个裸指针构造它们会各自创建独立的控制块各计各的数最后这个对象会被释放两次直接崩溃。所以永远不要对一块内存在两个地方分别用裸指针去构造shared_ptr。3.2 make_shared为什么更快以及它的代价std::make_sharedConfig()和std::shared_ptrConfig(new Config)的区别不只是写法好看。new Config加shared_ptr构造函数会导致两次内存分配一次给Config对象一次给控制块。make_shared则只做一次内存分配把对象和控制块放在同一块连续内存里。一次分配省下来的不只是一次malloc/free的开销还有内存局部性的收益。同一个对象和控制块在内存里挨得近CPU缓存命中率更高。在大量频繁创建和销毁shared_ptr的代码里这个差异能明显感知到。但天下没有免费的午餐。make_shared把对象和控制块合并分配之后对象内存要等控制块释放才能释放。前面说过只要有弱引用计数不为零控制块就不会释放。换句话说只要还有一个weak_ptr活着对象的内存即使已经析构了也没法还给系统。对于体积特别大的对象加上持有长时间weak_ptr的情况下这个“延迟归还”可能造成内存占用比预期高。这种情况我通常的做法是大对象且长期有弱引用观察时用shared_ptrT(new T)让对象和控制块分开分配析构之后对象内存能及时释放绝大多数中小对象无脑make_shared就行稳妥且高效。3.3 线程安全边界计数安全不代表数据安全这是一个被问烂但依然容易混淆的问题。shared_ptr的引用计数操作是原子的所以在多线程环境下拷贝、析构一个shared_ptr不会导致计数错乱控制块不会提前释放。这是“控制块级别的线程安全”。但这不代表被管理的对象本身是线程安全的。如果两个线程同时通过同一个shared_ptr去读写对象内部的字段那依然是数据竞争属于未定义行为。比如std::shared_ptrint sp std::make_sharedint(0); // 线程1: (*sp); // 线程2: (*sp);这两行各自都有读改写三步完全可能互相覆盖最终结果不是2而是1甚至更糟。解决的办法也简单加锁、用原子变量、或者干脆每个线程一份独立对象。还有一个容易踩的并发坑多个线程并发地“写同一个shared_ptr变量本身”也是不安全的。比如一个线程在reset()另一个线程在读这同样是数据竞争。正确做法是把shared_ptr放进std::atomicC20可用std::atomicstd::shared_ptrT或者对它的访问加锁。4. 实战中的高危雷区这些坑我都是填了又填4.1 循环引用不只是两个对象互指那么简单循环引用的教科书案例是两个类互相持有对方的shared_ptrstruct A { std::shared_ptrB b; }; struct B { std::shared_ptrA a; }; auto a std::make_sharedA(); auto b std::make_sharedB(); a-b b; b-a a;这里的引用计数形成了环A 的析构要等 B 的计数归零B 的析构要等 A 的计数归零两边都等对方先放手结果谁都放不了手。局部变量a、b离开作用域后对象仍然被环内的引用相互支撑永远泄漏。打破循环的办法是让环的某一条边变成弱引用。判断的依据是这条边所在的角色是否“拥有一条生命周期”如果B只是被A关联不能决定A活着还是死去那么A对B的引用通常应该是弱引用。把上面A中的b改成std::weak_ptrB环就断了外部a离开后A析构A持有的weak_ptr不增加B的计数B如果没有其他强引用自然随之析构。容易忽略的是循环不一定只有两个节点。A持有B、B持有C、C又持有A三个节点一样成环。排查这种问题的方法是画引用图凡是箭头首尾相接的路径都得想清楚哪条边应该改成弱引用。4.2 enable_shared_from_this为什么从this构造shared_ptr是UB在类内部如果想把自己的shared_ptr传给外部代码比如把自己注册进回调容器新手很容易写出这种代码std::shared_ptrMyClass self(this);这就是典型的错误。之前说过用裸指针构造shared_ptr会新建控制块。此时类可能已经有一个控制块了这里又造一个两套计数各管各的最终对象被析构两次未定义行为。正确做法是继承std::enable_shared_from_thisT然后调用shared_from_this()struct MyClass : std::enable_shared_from_thisMyClass { void registerMe() { auto self shared_from_this(); callbacks.push_back(self); } };这个机制的原理其实不神秘shared_ptr在创建对象时如果检测到对象继承了enable_shared_from_this就会把控制块里的一个weak_ptr关联到这个对象身上。之后调用shared_from_this()本质就是从内部那个weak_ptr做一次lock()拿到同一个控制块下的shared_ptr不会重复创建控制块。有几个限制必须记住。第一对象必须真的由一个shared_ptr管理才能调用shared_from_this()如果对象是栈上创建的内部那个weak_ptr是空的调用会出问题。第二不要在构造函数里调用shared_from_this()因为此时shared_ptr还没有完全构造成型内部关联还没建立。如果确实需要在构造阶段注册自己我会把逻辑拆成一个独立方法等对象构造完成后再调用。4.3 自定义删除器处理的不只是内存智能指针能管的资源远不止new/delete。文件句柄、socket描述符、锁、mmap映射这些都属于“必须在一定时机释放的资源”都可以交给智能指针。unique_ptr和shared_ptr都支持自定义删除器。比如用unique_ptr管FILE*auto fileDeleter [](FILE* f) { if (f) fclose(f); }; std::unique_ptrFILE, decltype(fileDeleter) file(fopen(a.txt, r), fileDeleter);注意unique_ptr的删除器是类型的一部分。如果你用一个无捕获的 lambda那么删除器对象没有实际状态unique_ptr仍能保持和裸指针一样的大小但如果删除器是函数指针或捕获了状态的 lambda整个unique_ptr的体积就会变大。shared_ptr则不同删除器类型被擦除了不参与指针类型shared_ptrFILE还是一个类型删除器信息只存在控制块里。这里有一条项目规范我会反复强调除非是接管外部资源否则不要在智能指针里传函数指针做删除器用无捕获lambda最干净既保持了类型清晰又不增加对象大小。4.4 get()的使用红线与管理原则get()返回裸指针相当于把对象暂时借给非智能指针的代码使用。但get()是一把双刃剑用不好就是刚才说的悬垂指针问题。红线之一get()返回的裸指针绝不能让另一个智能指针管理。std::shared_ptrT(ptr.get())这种写法是自寻死路两个独立控制块会Double Free。红线之二get()的生命周期边界要画清楚。比如你调用了一个C接口// c_api_register 内部会把这个指针保存下来一直不释放 c_api_register(sp.get());如果C接口在所有shared_ptr析构之后还持有这个裸指针那它就是在访问一块已经被释放的内存。正确的做法是评估C接口是否保存指针如果是你得保证接口撤销时对象一定还活着否则干脆别用get()。我自己在代码评审时定了一条简单规则get()只在同一个函数里、且明确知道对象在这段逻辑执行期间不会被释放时才允许出现。跨作用域保存get()的结果一律拒绝。4.5 不要把裸指针和智能指针混用的常见模式真正难管的是那些“既像裸指针、又像智能指针”的代码。常见的混用模式包括函数参数从裸指针改成智能指针时只改一半函数内部保留裸指针缓存回调里捕获裸指针而不是智能指针。这些混用会让所有权语义彻底糊涂间接导致悬垂和泄漏。我的经验是接口层面要先定清楚参数的所有权语义。如果函数只是借用对象、不持有它传裸指针或引用完全没问题如果函数会延长对象的生命周期那就必须传shared_ptr或unique_ptr。不要用“反正现在不会出事先传裸指针”的心态去写等对象生命周期变化时裸指针就变成定时炸弹。5. 从入门到精通我的学习路线、面试考点与工程约定5.1 按版本演进C11/14/17/20的使用差异如果你只是看网上老旧的教程容易停在C11的概念上。实际项目里我建议按这个梯度去掌握C11三种智能指针正式定型shared_ptr、weak_ptr、unique_ptr齐了。C14补上了std::make_unique。之前只能手动unique_ptrT(new T(...))有了它之后所有智能指针创建都应该走make系列。C17shared_ptr支持了数组版本可以shared_ptrint[]配合别名模板用起来更顺手。C20std::atomicstd::shared_ptrT成为标准库的一部分之前很多平台自己实现原子智能指针现在有了标准答案。入门阶段不需要追新先把C11的三种指针和make函数用熟就足够应付绝大多数日常开发。5.2 高频面试考点盘点从搜索热词里能看出智能指针是C面试的题库常客。我把常见的考点整理成清单每个都能展开讲十分钟unique_ptr为什么不能拷贝移动后原指针是什么状态shared_ptr的线程安全性到底保证到哪一层make_shared和直接new构造shared_ptr的区别与选择weak_ptr怎么解决循环引用expired()和lock()的区别如何正确从this获得shared_ptr自定义删除器如何影响unique_ptr和shared_ptr的类型与大小enable_shared_from_this的实现原理是什么面试时你一定得多说“为什么”而不是只背结论。比如问你unique_ptr能不能拷贝如果你能顺势讲出移动语义、所有权转移、以及为什么独占是零开销的前提这一题就答圆了。5.3 团队代码评审中的几条硬约定最后说点我从项目里沉淀出的几条约定说“硬”是因为它们都在Code Review里拦过真实事故第一默认unique_ptr。绝大多数资源的所有权是单一的不需要为“万一以后共享”的幻想提前付出shared_ptr的成本。如果后来确实需要共享再改成shared_ptr是一件很容易的事情反过来撤掉共享却很难。第二构造函数里不要做资源的所有权交接尤其是不要调用shared_from_this()必须保证对象已经完整构造。第三禁止在类公共接口里同时接受裸指针和智能指针的重载因为调用方无法确定所有权归属。要么明确“借用”要么明确“接管”。第四get()返回值不允许保存在任何可能跨越对象生命周期的位置。第五资源不是堆内存时一定要显式写删除器并优先用无捕获lambda。我在实际项目中最后沉淀下来的三句话是先想所有权再选型号任何时候优先make系列借用的生命周期边界必须画清楚。照着这三条写智能指针基本不会给你惹事。如果你自己写过崩溃案例回过头看多半是其中某一条被违反了。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →