前言
std::make_shared是 C++11 引入的工厂函数,用来创建一个由std::shared_ptr管理的对象。它最常见的形象是「少写一个new」:std::shared_ptr<Widget> p(new Widget(...))可以简写成auto p = std::make_shared<Widget>(...)。但如果只把它当成语法糖,就会错过它真正重要的两个作用——把对象和控制块合并成一次分配,以及消除「裸指针在交给智能指针之前抛异常」的泄漏窗口。
围绕它的误解也不少。第一,以为make_shared支持自定义删除器——不支持,需要删除器时只能直接用shared_ptr的构造函数。第二,以为它总能替代shared_ptr(new T)——当构造函数是私有的、需要自定义分配器或删除器、或者要精确控制对象内存的释放时机时,它并不适用。第三,以为 C++11 就能写make_shared<T[]>(n)——数组版本是 C++20 才加入的。
本文按「基本用法 → 一次分配的实现模型 → 与直接构造的对比 → 典型场景 → 陷阱」的顺序整理,代码以 C++17 为基准,数组与 C++20 相关的内容会单独标注。文中不涉及任何运行时验证,行为描述均按标准语义和主流实现的通常做法给出;具体实现细节以所用标准库的文档为准。
一、基本用法与签名
C++11 起的声明形如:
// 适用于 C++17(标准库声明示意,真实定义在 <memory> 中)
template <class T, class... Args>
std::shared_ptr<T> make_shared(Args&&... args);它把参数完美转发给T的构造函数,返回一个shared_ptr<T>。类型T必须显式写出,因为参数类型无法反推出「要构造什么类」。几个常见写法:
// 适用于 C++17
#include <memory>
#include <string>
#include <utility>
#include <vector>
struct Widget {
Widget(int id, std::string name) : id(id), name(std::move(name)) {}
int id;
std::string name;
};
void demo() {
auto a = std::make_shared<int>(42); // 值初始化
auto b = std::make_shared<std::string>("hello"); // 字符串
auto c = std::make_shared<Widget>(7, "gizmo"); // 多参数构造
auto d = std::make_shared<std::vector<int>>(10, 0); // vector(数量, 值)
// 空参数:值初始化,内置类型得到 0
auto e = std::make_shared<int>();
(void)a; (void)b; (void)c; (void)d; (void)e;
}std::make_shared<T>()对内置类型做值初始化,T为类类型时调用默认构造函数;如果类没有默认构造函数,这个调用会编译失败。它也能避免「最令人烦恼的解析」(most vexing parse):std::shared_ptr<Widget> p(new Widget());与函数声明之间的歧义,在auto p = std::make_shared<Widget>();里不存在。
C++20 增加了数组支持,写法是std::make_shared<T[]>(n),其中元素被值初始化;也可以用std::make_shared<T[N]>()创建固定长度数组。C++17 及更早没有这个重载,创建数组需要std::shared_ptr<T[]>(new T[n])(shared_ptr<T[]>特化本身是 C++17 引入的),或者给shared_ptr<T>传自定义删除器。使用前请确认编译器的标准版本与库实现。
二、一次分配:make_shared 的实现模型
要理解make_shared的价值,得先知道shared_ptr由两部分组成:一个指向对象的指针,和一个控制块(control block)。控制块保存强引用计数、弱引用计数、删除器、分配器等。引用计数归零时销毁对象;弱引用计数也归零时销毁控制块。
std::shared_ptr<Widget> p(new Widget());通常发生两次分配:一次new Widget分配对象,一次shared_ptr的构造函数分配控制块。而std::make_shared<Widget>()的典型实现是:一次分配一块足够大的内存,前半部分放控制块,后半部分放对象,再用 placement new 在对象位置构造。标准并没有强制要求「必须一次分配」,但这是make_shared被设计出来的主要动机,也是主流标准库的通常做法。
这带来三个直接后果。
其一,分配次数减少。少一次分配器调用,通常也意味着更好的局部性:控制块和对象相邻,访问对象时控制块所在缓存行可能已经被载入。但不要把它说成「一定更快」——分配器实现、对象大小、缓存状态都会影响结果,需要自己测量。
其二,弱引用会延长整块内存的生命周期。这是最容易被忽略的一点。用两次分配时,强引用归零就销毁对象、释放对象内存,控制块可以独立地等到弱引用归零再释放;而合并分配时,对象和控制块在同一块内存里,只要还有weak_ptr活着,整块内存(包括对象曾经占用的空间)就不能释放。如果对象很大、weak_ptr又被长期保存(例如观察者列表),内存占用会明显高于预期。
其三,构造必须在调用点可见。make_shared内部要调用T的构造函数,因此构造函数不能是私有的。想让类只能通过工厂创建,可以把make_shared设为友元,或者在类内提供一个静态工厂函数;后者更清晰,也不依赖具体的智能指针实现。
三、与shared_ptr<T>(new T)的对比
| 维度 | make_shared<T>(...) | shared_ptr<T>(new T(...)) |
|---|
| 分配次数 | 通常 1 次(实现相关) | 通常 2 次 |
| 异常安全 | 构造与接管在内部完成,无中间裸指针 | 需自己保证接管时机 |
| 自定义删除器 | 不支持 | 支持 |
| 自定义分配器 | 需用allocate_shared | 可用带分配器的构造函数 |
| 私有构造函数 | 不能直接调用 | 可在类内或工厂中调用 |
| 弱引用对内存的影响 | 对象内存延迟到弱引用归零 | 对象内存可先于控制块释放 |
| 数组(C++20) | make_shared<T[]>(n) | shared_ptr<T[]>(new T[n]) |
| 类型推导 | 自动推导返回类型 | 需显式写出T |
关于「异常安全」这一行需要展开。std::shared_ptr<Widget> p(new Widget());这条语句本身是安全的:如果shared_ptr的构造函数抛异常(例如控制块分配失败),标准要求它删除传入的指针。经典条款 17 担心的是「裸指针先出现、接管之前抛异常」:在 C++17 之前,多个实参的求值可能交错,于是出现过「new已完成、另一个实参抛异常、shared_ptr尚未构造」的泄漏窗口。C++17 把每个形参的初始化改为与其他形参「不确定顺序」(indeterminately sequenced),这种交错被消除;但只要裸指针跨过任何可能抛异常的操作,泄漏仍然成立:
// 适用于 C++17
#include <memory>
struct Widget {};
int compute();
void sink(std::shared_ptr<Widget> p, int n);
void risky() {
Widget* raw = new Widget(); // 裸指针先出现
int n = compute(); // 若抛异常,raw 泄漏
std::shared_ptr<Widget> p(raw); // 接管得太晚
sink(p, n);
}
void safe() {
auto p = std::make_shared<Widget>(); // 构造即接管
sink(p, compute());
}make_shared把分配与接管合并成一次函数调用,调用方不需要持有中间裸指针,因此这类窗口从写法上就不存在。
什么时候不该用make_shared。需要自定义删除器时(例如管理FILE*、第三方库的句柄),需要自定义分配器时(用std::allocate_shared),构造函数私有且不愿开友元时,或者对象很大而weak_ptr会长期存在、希望对象内存尽早释放时。这些场景直接用shared_ptr的构造函数更合适。
四、典型场景
工厂函数。返回shared_ptr的工厂最适合用make_shared:
// 适用于 C++17
#include <memory>
#include <string>
#include <utility>
class Connection {
public:
static std::shared_ptr<Connection> create(std::string host, int port) {
// 若构造函数私有,make_shared 本身无权调用,需要友元;
// 所以这里让构造函数保持公有
return std::make_shared<Connection>(std::move(host), port);
}
Connection(std::string host, int port)
: host_(std::move(host)), port_(port) {}
private:
std::string host_;
int port_;
};如果构造函数确实要私有,把std::make_shared<Connection>声明为友元是一种做法,但它把所有make_shared实例化都变成友元,粒度较粗;更干净的做法是让create内部用std::shared_ptr<Connection>(new Connection(...)),因为静态成员函数本来就能访问私有构造函数。
enable_shared_from_this。需要在成员函数里获得「指向自己的shared_ptr」时,类要继承std::enable_shared_from_this<T>,并调用shared_from_this()。前提是对象已经被某个shared_ptr接管;如果对象是栈上的、或者由new创建但还没交给shared_ptr,调用会失败——C++17 起标准规定抛出std::bad_weak_ptr,此前是 UB。构造期间尤其不能调用:此时shared_ptr还没有完成接管。
// 适用于 C++17
#include <memory>
class Session : public std::enable_shared_from_this<Session> {
public:
std::shared_ptr<Session> self() { return shared_from_this(); }
};
void use() {
auto s = std::make_shared<Session>(); // 先建立 shared_ptr 所有权
auto s2 = s->self(); // 再取自身,合法
(void)s2;
}自定义删除器。make_shared不支持删除器,管理非new资源时只能直接构造:
// 适用于 C++17
#include <cstdio>
#include <memory>
struct FileCloser {
void operator()(std::FILE* f) const noexcept {
if (f != nullptr) {
std::fclose(f);
}
}
};
void open_log() {
std::shared_ptr<std::FILE> fp(std::fopen("app.log", "a"), FileCloser{});
if (fp) {
std::fputs("hello\n", fp.get());
}
}别名构造。std::shared_ptr<T> alias(owner, ptr)可以创建一个指向owner内部子对象、但与owner共享控制块的shared_ptr。它不增加分配,也不会让子对象独立于所有者存活,适合把成员对象的生命周期安全地暴露出去。
常见坑点
- ❌ 用
std::make_shared<T>(...)并试图传入自定义删除器。
✅make_shared没有删除器参数;改用std::shared_ptr<T>(ptr, deleter)。
- ❌ 对同一个裸指针构造两个
shared_ptr,以为引用计数会合并。
✅ 会得到两个独立控制块,各自析构时重复释放,属于 UB;一个裸指针只交给一个shared_ptr。
- ❌ 类构造函数是私有的,却在类外直接调用
std::make_shared<T>()。
✅ 在类内提供静态工厂,或在类内调用;不要为了省事把构造函数改成公有。
- ❌ 长期保存
weak_ptr,同时用make_shared创建了很大的对象,困惑于内存不下降。
✅ 合并分配会让对象内存等到弱引用归零才释放;对内存敏感的场景改用shared_ptr(new T)或及时清理weak_ptr。
- ❌ 在构造函数里调用
shared_from_this()。
✅ 此时对象尚未被shared_ptr接管,C++17 起会抛std::bad_weak_ptr;把调用移到构造完成之后。
- ❌ 在 C++17 工程里写
std::make_shared<int[]>(10)。
✅ 数组重载是 C++20 才有的;C++17 用std::shared_ptr<int[]>(new int[10]())。
- ❌ 直接把花括号列表传给
make_shared,如make_shared<std::vector<int>>({1, 2, 3})。
✅ 模板参数包无法从花括号列表推导;显式写std::initializer_list<int>{1, 2, 3}作为实参。
- ❌ 认为
make_shared一定只分配一次、一定比shared_ptr(new T)快。
✅ 标准不强制分配次数;合并分配是常见实现策略,实际开销需结合对象大小与分配器测量。
总结
| 主题 | 结论 |
|---|
| 基本作用 | 转发参数构造T,返回shared_ptr<T> |
| 分配模型 | 常见实现把控制块与对象合并为一次分配 |
| 主要优势 | 少一次分配;消除裸指针接管前的泄漏窗口 |
| 主要限制 | 不支持删除器;私有构造不可直接调用;弱引用延长整块内存寿命 |
| 数组支持 | C++20 起有make_shared<T[]>(n) |
| 自定义分配器 | 用std::allocate_shared |
| 自身指针 | 先有shared_ptr所有权,再调shared_from_this() |
make_shared是创建shared_ptr的默认选择:写法短、异常安全、通常少一次分配。但它不是万能替换——删除器、私有构造、大对象加长寿命weak_ptr这三类场景要主动换回直接构造。把控制块、强弱引用计数和合并分配的关系搞清楚,选择就不再是靠记忆的规则,而是有依据的工程判断。