
看到这个问题,不禁让人想起一些真实的生产环境教训。例如,在一次线上问题的排查中,最终定位到一个偶发的内存泄漏,其根源正是由于不当使用 shared_ptr(new T()) 导致的构造异常安全问题。如果当时采用了 make_shared,这个隐患完全可以避免。
简而言之,std::make_shared 相比直接 new 有以下核心优势:性能更好、更安全、代码更简洁。理解其背后的原理和适用边界,是编写健壮、高效C++代码的基本功。
一、核心区别:一次分配 vs 两次分配
最关键的区别在于内存分配的机制。
使用 new 的方式:
std::shared_ptr<Widget> sp(new Widget());
这种方式至少需要两次独立的内存分配:
- 为
Widget 对象本身分配内存。
- 为
shared_ptr 的控制块(存储引用计数、弱引用计数、删除器等)分配内存。
其内存布局大致如下:
[Widget对象] <--- 第一次分配
...
[控制块(引用计数等)] <--- 第二次分配
使用 make_shared 的方式:
auto sp = std::make_shared<Widget>();
std::make_shared 通常通过一次内存分配,在连续的内存空间中同时容纳控制块和对象本身。
[控制块 + Widget对象] <--- 一次分配搞定
性能提升体现在哪里?
- 减少分配开销:减少一次内存分配/释放的系统调用。
- 提升缓存局部性:对象和控制块位置相邻,访问时CPU缓存命中率更高。
- 减少内存碎片:更少、更大的内存块分配有助于降低内存碎片。
在需要频繁创建和销毁shared_ptr的高性能场景或并发编程中,这种优化带来的累积效果非常可观。正确使用智能指针是构建健壮并发程序的重要基础。
二、异常安全性:这个更致命
这是一个容易被忽视但后果严重的陷阱。
考虑以下函数和调用:
void processWidget(std::shared_ptr<Widget> sp, int priority);
// 调用方式1: 使用 new (存在风险!)
processWidget(std::shared_ptr<Widget>(new Widget()), computePriority());
// 调用方式2: 使用 make_shared (安全)
processWidget(std::make_shared<Widget>(), computePriority());
第一种方式的风险在哪里?
C++标准并未规定函数参数的求值顺序。对于 processWidget(new Widget(), computePriority()),编译器可能的执行顺序之一是:
new Widget() —— 成功分配内存。
computePriority() —— 执行时抛出异常!
std::shared_ptr<Widget>(...) —— 由于异常发生,此构造步骤永远不会执行。
结果就是:Widget 对象的内存已经被分配,但负责管理它的 shared_ptr 却未被成功构造,导致内存泄漏。
而 std::make_shared 是一个单一的函数调用,它会在内部完成内存分配和智能指针的构造,从而保证了强异常安全性,computePriority 的异常不会导致内存泄漏。
三、代码简洁性
make_shared 在语法上也更具优势。
// 繁琐且需要重复书写长类型名
std::shared_ptr<SomeVeryLongTypeName> sp1(new SomeVeryLongTypeName(arg1, arg2, arg3));
// 简洁优雅,借助auto关键字
auto sp2 = std::make_shared<SomeVeryLongTypeName>(arg1, arg2, arg3);
无需重复书写类型,代码更简洁,可读性和维护性都更好。
四、make_shared 的局限性
当然,make_shared 并非银弹,在以下特定场景中,仍需使用 new 配合 shared_ptr 构造函数。
1. 需要自定义删除器
make_shared 无法指定自定义删除器。
// make_shared 不支持此方式,必须使用 new
auto sp = std::shared_ptr<FILE>(fopen("file.txt", "r"), fclose);
2. 构造函数是私有的或受保护的
make_shared 作为非成员函数,无法访问类的私有或保护构造函数。
class Singleton {
private:
Singleton() {}
public:
static std::shared_ptr<Singleton> create() {
// 此处无法使用 make_shared
return std::shared_ptr<Singleton>(new Singleton());
}
};
3. 对象内存延迟释放问题(与 weak_ptr 相关)
当使用 make_shared 时,对象和控制块位于同一内存块。如果有 std::weak_ptr 长期存在(即使对应的 shared_ptr 已销毁),由于控制块仍需存活以跟踪弱引用计数,整个内存块(包括对象所占用的部分)都无法被释放。
auto sp = std::make_shared<HugeObject>(); // 假设对象体积非常大
std::weak_ptr<HugeObject> wp = sp;
sp.reset(); // shared_ptr 引用计数归零,对象析构函数被调用
// 但是,只要 wp 还存在,为 HugeObject 分配的那部分内存就无法归还给系统
如果采用 new 的方式,对象和控制块分离,对象被销毁后,其内存可以立即释放,控制块则会等到所有 weak_ptr 释放后再回收。
五、最佳实践总结
默认情况下,应优先使用 make_shared:
auto sp = std::make_shared<T>(args...);
仅在以下特定情况考虑使用 new:
- 需要指定自定义删除器。
- 需要构造一个构造函数为非公有的类对象。
- 对象体积巨大,且预期会有长生命周期的
weak_ptr 引用,需要避免内存延迟释放。
- 在 C++20 之前,需要使用大括号初始化列表构造对象。
六、补充:make_unique 的类似建议
对于 std::unique_ptr,C++14 引入了 std::make_unique,其建议同理:
// 推荐:异常安全且简洁
auto up = std::make_unique<T>(args...);
// 不推荐
std::unique_ptr<T> up(new T(args...));
它同样具备异常安全性和代码简洁性的优势,是现代C++编程中的首选方式。掌握这些智能指针的最佳实践,是深入理解C++内存管理模型与系统编程的关键一步。