
如果你去参加一次现代 C 的代码评审看到auto p new Foo()这行大概率会有人当场坐不住。这倒不是矫情——过去十几年make_shared、make_unique已经把裸new在绝大多数场景里挤出了舞台。但你要是因此认定new已经彻底没用了那下个项目里照样会撞上无处下手的尴尬。今天就把这个问题一次聊透make 系列凭什么叫板new以及哪些场景下new依旧是那个绕不开的答案。这篇文章适合所有写过new的 C 开发者。无论你是刚从 C 风格转型、正在看懂别人代码里的智能指针还是已经用make_unique写了一年多代码但说不清内部原理都值得往下看。我会尽量把原理、坑和选型逻辑揉在一起讲而不是只丢几条干巴巴的规则。1. new 到底错在哪这要从 RAII 和所有权说起1.1 裸 new 的三大硬伤先说个事实new本身并不是洪水猛兽。int* p new int(42);这行代码在 C 里完全正确也谈不上危险。真正危险的是人们在使用new之后的几十年里反复犯的几类错误。第一类是忘记delete。这几乎是每个 C 程序员的入门第一课惨痛记忆。代码写得多了分支一复杂总有那么一条路径绕过了delete于是一个对象就这么静默地活在堆上。单个对象看着无所谓可一旦出现在长期运行的服务进程里就是积少成多的内存膨胀。第二类是重复delete或者释放后继续使用。这类问题的调试难度远超内存泄漏因为它往往不在分配点附近崩溃而是等到堆结构被破坏、或者恰好有别的对象复用了那块内存之后才炸。真正定位起来需要 valgrind、ASan 轮番上阵成本极高。第三类是我认为最阴险的异常安全。new和delete之间夹杂了业务逻辑只要逻辑中抛出了异常释放动作就可能被跳过。很多老项目里所谓的内存泄漏根本不是忘了delete而是异常路径上的delete没有执行到。后面两点其实指向同一个本质裸new把堆对象的生命周期管理责任完全交给了人而人恰恰是最不可靠的组件。每次手动delete都是在赌赌自己记得住所有执行路径、赌异常不会来捣乱、赌未来维护这段代码的同事也能和你保持同样的纪律。这种赌局偶尔会赢但只要输一次代价就是一次线上事故。1.2 智能指针为什么能根治这个问题C 解决这类问题的核心思想叫 RAIIResource Acquisition Is Initialization资源获取即初始化。这个名词听起来唬人但翻译成大白话就一句话把资源的生死绑定到一个栈上对象的构造和析构上。栈对象的析构是编译器保证的无论中途抛出异常、提前 return还是正常走到函数末尾析构都会被调用。那么资源自然跟着释放。智能指针就是这个思路的具体产物std::unique_ptr表示独占所有权std::shared_ptr表示共享所有权 引用计数std::weak_ptr作为观察者介入而不增加生命周期绑定。你把new出来的指针丢进智能指针里剩下的事情它替你兜底。但故事到这儿还没完。有心人应该已经发现std::unique_ptrT(new T)这行代码里new依然存在。智能指针只解决指针已经进入智能指针管理之后的释放问题可如果new到unique_ptr之间出了岔子呢这个缝隙正是make_unique和make_shared要填上的核心缺口。2. make_unique很多人以为是性能优化其实是异常安全的补课2.1 一场由求值顺序引发的内存泄漏先看一个非常经典的反面教材void process(std::unique_ptrResource a, std::unique_ptrResource b); // 危险用法new 出来了但还没真正进入智能指针管理 process(std::unique_ptrResource(new Resource()), std::unique_ptrResource(new Resource()));在 C17 之前函数实参的求值顺序是未指定的。编译器可以先算第一个new Resource()再算第二个new Resource()也可以反过来甚至两个new交错执行。假如第一个new成功了、第二个new抛出了异常会发生什么第一个新创建的对象还只是一块裸内存因为它的地址还没来得及传给对应的unique_ptr构造函数。没有智能指针会替它释放于是它就这么凭空泄漏了。有人可能会说那我不用这种写法不就行了。问题是大型代码库里这种临时构造智能指针 背后藏裸 new的写法到处都是尤其是出现在构造函数初始化列表、函数参数拼接、容器插入等场景时肉眼根本防不过来。换成make_unique情况立刻不同process(std::make_uniqueResource(), std::make_uniqueResource());每个make_unique返回的临时对象都是完整的unique_ptr任何异常发生时已经构造好的临时对象都会以自己的析构函数收场。不需要你操心求值顺序也不需要你记得把它存到变量里。2.2 C14 才补上 make_unique 的原因这里有个很多人不知道的历史细节std::make_shared从 C11 就进了标准库但std::make_unique一直到 C14 才被正式加入。为什么标准委员会先做了难的、却漏了简单的原因挺现实C11 制定时make_unique被认为没啥存在感——unique_ptr本身没有引用计数、没有控制块unique_ptrT(new T)的语法也不算太痛苦所以一度被搁置。后来 Herb Sutter 等人在社区里反复呼吁大家才意识到make_unique根本不是性能优化它是异常安全和代码一致性的补课。于是 C14 把它补了进来。另外make_unique在性能上没有任何代价。它的实现本质就是一行转发构造templatetypename T, typename... Args std::unique_ptrT make_unique(Args... args) { return std::unique_ptrT(new T(std::forwardArgs(args)...)); }所以它和unique_ptrT(new T)在产物上是等价的。既然正确性更高、写法更短、风格更统一那就没有理由继续写new T。这也是为什么我总跟团队说先别讨论性能先把能白捡的安全拿到手。3. make_shared 的一次分配是优点也是坑3.1 内存布局对象和控制块的三种摆放方式shared_ptr要比unique_ptr复杂得多因为它除了管理对象本身还要管理一个控制块。控制块里存着强引用计数、弱引用计数可能还有删除器、分配器等信息。用shared_ptrT(new T)时通常发生两次内存分配第一次new T为对象本身分配内存。第二次控制块单独分配内存。用std::make_sharedT(args...)时编译器会把对象和控制块塞进同一次分配的内存里一次 malloc 搞定一切。带来的直接好处有两点。第一少一次内存分配整个创建和释放过程更快第二对象和控制块在内存中紧挨着访问相邻内存的缓存局部性更好这对频繁创建小shared_ptr的场景可能带来明显收益。但天下没有免费的午餐。一次分配意味着控制块的生命周期和对象内存的生命周期被硬绑在了一起。这个问题放在 3.2 小节重点说。3.2 强引用归零后大对象内存为什么还赖在堆上理解make_shared的隐坑之前先弄清楚引用计数归零的完整过程。shared_ptr的控制块里有两个计数强引用计数和弱引用计数。当强引用计数归零时对象会被析构但控制块本身还活着因为弱引用计数还没归零。弱引用归零后控制块才被释放。问题来了如果用make_shared分配对象和控制块在同一个内存块里。强引用归零、对象析构之后这块内存并不能归还给堆分配器因为控制块还在而控制块紧挨着对象。于是整个内存块包括对象占用的那一段就只能继续占着直到所有weak_ptr也失效。极端一点说一个 500MB 的大对象make_shared创建后在局部作用域里迅速使用完强引用归零但全局某个地方还留着一个weak_ptr。你原本以为大对象已经释放了实际它是析构了但空间没有还给内存池。如果这个weak_ptr一直活着这 500MB 就这么一直被扣在那里。我见过的一个真实场景是缓存服务全局mapstring, weak_ptrCacheEntry保留弱引用请求来了创建shared_ptr处理完释放强引用。条目本身几十 MB用make_shared创建后进程内存随着请求量一路涨。排查到最后问题不在业务泄漏而在上面这个机制。对策也很直接对这类对象极大 weak_ptr 长期存活的场景放弃make_shared回到shared_ptrT(new T)。这样对象和控制块分开分配强引用归零时对象内存立即归还控制块再小也不过几十字节留着无伤大雅。3.3 自定义删除器、分配器和 make_shared 的边界make_shared还有一个天生短板它不支持自定义删除器。因为make_shared内部只能用默认的delete来销毁对象你没法在调用时传入fclose、munmap或者其他释放逻辑。如果资源不是来自new而是来自fopen、CreateFile、mmap那么直接拿make_shared就成了一件不可能的事。这种时候要么用带删除器的shared_ptr构造要么用allocate_shared配合自定义分配器。我这里总结一个简单的对比表方便查阅特性make_sharedshared_ptr (new T)allocate_shared内存分配次数1 次2 次由分配器决定异常安全高需配合 RAII 使用高自定义删除器不支持支持不支持但可定制分配器对象极大且 weak_ptr 长期存在对象内存不释放对象内存在强引用归零后立即释放取决于分配器实现缓存局部性好一般取决于分配器实现记住这张表的最后一行的坑比记住make_shared的好处更值钱。4. 真正绕不开 new 的五个场景4.1 接管现有原始指针包括 this现实项目里有很大一部分指针不是你自己new出来的而是某个老接口、第三方库、C 风格回调交给你的。比如SomeObject* raw some_c_api_create(); if (!raw) { throw std::runtime_error(some_c_api_create failed); } std::shared_ptrSomeObject owner(raw, [](SomeObject* p) { some_c_api_destroy(p); });这里的owner接管了raw的所有权并在析构时调用对应的销毁函数。make_shared和make_unique无论如何都做不到这件事因为对象根本不由你创建。还有一种常见情形是在对象内部把自己交给外部管理。比如某些事件系统要求组件注册一个自指针class Listener : public std::enable_shared_from_thisListener { public: void registerSelf() { auto self shared_from_this(); // 依赖对象由 shared_ptr 管理 } };这里虽然没直接写new但它隐含的要求是对象必须先被一个shared_ptr管理shared_from_this才能工作。你可以用make_shared创建也可以shared_ptrT(new T)创建但永远不能用裸new之后直接撒手。这个场景想表达的是管理现有指针、管理 this是 make 系列替代不了的一类需求。4.2 自定义删除器万物皆可 RAIImake_unique和make_shared都只能管理用new创建并用delete销毁的资源。可现实中资源远不止内存。文件句柄、互斥锁、socket、mmap 区域甚至数据库连接都算资源。举个实际例子用智能指针管理一个 mmap 出来的共享内存映射#include sys/mman.h struct MmapDeleter { size_t length; void operator()(void* p) const { munmap(p, length); } }; size_t map_len 4096; void* addr mmap(nullptr, map_len, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); std::unique_ptrvoid, MmapDeleter mapped(addr, MmapDeleter{map_len});这段代码里mmap返回的是裸指针释放逻辑是munmapmake_unique完全无法表达。唯一合理的方式就是裸指针 自定义删除器。再比如管理fopen返回的FILE*std::shared_ptrFILE fp(fopen(data.txt, r), fclose);这种写法在很多老代码库中非常常见而且完全正确。不要为了现代化改造而强行改成make_shared因为make_shared根本没有这个能力。我们应该做的是承认并理解这个边界。4.3 大对象配合长期存活的 weak_ptr内存归还的硬需求这个场景在第 3 章已经剖析过原理这里再说说决策层面。如果对象的体积巨大并且它的weak_ptr会被某个长期存活的容器持有缓存、注册表、观察者列表那么用make_shared就会造成析构但不归还的隐性占用。此时直接std::shared_ptrBigObject sp(new BigObject(...));对象内存会在强引用归零后立即归还控制块单独留着等弱引用清空。内存条使用率难道不是越高越好感情上是但事实不是。长期运行的服务里这种多扣住不放的块一旦累积轻则内存监控告警重则被运维判定为泄漏迫使你加班排查。与其到时候怀疑人生不如一开始就按需选择。4.4 与 C API 和第三方库的交接任何用 C 写过插件、接入过 C 库的人都会对下面这种模式有强烈共鸣// C 风格事件循环用户数据指针是一个 void* void on_event(void* user_data) { auto* obj static_castMyClass*(user_data); // 处理完要求我们自己释放 delete obj; }在这种流程里对象起初就是通过new创建的因为要传给 C 接口当作void*使用。你不能在这个环节用make_unique也不能期待shared_ptr替你解决因为 C 那边只认指针不认智能指针。正确做法是创建时用new或者make_unique在传给 C 接口时release()掉所有权回调时再迅速用一个智能指针把指针包回来确保异常路径也能释放void on_event(void* user_data) { std::unique_ptrMyClass guard(static_castMyClass*(user_data)); guard-handle(); } // 正常或异常退出guard 都会析构并 delete这个模式里new是接口边界的硬通货智能指针是边界内部的安保系统两者互相配合谈不上谁替代谁。4.5 老标准代码与重载了 operator new 的类型最后说两件容易被忽略的边角场景。第一老代码。std::make_unique是 C14 才有的如果你的项目还停留在 C11 标准某些嵌入式、车载、军工项目确实有这种约束那么代码里大量std::unique_ptrT(new T)不是坏味道而是一种不得已的正确写法。改造之前先确认标准版本别看到new就条件反射式地要换成make_unique。第二重载了operator new/operator delete的类型。make_unique内部调用的还是new T所以大部分情况下它依然会进入你重载的分配函数。但如果你需要的是 placement new即在已经分配好的内存地址上构造对象那问题就彻底不同了void* arena get_shared_buffer(); T* p new (arena) T(args...); // placement new不分配内存只构造这种构造动作是make_shared/make_unique永远表达不了的。释放时也绝不能直接delete p而要手动调用析构函数或配合自定义删除器。这个场景虽然少见但一旦遇到你对new必须消亡的执念反而会变成 bug 的温床。5. 生产环境里的选型策略我的默认规则和踩坑记录5.1 一条可以抄走的决策流程我给自己定过一条规则也用这条规则做过不少评审收益一直很稳定。现在把它开放出来// 伪代码式的决策思路 if (需要独占所有权) { use make_unique; // 默认 if (需要自定义删除器 || 接管已有指针) use unique_ptr(ptr, deleter); } else if (需要共享所有权) { use make_shared; // 默认 if (对象极大 weak_ptr 长期存在) use shared_ptr(new T); if (需要自定义删除器) use shared_ptr(ptr, deleter); } else { // 纯观察不持所有权 use weak_ptr / raw pointer; } // 手动 delete 只允许出现在指针刚从 C 接口/回调里拿出来的一瞬间这套流程的核心是默认走 make 系列破例必须给出理由。破例不是坏事但破例最好写在注释里。我见过太多用shared_ptrT(new T)只是因为当初随手写的代码后面维护的人也不知道该不该改成make_shared。一旦你把理由写清楚后续改造就有依据了。5.2 坑一make_shared 的大对象内存迟迟不归还前文已经把这个坑的原理讲透了这里补充一下排查经历。当时一个后台服务的内存曲线持续爬坡用 valgrind 和 ASan 都查不出传统泄漏。后来我打印了weak_ptr::use_count()发现大量weak_ptr虽然对应的强引用已经是 0但lock()还能返回不lock()返回的是空指针这没问题。真正的问题出在/proc/pid/smaps里堆内存居高不下。后来在代码里 grep 了一遍所有make_shared的位置其中一个频繁创建大CacheEntry的调用让我起了疑心。试改成shared_ptrCacheEntry(new CacheEntry(...))之后内存曲线肉眼可见地稳了。这个案例之所以印象深刻是因为它提醒我没有内存泄漏和内存使用合理是两件事。make_shared 没有泄漏只是延迟归还但延迟可以看出 bug 的错觉。5.3 坑二在异常路径里裸 new 的连锁反应还有一次踩坑是在一个音频处理模块里。代码大致长这样void process(Data d) { auto p new GraphNode(d.input); try { runPipeline(p); } catch (...) { delete p; throw; } delete p; }看到try/catch里补了一个delete这位同事觉得自己已经考虑周全了。实则不然。后来runPipeline内部在某个地方把p地址传给了无锁队列异步线程拿着这个指针继续跑主线程却已经delete了。局面瞬间变成典型的 use-after-free而且极其难查。如果当初直接用std::shared_ptrGraphNode管理异步线程哪怕延迟消费强引用计数也会保证对象还活着。这种问题不是非得make_shared才能解决但它说明当你有任何跨线程、跨生命周期传递的需求时裸new 手动delete的本质缺陷就会暴露。这不是使用姿势问题而是所有权模型的问题。5.4 一个简单的性能对比结论总有人会问make_shared 到底快多少或者make_unique 是不是比 new 慢。我的实测经验是make_unique和unique_ptrT(new T)基本没有性能差距不要在两者之间纠结性能。make_shared相比shared_ptrT(new T)由于少一次分配通常有明显优势尤其在频繁创建/销毁小型共享对象时。不同平台的 malloc 实现不同收益也不同但方向几乎是确定的。真正影响大的是分配次数和内存局部性而不是函数是不是魔法。如果性能敏感更好的做法是直接上内存池、对象池而不是纠结new和make_shared的语法差异。所以在绝大多数业务代码里基于性能选make_shared是个红利但不是核心理由。核心理由从来都是安全、一致、省心。6. 新标准正在给这个问题添答案for_overwrite 与分配器版本6.1 make_unique_for_overwrite省掉一次不必要的清零C20 引入了make_unique_for_overwrite和make_shared_for_overwrite。它们和普通 make 系列的区别只在初始化语义上普通版本会对T做值初始化内建类型会被清零overwrite 版本只做默认初始化也就是对于 POD 类型不保证清零。很多人会觉得这有什么意义意义很大。一些场景下你立刻就要往缓冲区填满数据比如读文件、收网络包、解码图像前面的清零动作纯属白做// 传统写法先清零再被覆盖 auto buf std::make_uniquestd::byte[](1024 * 1024); // C20 写法跳过清零直接用后续数据覆盖 auto buf std::make_unique_for_overwritestd::byte[](1024 * 1024);性能敏感路径上这种优化实打实。但请注意for_overwrite不解决自定义删除器的问题它只是在初始化语义上多了一个选项。所以 new 的边界并没有因此缩小多少只是堆上创建这件事本身被打磨得更精细了。6.2 allocate_shared / allocate_unique进入特定内存池另一个值得关注的方向是allocate_shared。它允许你传入一个分配器让对象和控制块都从指定内存池里分配而不是走全局堆。std::pmr::monotonic_buffer_resource pool; std::pmr::polymorphic_allocatorstd::byte alloc(pool); auto sp std::allocate_sharedSomeHeavyObject(alloc, /* args */);这在高性能服务器、游戏引擎、低延迟交易系统里很常见对象不直接使用malloc而是从线程局部内存池分配避免锁竞争和堆碎片。注意标准库没有allocate_unique但有allocate_shared依赖的完整机制。真需要独占 分配器时往往要自己小封装一下或者直接用unique_ptr 自定义删除器。这个方向本质上是把如何分配内存这件事从new上剥离出来交给更灵活的策略。但无论怎么灵活只要你想让某个对象由智能指针管理而它的创建方式又绝非默认delete能释放new的那一口边界就永远存在。6.3 我的最终体会新标准确实一直在加新工具for_overwrite、allocate_shared、polymorphic_allocator都在把创建对象这件事做得更细、更安全、更可定制。但你要是问new会不会被彻底替代我的答案是不会也不应该被彻底替代。new会从一个默认的创建方式收敛成一个隐式的基础设施make 系列最终也会调用它接管旧指针时依然绕不开它自定义分配器和删除器也建立在理解它的基础之上。真正重要的不是用不用new而是用new的意图和边界是否清晰。就我个人而言写新代码时默认make_unique/make_shared碰到老接口、自定义删除器、大对象 长期弱引用这些场景时我会安静地写回new并且加一行注释注明为什么这里不能改用 make。这种先默认、再破例、注释说清理由的秩序感比死记任何规则都更可靠。你如果正在被make_shared和new的选择困扰不妨也按这个思路做把安全交给机制把判断留给自己。