更新时间: 2026-08-27
前面几篇里我一直强调"创建智能指针用 make_unique / make_shared,别用 new"。这篇就把这个建议讲透:工厂函数到底好在哪?"异常安全"是怎么被 new 破坏的?make_shared 和 make_unique 又有什么差别,什么时候必须放弃工厂函数?
本文要回答:为什么 unique_ptr<Widget>(new Widget()) 是"坏味道"?工厂函数的两大优势是什么?以及什么时候必须用 new?
一、先看两种写法
#include <memory>
// 写法 A:new + 构造函数(能用,但有隐患)
std::unique_ptr<Widget> a(new Widget(1, 2));
// 写法 B:make_unique(推荐)
auto a = std::make_unique<Widget>(1, 2);
// shared_ptr 同理
auto b = std::make_shared<Widget>(1, 2);工厂函数的参数直接转发给 Widget 的构造函数——make_unique<Widget>(1, 2) 里的 (1, 2) 就是 Widget 构造函数的参数。写法更简洁,auto 自动推导类型,少敲一遍 Widget。
二、异常安全的坑:new 的经典翻车现场
写法 A 为什么危险?看这个函数:
void process(int x) {
// 假设 f() 和 g() 都可能抛异常
std::shared_ptr<Widget> sp(new Widget(f(), g()));
}C++ 里"实参求值"的顺序:new Widget(f(), g()) 要先把 f()、g() 的结果算出来,再构造 Widget,再把裸指针交给 shared_ptr。如果求值顺序是:
new分配内存f()抛出异常 →new的结果还没交给shared_ptr,裸指针泄漏!
在 C++17 之前,f() 和 g() 的求值顺序甚至没被标准规定(旧标准的未指定行为),有的编译器先算 g() 有的先算 f(),这个泄漏是"编译器相关"的——换个编译器行为就变。C++17 起求值顺序有了一些保证,但 new + 裸指针交接的窗口期问题依然存在。
make_shared 就完全没这个问题:
std::shared_ptr<Widget> sp = std::make_shared<Widget>(f(), g());
// f() 或 g() 抛异常 → 尚未创建任何对象,nothing to leak工厂函数把"分配 + 构造 + 交给智能指针"合并成一个原子操作,中间没有裸指针暴露的窗口,异常安全从根上保证。
三、性能:make_shared 少一次内存分配
shared_ptr 从 new 构造时,需要两次内存分配:一次给 Widget 对象,一次给引用计数控制块。
@startuml
left to right direction
skinparam nodeFontSize 13
skinparam backgroundColor #FFFFFF
rectangle "new 版本(两次分配)" as twice #FFD9D9 {
node "分配 1:Widget 对象" as w1 #FFD9D9
node "分配 2:控制块(计数)" as c1 #FFD9D9
}
rectangle "make_shared(一次分配)" as once #D9FFE2 {
node "分配 1:Widget + 控制块(连续内存)" as w2 #D9FFE2
}
twice -[hidden]down- once
@endumlmake_shared 把对象和控制块放进同一次分配(连续内存),省一次 malloc,还顺便提高了缓存局部性——对象和控制块挨着,访问计数时大概率命中缓存。make_unique 没有控制块,所以没有"省分配"一说,它纯粹是异常安全和语法简化的价值。
再补一个 make_shared 的副作用:因为对象和控制块在一起,只有所有 weak_ptr 也都销毁后,这块内存才整体释放。也就是说,存在 weak_ptr 时,make_shared 的对象内存可能"晚释放"一点(控制块在,对象内存就在)。绝大多数场景无感知,但如果你有超长生命周期的 weak_ptr 挂着一个巨大的对象,可以改用 shared_ptr<T>(new T(...)) 分离生命周期——这是"用 new 的合理理由"之一。
四、对比表:工厂函数 vs new
| 维度 | make_unique / make_shared | new + 构造函数 |
|---|---|---|
| 异常安全 | 原子,无泄漏窗口 | 参数求值期可能泄漏 |
| 语法 | 简洁,auto 推导 | 类型写两遍 |
| 分配次数 | make_shared 一次 | shared_ptr 两次 |
| 控制块位置 | 与对象连续 | 独立 |
| 自定义删除器 | 不支持 | 支持 |
| 对象存活期独立 | make_shared 弱指针延迟释放 | 可独立 |
五、什么时候必须用 new
工厂函数不是万能的,有几个场景绕不开 new:
1. 自定义删除器
// 自定义删除器只能配 new
std::shared_ptr<FILE> fp(fopen("a.txt", "r"), [](FILE* f){ fclose(f); });
// make_shared 没有自定义删除器的版本2. 需要裸指针参与构造(比如已有的 Widget* 要转成 shared_ptr)
3. 需要分离对象与控制块的生命周期(上面说的内存大对象 + 长命 weak_ptr 场景)
4. C++11 之前的编译器(make_unique 是 C++14 加的,make_shared 是 C++11 就有)——本站统一 C++17,这条不用操心。
标准库在 C++14 才加 std::make_unique(C++11 只有 make_shared)。本站按 C++17 教学,两个都能用。
六、与本站主线衔接
- 43/44 篇:unique_ptr、shared_ptr 的正确创建姿势都在这篇收口
- 42 篇(RAII):工厂函数是"构造即获取"的最干净形态
- 20 篇(拷贝与 Rule of Three):make_* 与拷贝语义的配合
- 46 篇之后,47 篇讲三者如何选型
七、一句话总结
make_unique/make_shared 用"分配 + 构造 + 交给智能指针"的原子化消灭了 new 的异常安全窗口,make_shared 还少一次内存分配;默认一律用工厂函数,只有自定义删除器、裸指针转智能指针、分离生命周期这三种情况才回退到 new。