news 2026/10/2 2:24:10

make_unique / make_shared vs 裸 new:三个理由与反直觉权衡

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
make_unique / make_shared vs 裸 new:三个理由与反直觉权衡

std::make_unique(C++14 起)和std::make_shared(C++11 起)几乎总是创建智能指针的首选。但它们到底好在哪、有没有代价、什么时候该反着来?这篇给三个硬理由,再补一个反直觉的权衡——没有银弹,得看场景。

1. 理由一:异常安全,最隐蔽的雷

unique_ptr的构造是「先拿到裸指针,再交给智能指针接管」。只要你把这两步拆开——哪怕只是拆成上下两行——中间任何一个可能抛异常的调用都会让裸指针彻底失去主人:

// mu_safe.cpp — 编译: g++ -std=c++17 -Wall -O2 mu_safe.cpp -o mu_safe#include<iostream>#include<memory>#include<stdexcept>structGadget{Gadget(){std::cout<<"Gadget()\n";}~Gadget(){std::cout<<"~Gadget()\n";}};voidmay_throw(){throwstd::runtime_error("boom");}voidleaky(){Gadget*raw=newGadget;// 反例,不要这么写:裸 new,此刻没人拥有它may_throw();// 抛出 -> raw 永远泄漏std::unique_ptr<Gadget>owner(raw);// 永远到不了这行}voidsafe(){autoowner=std::make_unique<Gadget>();// 分配与接管在同一个表达式里完成may_throw();// 抛了也没关系:owner 会在栈展开时析构}intmain(){try{leaky();}catch(conststd::exception&e){std::cout<<"leaky caught: "<<e.what()<<'\n';}try{safe();}catch(conststd::exception&e){std::cout<<"safe caught: "<<e.what()<<'\n';}return0;}
Gadget() leaky caught: boom Gadget() ~Gadget() safe caught: boom

对比两段输出:leaky()里Gadget()打印了、~Gadget()始终没出现——泄漏;safe()里Gadget()与~Gadget()成对出现,对象被正确回收。差别只在于make_unique把「分配」和「接管」压进了同一个表达式,中间没有留下能插入异常的缝。

顺便破一个常见的说法。有人写成一行:

// 片段(无 main,不校验):把 new 塞进实参列表voidf(std::unique_ptr<Gadget>p,inttag);f(std::unique_ptr<Gadget>(newGadget),may_throw());

C++17 收紧了规则:实参之间改为不确定顺序(indeterminately sequenced),各段实参作为一个整体求值、不再彼此穿插。规则变严了,但谁先谁后仍然没有规定——所以你该得出的结论不是「可以随便写」,而是「别把资源安全寄托在求值顺序上」。

官方文档:求值顺序(含函数实参)— cppreference · std::make_unique — cppreference

2. 理由二:类型名只写一次

这条最朴素也最实在。名字越长,重复就越像负担:

// 片段(无 main,不校验):同一个类型写两遍 vs 写一遍std::unique_ptr<VeryLongClassName>p(newVeryLongClassName(args));// T 出现两次autoq=std::make_unique<VeryLongClassName>(args);// T 只出现一次

写成两遍不只是啰嗦——两处类型一旦写得不一样,就是编译错误或者更糟的隐式转换,比如把T写成T*、把基类写成派生类。少写一处,就少一个错处。

3. 理由三:make_shared 只分配一次

shared_ptr<T>(new T)要两次堆分配:一次给对象T,一次给控制块(见《shared_ptr 完全指南》)。make_shared把两者放进同一块内存,只有一次。用全局operator new计数器实测:

// ms_alloc.cpp — 编译: g++ -std=c++17 -Wall -O2 ms_alloc.cpp -o ms_alloc#include<cstdlib>#include<iostream>#include<memory>intg_alloc=0;void*operatornew(std::size_t n){++g_alloc;returnstd::malloc(n);}voidoperatordelete(void*p)noexcept{std::free(p);}structBig{intdata[16];};intmain(){g_alloc=0;autop=std::make_shared<Big>();std::cout<<"make_shared allocations = "<<g_alloc<<'\n';g_alloc=0;std::shared_ptr<Big>q(newBig);std::cout<<"shared_ptr(new) allocations = "<<g_alloc<<'\n';return0;}
make_shared allocations = 1 shared_ptr(new) allocations = 2

一次分配除了省掉分配器开销,还换来更好的缓存局部性:对象和控制块紧挨着,第一次访问对象时控制块大概率已经在缓存里。

make_shared<T>(args):一次分配 堆 ┌───────────────────────────────────────┐ │ 控制块:强计数 / 弱计数 / 删除器… │ │ 对象 T(就地构造,紧跟控制块) │ └───────────────────────────────────────┘ ↑ 一次 operator new —— 两块相邻,缓存友好 shared_ptr<T>(new T):两次分配 堆 ┌────────────────────┐ ┌──────────────────┐ │ 控制块:强/弱计数… │ │ 对象 T │ └────────────────────┘ └──────────────────┘ ↑ 第 2 次 new ↑ 第 1 次 new(位置不保证相邻)

官方文档:std::make_shared — cppreference

4. 反直觉的另一面:weak_ptr 会把整块内存钉死

「只分配一次」不是无条件的优点。因为对象和控制块在同一块内存里,这块内存的释放时机由两者中较晚归零的那个决定:只要有weak_ptr还活着,控制块就不能释放,于是已经析构的对象内存也被一起拖着。下面这段能跑出「对象析构了、内存还占着」的效果:

// ms_weak_mem.cpp — 编译: g++ -std=c++17 -Wall -O2 ms_weak_mem.cpp -o ms_weak_mem#include<iostream>#include<memory>structBig{~Big(){std::cout<<"~Big() destroyed\n";}};intmain(){std::weak_ptr<Big>wk;{autos=std::make_shared<Big>();wk=s;// 弱引用 +1std::cout<<"strong+weak alive\n";}// 强引用归零 -> 对象析构,但整块内存被 weak_ptr 占着std::cout<<"after scope: expired? "<<wk.expired()<<'\n';autolocked=wk.lock();std::cout<<"lock returns empty? "<<(locked==nullptr)<<'\n';return0;}
strong+weak alive ~Big() destroyed after scope: expired? 1 lock returns empty? 1

~Big()已经打印了,说明对象逻辑上没了;但整块内存(含Big的那部分)要等wk也消失才能归还。对比shared_ptr<T>(new T)的两次分配:强引用一归零,对象那块内存立刻释放,只有控制块被wk钉住。

强计数归零之后: make_shared:一块内存,谁也跑不掉 shared_ptr(new T):两块,各回各家 ┌───────────────────────────┐ ┌──────────────────┐ │ 控制块(弱引用还活着) │ │ 控制块(还活着) │ ├───────────────────────────┤ └──────────────────┘ │ 已析构的对象内存 ← 钉死! │ ┌──────────────────┐ └───────────────────────────┘ │ 对象内存 ← 已归还 │ └──────────────────┘

所以「大对象 + 长寿命weak_ptr」是个真实的反例场景:比如缓存里长期存着weak_ptr观察一坨几 MB 的图片,用make_shared就会把这坨内存一起钉住——此时shared_ptr<T>(new T)反而更省内存。

维度make_shared<T>shared_ptr<T>(new T)
堆分配次数1(对象 + 控制块同块)2(对象、控制块各一块)
缓存局部性好(相邻)较差
有 weak_ptr 存活时整块(含对象)都释放不掉对象内存可立即释放,仅控制块被钉
大对象 + 长寿命 weak_ptr内存被长期钉死更省内存
异常安全安全(分配一次,无中间窗口)分配与接管分离,拆开就有风险

5. 两个 make 都不接自定义删除器

用unique_ptr管FILE*、管dlopen句柄时,你需要自定义删除器。这时会发现:make_unique没有删除器重载,make_shared也没有,只能手写一次裸指针接管:

// mu_deleter.cpp — 编译: g++ -std=c++17 -Wall -O2 mu_deleter.cpp -o mu_deleter#include<iostream>#include<memory>structFile{File(){std::cout<<"File opened\n";}~File(){std::cout<<"File closed\n";}};structFileCloser{// 无捕获 lambda 的等价物:空类型voidoperator()(File*p)const{std::cout<<"custom close\n";deletep;}};intmain(){// 标准库没有带删除器的 make 函数,这里只能手写 new;默认删除器场景请一律用 make_uniquestd::unique_ptr<File,FileCloser>a(newFile);std::cout<<"sizeof(empty deleter) = "<<sizeof(a)<<'\n';std::unique_ptr<File,void(*)(File*)>b(newFile,[](File*p){std::cout<<"fnptr close\n";deletep;});std::cout<<"sizeof(fnptr deleter) = "<<sizeof(b)<<'\n';return0;}
File opened sizeof(empty deleter) = 8 File opened sizeof(fnptr deleter) = 16 fnptr close File closed custom close File closed

注意最后两行sizeof:把删除器写成无状态类型(无捕获 lambda、或像FileCloser这样的空类)时,空基类优化让它不占空间,unique_ptr仍是 8 字节;一旦用函数指针当删除器,体积立刻翻倍成 16 字节。这是「用类型表达意图」比「用指针省事」更划算的典型案例。

官方文档:std::unique_ptr 的删除器与空基类优化 — cppreference · C++ Core Guidelines「R: Resource management」(R.22:用 make_shared 构造)

6. 选型速查表

场景推荐写法原因
独占所有权,默认删除器std::make_unique<T>(args)异常安全、类型只写一次
独占所有权,自定义删除器std::unique_ptr<T, D>(new T, d)标准库没有带删除器的 make
共享所有权,常规场景std::make_shared<T>(args)一次分配、缓存友好
共享所有权,大对象 + 长寿命weak_ptrstd::shared_ptr<T>(new T)避免对象内存被弱引用钉住
目标不是new出来的(工厂返回值、C API 指针)直接构造shared_ptr/unique_ptr并给删除器make 系列只能调new

最后一行值得单独记:make_shared内部就是new T,所以它没法接管别人的指针——要包装fopen的返回值或库返回的裸指针,还是得走构造函数。

7. 延伸阅读

  • std::make_shared — cppreference:单分配的实现说明,以及「弱引用导致内存延迟释放」这条实现注记。
  • std::make_unique — cppreference:为什么没有删除器重载,以及 C++14 才引入的原因。
  • std::shared_ptr — cppreference:控制块与两次分配的出处(见《shared_ptr 完全指南》)。
  • C++ Core Guidelines「R: Resource management」:R.22 / R.23 就是「优先make_shared/make_unique,别手写裸new」这两条。
  • Compiler Explorer(godbolt):想比较两种写法的实际汇编与分配调用,直接贴进去看。

8. 一句话总结

make_unique/make_shared胜在异常安全、类型名只写一次、make_shared只分配一次且缓存友好;但make_shared把对象和控制块合在一块内存,长寿命weak_ptr会连对象内存一起钉死——大对象 + 长weak_ptr场景反而该用shared_ptr<T>(new T)。另外两个 make 都不支持自定义删除器,需要时只能手写构造函数。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/2 2:23:44

GPUI Rust UI 框架上手:5 步搭出你的第一个 GPU 加速窗口

GPUI Rust UI 框架上手&#xff1a;5 步搭出你的第一个 GPU 加速窗口 【免费下载链接】zed Code at the speed of thought – Zed is a high-performance, multiplayer code editor from the creators of Atom and Tree-sitter. 项目地址: https://gitcode.com/GitHub_Trendi…

作者头像 李华
网站建设 2026/10/2 2:20:49

Python aggie-unterprise 包完全指南与常见错误

1. 引言aggie-unterprise 是一个面向 Python 开发者的实用工具包&#xff0c;专注于简化企业级应用开发中的常见任务。它提供了一系列封装良好的接口&#xff0c;帮助开发者快速完成数据聚合、配置管理、日志记录、任务调度等操作&#xff0c;从而减少重复代码&#xff0c;提升…

作者头像 李华
网站建设 2026/10/2 2:20:49

Python agg-abdurion 包完全指南与实战案例

1. 引言agg-abdurion 是一个面向 Python 数据处理场景的聚合分析工具包&#xff0c;专注于为开发者提供简洁、高效的数据聚合与分组计算能力。它建立在 Python 原生数据结构之上&#xff0c;通过统一的 API 设计&#xff0c;帮助开发者快速完成数据分组、聚合统计、窗口计算等常…

作者头像 李华