C++智能指针:std::make_shared的原理、优势与使用场景详解 1. 项目概述为什么我们需要std::make_shared在C的日常开发中尤其是涉及到资源管理和对象生命周期时智能指针是我们绕不开的话题。从C11开始std::shared_ptr成为了管理动态分配对象、实现共享所有权的标准工具。然而直接使用new表达式来构造shared_ptr例如std::shared_ptrMyClass sp(new MyClass(args...));虽然功能上没问题但在性能和异常安全方面存在一些潜在的“坑”。这就是std::make_shared登场的背景。它不是一个独立的类型而是一个函数模板其核心使命是以一种更高效、更安全的方式构造一个std::shared_ptr来管理一个新对象。简单来说它把“分配内存”和“构造对象”这两个步骤以及为控制块分配内存的步骤优雅地合并到了一起。想象一下你正在组装一台复杂的设备。传统方法newshared_ptr构造函数好比是你先去A仓库订购零件分配对象内存再去B仓库订购组装说明书和保修卡分配控制块内存最后自己动手组装。而std::make_shared则像是一个一站式解决方案你只需要下一个订单供应商就会把零件和说明书打包在一个盒子里单次内存分配并组装好成品直接送给你。后者显然更省事运输成本内存开销也更低并且避免了你在拿到零件后、拿到说明书前发生意外异常导致零件丢失内存泄漏的风险。对于任何使用现代CC11及以上的开发者无论是正在学习智能指针的初学者还是追求高性能和鲁棒性的资深工程师深入理解std::make_shared的机制、优势、局限以及最佳实践都是一项必备技能。它能帮助你写出更简洁、更高效、更安全的代码。2. 核心原理与优势深度剖析要真正理解std::make_shared为什么被推荐我们需要深入到内存布局和异常安全的层面去看。2.1 内存分配优化合二为一的艺术这是std::make_shared最常被提及的优势。我们来对比两种方式的内存分配过程。方式一使用new表达式std::shared_ptrWidget sp(new Widget(10, 20));这行代码背后发生了至少两次独立的内存分配new Widget(10, 20)在堆上分配一块足够容纳Widget对象的内存并调用构造函数初始化。我们称这块内存为“对象数据块”。std::shared_ptr构造函数内部为了管理这个Widget对象的共享所有权和弱引用计数shared_ptr需要另一个独立的结构称为“控制块”。控制块通常包含至少两个引用计数器use_count,weak_count和一个删除器deleter等。因此shared_ptr的构造函数会第二次在堆上分配内存用于创建这个控制块。两次分配意味着两次系统调用如malloc增加了运行时开销也导致内存碎片化的可能性更高。并且对象数据块和控制块在内存中是不连续的这对CPU缓存局部性不友好。方式二使用std::make_sharedauto sp std::make_sharedWidget(10, 20);这行代码只发生一次内存分配。std::make_shared函数模板会计算所需的总内存大小sizeof(Widget) sizeof(ControlBlock)控制块大小因实现而异但通常固定。然后它一次性分配一块连续的、足够容纳两者的大内存块。在这块连续内存的布局中控制块和对象数据块通常是相邻的。这种“单次分配”带来了多重好处性能提升减少了一次昂贵的内存分配系统调用对于频繁创建共享对象的场景累积效应显著。内存局部性对象和其控制块位于连续内存中访问时更有可能同时存在于CPU缓存中提高了访问效率。内存开销可能更小内存分配器本身有管理开销如头部信息。两次独立分配会产生两份管理开销而一次合并分配只产生一份理论上节省了少许内存。2.2 异常安全强异常保证的守护者异常安全是编写健壮C代码的关键考量。std::make_shared提供了“强异常保证”。考虑一个看似无害的函数void processWidget(std::shared_ptrWidget sp1, std::shared_ptrWidget sp2); // 调用方式A使用new表达式 processWidget(std::shared_ptrWidget(new Widget(1)), std::shared_ptrWidget(new Widget(2)));在C17之前函数参数的求值顺序是未指定的。编译器可能会生成这样的执行序列分配Widget(1)的内存 (new Widget(1))构造Widget(1)对象分配Widget(2)的内存 (new Widget(2))构造Widget(2)对象构造第一个shared_ptr的控制块构造第二个shared_ptr的控制块如果在步骤4之后、步骤5之前即在第二个Widget构造成功但第一个shared_ptr还未接管其资源时抛出了一个异常可能来自Widget构造函数、内存不足、或任何地方那么已经成功构造的Widget(2)对象就会发生内存泄漏因为还没有任何shared_ptr拥有它也就没有责任去释放它。这就是典型的因求值顺序导致的资源泄漏风险。现在使用std::make_shared// 调用方式B使用make_shared processWidget(std::make_sharedWidget(1), std::make_sharedWidget(2));每个std::make_sharedWidget(...)都是一个独立的函数调用。它内部一次性完成内存分配和对象构造并立即将所有权移交给shared_ptr。这个操作是原子化的要么完全成功返回一个有效的shared_ptr要么在对象构造失败时分配的内存会被自动清理不会留下任何孤儿资源。因此无论参数求值顺序如何每个make_shared调用本身都是异常安全的。注意C17标准规定了函数参数的求值顺序尽管不是严格的从左到右但有了更明确的规则这在一定程度上缓解了上述使用new的直接风险但并未根本消除。使用make_shared依然是编写异常安全代码的更清晰、更可靠的习惯。2.3 代码简洁性与防错从代码风格上看std::make_shared让代码更简洁并减少了犯错的机会。避免重复类型名使用auto关键字可以自动推导出指针类型避免了在new表达式中重复书写类型名如Widget这在模板编程或类型名很长时尤其有用。防止资源泄漏它杜绝了手写new而忘记将其放入智能指针的经典错误。// 潜在错误如果后续代码抛出异常ptr 可能未被智能指针管理 Widget* raw_ptr new Widget(); // ... 一些可能抛出异常的操作 std::shared_ptrWidget sp(raw_ptr); // 如果上面抛异常这行不会执行 // 正确做法使用make_shared构造和资源绑定一步到位 auto sp std::make_sharedWidget();3.std::make_shared的语法与使用详解3.1 基本语法形式std::make_shared是一个定义在memory头文件中的函数模板。其基本声明简化如下template class T, class... Args std::shared_ptrT make_shared( Args... args );T你想要创建的对象的类型也是返回的std::shared_ptr所指向的类型。Args...可变模板参数包对应传递给T的构造函数的参数。args...转发引用万能引用参数包std::make_shared会使用std::forwardArgs(args)...将这些参数完美转发给T的构造函数。这意味着你可以像调用T的构造函数一样调用std::make_shared。3.2 各种构造场景示例让我们通过一个示例类Person来演示各种用法#include memory #include string #include iostream class Person { public: Person() : name_(Unknown), age_(0) { std::cout Person() default constructed.\n; } Person(const std::string name, int age) : name_(name), age_(age) { std::cout Person(\ name_ \, age_ ) constructed.\n; } Person(const Person other) : name_(other.name_), age_(other.age_) { std::cout Person copy constructed from other.name_ .\n; } Person(Person other) noexcept : name_(std::move(other.name_)), age_(other.age_) { std::cout Person move constructed from name_ .\n; } ~Person() { std::cout Person \ name_ \ destroyed.\n; } void greet() const { std::cout Hello, Im name_ , age_ years old.\n; } private: std::string name_; int age_; };1. 默认构造auto p1 std::make_sharedPerson(); // 调用 Person() p1-greet(); // 输出: Hello, Im Unknown, 0 years old.2. 带参数构造auto p2 std::make_sharedPerson(Alice, 30); // 输出: Person(Alice, 30) constructed. // 等价于 shared_ptrPerson(new Person(Alice, 30))但更高效安全。3. 使用初始化列表C11以后如果类的构造函数接受std::initializer_listmake_shared也能完美支持。class MyContainer { public: MyContainer(std::initializer_listint list) : data_(list) {} private: std::vectorint data_; }; auto container std::make_sharedMyContainer({1, 2, 3, 4, 5});4. 构造派生类对象并存储到基类指针这是多态性的常见用法。make_shared会正确构造派生类对象。class Base { public: virtual ~Base() default; virtual void foo() 0; }; class Derived : public Base { public: void foo() override { /*...*/ } }; std::shared_ptrBase basePtr std::make_sharedDerived();5. 与auto关键字结合这是最推荐的写法简洁且避免了类型重复。auto sp std::make_sharedPerson(Bob, 25); // sp 的类型是 std::shared_ptrPerson3.3 性能对比实测浅析虽然理论上make_shared有性能优势但“感觉”可能不明显。我们可以通过一个简单的测试来观察。注意这种微基准测试受编译器、标准库实现、运行环境影响很大结果仅供参考旨在理解原理。#include memory #include chrono #include iostream #include vector class HeavyClass { std::arraychar, 1024 data; // 一个占用1KB的类 public: HeavyClass() { /* 模拟耗时构造 */ } }; void test_make_shared(int count) { auto start std::chrono::high_resolution_clock::now(); for (int i 0; i count; i) { auto sp std::make_sharedHeavyClass(); // 让sp离开作用域对象被销毁 } auto end std::chrono::high_resolution_clock::now(); std::chrono::durationdouble diff end - start; std::cout make_shared for count times: diff.count() s\n; } void test_new_shared(int count) { auto start std::chrono::high_resolution_clock::now(); for (int i 0; i count; i) { std::shared_ptrHeavyClass sp(new HeavyClass()); } auto end std::chrono::high_resolution_clock::now(); std::chrono::durationdouble diff end - start; std::cout new shared_ptr for count times: diff.count() s\n; } int main() { const int N 1000000; // 一百万次 test_make_shared(N); test_new_shared(N); return 0; }在我的某次测试环境中Clang编译器libcmake_shared版本通常比new版本快 10%-20%。这个差距在对象本身很小、但创建极其频繁的场景下会更加明显。对于大型、不频繁创建的对象单次分配的优化可能不那么显著但异常安全的收益始终存在。4. 使用限制与注意事项何时不用make_shared尽管std::make_shared优点众多但它并非银弹。在某些特定场景下直接使用std::shared_ptr的构造函数是必要或更优的选择。4.1 自定义删除器Custom Deleter和分配器Allocatorstd::make_shared的语法不允许指定自定义删除器或分配器。它使用默认的operator delete和默认分配器通常是std::allocator。如果你的资源不是通过new分配的或者需要特殊的清理逻辑就必须使用shared_ptr的构造函数。场景一管理非new分配的资源如文件句柄、C风格数组#include cstdio // 使用自定义删除器关闭文件 auto fileDeleter [](std::FILE* fp) { if(fp) std::fclose(fp); std::cout File closed.\n; }; // 错误无法指定删除器 // auto fp_sp std::make_sharedstd::FILE(std::fopen(test.txt, r)); // 正确使用 shared_ptr 构造函数 std::shared_ptrstd::FILE fp_sp(std::fopen(test.txt, r), fileDeleter); if (!fp_sp) { /* 处理打开失败 */ }场景二管理数组C17前或需要特殊处理在C17之前shared_ptr不支持直接管理动态数组T[]。虽然C17引入了shared_ptrT[]和对应的make_sharedT[](size_t)但如果你需要更复杂的数组管理比如自定义对齐可能仍需构造函数。// C17 方式管理数组 auto arr_sp std::make_sharedint[](10); // 创建一个有10个int的数组 arr_sp[5] 42; // 如果需要自定义删除器例如使用 delete[] auto arr_sp2 std::shared_ptrint(new int[10], std::default_deleteint[]()); // 或者更早的C版本 auto arr_sp3 std::shared_ptrint(new int[10], [](int* p) { delete[] p; });4.2 需要分离控制块和对象生命周期的场景这是std::make_shared一个不那么直观但非常重要的限制。由于对象数据块和控制块内存是连续分配的它们的生命周期被绑定在了一起。对象内存的延迟释放当所有shared_ptr强引用都销毁后对象本身Widget的析构函数会被立即调用。但是为对象分配的那块内存即对象数据块所占的空间并不会立即释放因为它和控制块还在同一块内存里。只有当所有weak_ptr弱引用也都被销毁后整块内存包含控制块和对象数据块才会被一次性释放。考虑以下场景class LargeObject { std::arraychar, 1024*1024 huge_data; // 占用1MB public: LargeObject() { std::cout LargeObject constructed.\n; } ~LargeObject() { std::cout LargeObject destroyed. Memory freed later?\n; } }; void test_memory_hold() { std::weak_ptrLargeObject wp; { auto sp std::make_sharedLargeObject(); // 单次分配约 1MB 控制块 wp sp; // 创建一个弱引用 std::cout sp goes out of scope.\n; } // 此处 sp 被销毁use_count 为 0~LargeObject() 被调用。 // 但是因为 wp 还存在weak_count 0包含 LargeObject 对象数据的那1MB内存并未释放 std::cout Weak ptr still alive. The 1MB memory is still held.\n; // ... 可能进行一些其他内存敏感操作 // 只有当 wp 也被销毁或重置内存才最终释放。 }如果你的对象非常大例如缓存、图像数据并且你使用了weak_ptr来观察它那么即使对象逻辑上已经“销毁”它占用的内存仍会被占用直到最后一个weak_ptr离开。这在内存紧张的应用中可能是个问题。对比使用new的方式void test_memory_separate() { std::weak_ptrLargeObject wp; { std::shared_ptrLargeObject sp(new LargeObject()); // 两次分配 wp sp; std::cout sp goes out of scope.\n; } // 此处 sp 销毁use_count 为 0。 // 1. ~LargeObject() 被调用。 // 2. 对象数据块的1MB内存通过 delete 立即释放。 // 3. 控制块内存由于 wp 存在而保留但很小。 std::cout Weak ptr alive, but only small control block memory held.\n; }使用new时对象内存和控制块内存是分开的。因此当强引用计数为零时对象内存可以立即释放只留下很小的控制块内存等待弱引用计数归零。实操心得在设计大型对象或缓存系统时如果预计会大量使用weak_ptr且对内存占用敏感需要仔细评估是否使用make_shared。对于小对象或weak_ptr生命周期很短的情况make_shared的优势通常压倒这个缺点。4.3 构造函数访问权限问题std::make_shared作为一个非成员函数在构造对象时必须能够访问目标类的构造函数。如果类的构造函数是private或protected的并且没有将std::make_shared设为友元那么就无法使用它。class PrivateConstructor { private: PrivateConstructor(int x) : val(x) {} // 声明 make_shared 为友元才能使用 templatetypename T, typename... Args friend std::shared_ptrT std::make_shared(Args... args); public: static std::shared_ptrPrivateConstructor create(int x) { // 现在可以用了 return std::make_sharedPrivateConstructor(x); // 或者如果不愿友元可以在静态成员函数内部使用 new // return std::shared_ptrPrivateConstructor(new PrivateConstructor(x)); } private: int val; };通常如果一个类希望通过工厂函数来创建实例那么在工厂函数内部使用new来构造shared_ptr是更常见的模式因为它避免了将标准库模板声明为友元的侵入式操作。4.4 需要直接传递shared_ptr构造函数参数的场景极少数情况下你可能需要直接调用shared_ptr的某个特定构造函数而make_shared的语法不直接支持。例如shared_ptr有一个别名构造aliasing constructorshared_ptrT(const shared_ptrU, element_type*)用于创建一个与原shared_ptr共享控制块但指向不同对象的指针。这无法用make_shared表达。5. 常见问题与排查技巧实录在实际使用中你可能会遇到一些典型的问题或困惑。5.1 错误调用不明确的构造函数当类有重载的构造函数特别是包含了std::initializer_list时make_shared的参数推导可能会出人意料。class Widget { public: Widget(int a, int b) { std::cout Widget(int, int)\n; } Widget(std::initializer_listint list) { std::cout Widget(initializer_list)\n; } }; auto w1 std::make_sharedWidget(10, 20); // 调用哪个 // 输出: Widget(int, int) auto w2 std::make_sharedWidget(std::initializer_listint{10, 20}); // 明确 // 输出: Widget(initializer_list) // 如果你想用初始化列表构造但参数可能被误解为多个参数需要额外注意。排查技巧当构造行为不符合预期时检查类是否有std::initializer_list构造函数。make_shared会优先匹配普通参数列表除非你用花括号显式传递一个初始化列表。在C11/14中圆括号()和花括号{}的初始化区别需要特别注意。5.2 性能调优与内存分析问题如何验证make_shared确实减少了内存分配次数技巧可以重载全局的operator new和operator delete来跟踪分配和释放。或者使用像 Valgrind、Heaptrack 这样的内存分析工具观察程序运行时的内存分配调用栈。你会看到使用make_shared时对应对象的分配次数减半。问题怀疑weak_ptr导致大对象内存无法释放排查检查程序中所有weak_ptr的生命周期。确保在不再需要观察对象时及时调用weak_ptr::reset()或让其离开作用域。使用调试器或打印weak_count注意weak_count通常不直接暴露但use_count和weak_count的关系可以帮助推断来观察弱引用计数。如果确实存在内存滞留问题考虑改用shared_ptrT(new T(...))的构造方式将对象内存与控制块内存分离。5.3 与std::make_unique的对比与选择C14引入了std::make_unique它为独占所有权的std::unique_ptr提供了类似的便利和异常安全保证。选择原则很简单需要共享所有权使用std::make_shared。需要独占所有权使用std::make_unique。std::make_unique的实现通常就是封装一个new表达式不存在make_shared那种内存绑定问题因为它没有控制块的概念unique_ptr的删除器是直接存储在指针对象内的或者作为类型的一部分。一个良好的模式是优先使用make_unique创建对象如果需要共享再将其转换为shared_ptr。auto uniqueWidget std::make_uniqueWidget(args...); // 独占无额外开销 std::shared_ptrWidget sharedWidget std::move(uniqueWidget); // 转移所有权此时才分配控制块这种方式在对象可能不需要被共享时避免了控制块的开销并且在决定共享时依然保持了代码的清晰和安全。5.4 在容器和工厂函数中的应用std::make_shared非常适合在返回shared_ptr的工厂函数中使用也便于在标准容器中存放shared_ptr。// 工厂函数 std::shared_ptrConnection createConnection(const std::string address) { // 使用 make_shared 保证异常安全 return std::make_sharedTcpConnection(address); } // 在容器中使用 std::vectorstd::shared_ptrEmployee team; team.push_back(std::make_sharedEmployee(Alice, Engineer)); team.emplace_back(std::make_sharedEmployee(Bob, Manager)); // 更高效避免临时对象我个人在项目中几乎将std::make_shared作为创建共享对象的默认选择。它带来的代码简洁性和基础性的异常安全保障是立竿见影的。只有在遇到需要自定义删除器、或者明确意识到大对象与长生命周期weak_ptr结合会导致内存压力时我才会退回到显式的new表达式。这种“优先使用make_shared必要时才用new”的策略在实践中能有效减少低级错误并提升代码的整体质量。最后一个小技巧是结合auto使用让编译器帮你推导类型可以让代码看起来非常干净利落。