☰
C++ make_shared 用法小结
2026/10/21 4:34:15 网站建设 项目流程

前言


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。它不增加分配,也不会让子对象独立于所有者存活,适合把成员对象的生命周期安全地暴露出去。


常见坑点



  1. ❌ 用std::make_shared<T>(...)并试图传入自定义删除器。


✅make_shared没有删除器参数;改用std::shared_ptr<T>(ptr, deleter)。



  1. ❌ 对同一个裸指针构造两个shared_ptr,以为引用计数会合并。


✅ 会得到两个独立控制块,各自析构时重复释放,属于 UB;一个裸指针只交给一个shared_ptr。



  1. ❌ 类构造函数是私有的,却在类外直接调用std::make_shared<T>()。


✅ 在类内提供静态工厂,或在类内调用;不要为了省事把构造函数改成公有。



  1. ❌ 长期保存weak_ptr,同时用make_shared创建了很大的对象,困惑于内存不下降。


✅ 合并分配会让对象内存等到弱引用归零才释放;对内存敏感的场景改用shared_ptr(new T)或及时清理weak_ptr。



  1. ❌ 在构造函数里调用shared_from_this()。


✅ 此时对象尚未被shared_ptr接管,C++17 起会抛std::bad_weak_ptr;把调用移到构造完成之后。



  1. ❌ 在 C++17 工程里写std::make_shared<int[]>(10)。


✅ 数组重载是 C++20 才有的;C++17 用std::shared_ptr<int[]>(new int[10]())。



  1. ❌ 直接把花括号列表传给make_shared,如make_shared<std::vector<int>>({1, 2, 3})。


✅ 模板参数包无法从花括号列表推导;显式写std::initializer_list<int>{1, 2, 3}作为实参。



  1. ❌ 认为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这三类场景要主动换回直接构造。把控制块、强弱引用计数和合并分配的关系搞清楚,选择就不再是靠记忆的规则,而是有依据的工程判断。




需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询