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_ptr | std::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 都不支持自定义删除器,需要时只能手写构造函数。