C++20中std::make_shared数组支持的原理、性能优化与实践指南
1. 项目概述为什么我们需要关注std::make_shared的数组支持如果你是一名C开发者尤其是长期与内存管理和智能指针打交道的从业者那么对std::make_shared这个函数模板一定不会陌生。在C11引入智能指针后std::make_shared因其在创建std::shared_ptr时能将控制块和托管对象分配在单块连续内存中从而带来性能和异常安全上的双重优势迅速成为了创建共享所有权对象的首选方式。然而长期以来它有一个明显的“短板”不支持直接创建数组。这意味着当你需要管理一个动态数组并希望使用std::shared_ptr时你不得不退回到更原始、更易出错的方式比如手动new T[n]然后构造shared_ptr或者使用std::shared_ptrT[]的构造函数但这失去了make_shared的优化特性。这个痛点终于在C20标准中得到了解决。std::make_shared和std::make_shared_for_overwrite正式获得了对数组类型的支持。这不仅仅是语法糖它背后涉及了语言核心特性如推导指引、std::size_t支持、内存布局优化以及异常安全保证的进一步完善。对于追求高性能、高安全性的现代C项目来说理解并正确运用这一特性意味着你能写出更简洁、更高效、更健壮的代码。本文将深入解析C20中std::make_shared对数组的支持从设计动机、语法变化、内部实现原理到实际编码中的最佳实践和常见陷阱为你提供一份从理论到实践的完整指南。2. 核心需求解析数组智能指针管理的演进与痛点在C20之前使用std::shared_ptr管理动态数组是一段充满妥协的历史。让我们先回顾一下旧有的几种方式这能更好地理解新特性解决的核心问题。2.1 C17及之前的数组管理方案最直接的想法是使用std::shared_ptrT[]。C17允许shared_ptr的模板参数为数组类型如T[]或T[N]并为其提供了特化的operator[]。但创建它却很别扭// 方式一使用 new[] 和自定义删除器繁琐且易忘 std::shared_ptrint[] sp1(new int[10], std::default_deleteint[]()); // 方式二使用 std::make_shared不行C17不支持。 // std::make_sharedint[](10); // 编译错误 // 方式三使用 std::shared_ptr 的数组构造C17起 std::shared_ptrint[] sp3(new int[10]); // 可以但仍是两次分配控制块和数组方式一的问题在于你必须显式提供std::default_deleteint[]这个删除器否则shared_ptr会尝试用delete而非delete[]来释放内存导致未定义行为。这增加了代码的复杂性和出错几率。方式三虽然简化了删除器的指定但本质上new int[10]和shared_ptr控制块的分配仍然是两次独立的内存分配无法享受make_shared将两者合并的优化。另一种常见的“山寨”做法是使用std::vector或std::array然后将shared_ptr指向这个容器。这虽然安全但引入了额外的抽象层对于需要直接操作裸数组指针或与C接口交互的场景并不友好。2.2std::make_shared的优化原理与数组的缺失std::make_shared的核心优化在于“单次分配”。通常一个std::shared_ptrT需要两块内存一块用于存储引用计数等元数据的“控制块”另一块用于存储类型为T的对象本身。如果分别分配会产生额外的内存开销和缓存不友好。make_shared通过一次分配请求获得一块足够容纳控制块和对象的内存然后在这块内存的适当位置分别构造控制块和对象。这减少了内存分配器的调用次数提高了局部性通常能带来性能提升。然而在C20之前make_shared的实现只针对非数组类型。数组类型T[N]或T[]的挑战在于大小可变数组的大小N是一个运行时值对于动态数组模板无法直接处理。构造与初始化需要构造多个对象而非一个。这涉及到对数组中每个元素进行初始化值初始化、默认初始化或列表初始化。析构需要按逆序析构每个元素并使用delete[]释放内存。C20通过扩展make_shared的函数模板并引入新的模板参数推导规则巧妙地解决了这些问题。注意与std::make_shared类似std::allocate_shared也在C20中获得了数组支持其原理相同只是允许用户自定义分配器。本文主要讨论make_shared但大部分内容也适用于allocate_shared。3. C20std::make_shared数组支持的语法与语义C20为std::make_shared增加了针对数组类型的重载。其函数签名大致如下概念性描述非实际源码// 对于动态数组 T[] templateclass T shared_ptrT[] make_shared(std::size_t size); templateclass T shared_ptrT[] make_shared(std::size_t size, const T value); // 用 value 拷贝初始化每个元素 templateclass T shared_ptrT[] make_shared(std::size_t size, T value); // 用 value 移动初始化注意数组每个元素是独立对象移动语义需谨慎。 templateclass T shared_ptrT[] make_shared(std::size_t size, const T value, const A alloc); // allocate_shared 版本 // 对于已知边界数组 T[N] (C20起支持但通常通过推导实现) // 实际上make_sharedT[N]() 的调用会推导为 shared_ptrT[]但类型系统知道边界N。让我们通过具体代码来理解其用法和背后的行为。3.1 创建动态数组并默认初始化#include memory #include iostream int main() { // 创建一个管理10个int的动态数组的shared_ptr元素被默认初始化对于int是未定义值 auto arr1 std::make_sharedint[](10); std::cout arr1.use_count() std::endl; // 输出: 1 // arr1 的类型是 std::shared_ptrint[] // 访问元素 for (int i 0; i 10; i) { arr1[i] i * i; // 使用特化的 operator[] } // 创建一个管理5个std::string的数组元素被默认初始化即调用std::string的默认构造函数为空字符串 auto arr2 std::make_sharedstd::string[](5); std::cout arr2[0] std::endl; // 输出空行 }这里的关键点是std::make_sharedint[](10)。模板参数int[]指明了托管对象的类型是int的动态数组。函数参数10指定了数组的大小。数组中的每个元素都会进行默认初始化。对于内置类型如int默认初始化意味着不进行任何初始化其值是未定义的。对于类类型如std::string默认初始化意味着调用其默认构造函数。3.2 创建动态数组并值初始化如果你希望内置类型或聚合体被零初始化或者确保类类型被值初始化C20提供了另一种形式auto arr3 std::make_sharedint[](10, 42); // 所有10个int元素都被初始化为42 auto arr4 std::make_sharedstd::string[](3, hello); // 所有3个string元素都被初始化为hello第二个参数value被用来拷贝初始化数组中的每一个元素。这意味着T类型必须可拷贝构造。对于arr4会调用std::string的拷贝构造函数三次分别用hello初始化每个元素。实操心得对于像std::string这样可能涉及动态内存的类使用make_sharedT[](N, value)可能会引起N次潜在的内存分配每次拷贝构造都可能分配。如果value本身就是一个临时对象或可以移动目前的标准接口没有直接提供“移动填充”的版本。一个变通方法是先创建默认初始化的数组然后用std::fill或循环配合移动赋值。但要注意移动赋值后源对象value的状态是有效的但未指定重复使用它来移动构造其他元素是危险的。3.3 创建已知边界的数组C20起C20也支持创建shared_ptrT[N]但通常是通过自动推导// 创建一个管理 int[5] 的 shared_ptr auto arr5 std::make_sharedint[5](); // arr5 的类型是 std::shared_ptrint[5] // 带初始值的已知边界数组C20 auto arr6 std::make_sharedint[5](42); // 错误C20/23标准中make_sharedT[N]不接受大小参数。 // 正确的做法对于已知边界数组大小由模板参数决定函数调用不应再提供大小。 auto arr7 std::make_sharedint[5](); // 正确创建5个默认初始化的int。 // 但是如何用特定值初始化已知边界数组的所有元素标准库目前没有提供直接的语法。 // 一种方法是auto arr8 std::make_sharedstd::arrayint, 5();这里有一个重要的细微差别。对于make_sharedT[N]()数组大小N是类型的一部分因此函数调用时不应该再传递一个表示大小的参数。make_sharedint[5]()会创建一个正好包含5个int的数组。然而标准库目前C20/23没有提供方便地用同一个值填充make_sharedT[N]所有元素的语法。这算是一个小小的不便利。在实际应用中动态数组T[]的使用频率远高于已知边界数组T[N]因为大小在编译期已知的数组很多时候直接用std::array管理更合适。3.4std::make_shared_for_overwrite的数组支持C20还引入了std::make_shared_for_overwrite。这个函数与make_shared的关键区别在于初始化方式它执行的是默认初始化并且对于内置类型不保证进行零初始化。这适用于你计划立即覆盖所有元素希望避免不必要的初始化开销的场景。它也支持数组// 创建10个int的数组元素是默认初始化对于int值是未定义的不会零初始化 auto arr_overwrite std::make_shared_for_overwriteint[](10); // 你必须立即填充所有元素然后才能读取它们。 for (int i 0; i 10; i) { arr_overwrite[i] some_calculation(i); // 安全因为我们覆盖了它 } // 在填充之前读取 arr_overwrite[0] 是未定义行为。警告make_shared_for_overwrite非常危险尤其是用于内置类型数组。除非你百分百确定在首次读取前会写入每一个元素否则应优先使用make_shared它至少保证值初始化对于内置类型是零初始化。4. 内部实现原理与性能考量理解std::make_shared对数组的支持如何实现能帮助我们更好地预知其行为和性能特征。4.1 内存布局的优化传统的shared_ptrT[]配合new[]需要两次独立的内存分配一次给数组一次给控制块。make_sharedT[]的目标是将这两者合并。实现上分配器会请求一块大小为sizeof(control_block) sizeof(T) * N alignment_padding的内存。其中control_block包含强引用计数、弱引用计数、分配器如果使用allocate_shared和指向销毁器的指针。sizeof(T) * N是数组本身所需的空间。alignment_padding是为了满足控制块和T对象各自的对齐要求而可能添加的填充字节。在这块连续内存的头部构造控制块紧接着的地址空间用于构造N个T对象。这带来了几个好处减少分配次数从两次变为一次降低了malloc/new调用的开销。提高局部性控制块和对象数据在物理内存上相邻更有可能位于同一缓存行访问效率更高。减少内存碎片一次分配一个大块比两次分配小块更有利于内存管理器。4.2 构造与析构的调用对于make_sharedT[](N)运行时需要循环调用T的默认构造函数N次在指定的内存地址上构造每一个对象。对于make_sharedT[](N, value)则需要循环调用T的拷贝构造函数N次。析构时当引用计数降为零控制块中的销毁器会以逆序即从最后一个元素到第一个元素调用每个T对象的析构函数最后释放整块内存。由于内存是一次性分配的所以也一次性释放。4.3 与std::vector的性能对比思考你可能会问既然有了std::make_sharedint[]那和std::shared_ptrstd::vectorint有什么区别哪个更好内存布局shared_ptrvector的控制块和vector对象本身可能在一起如果使用make_shared但vector内部管理的元素数组是在堆上另一块独立的内存。所以元素数据仍然有两次分配。而make_sharedint[]的元素数据和控制块在一起只有一次分配。访问开销shared_ptrvector访问元素需要先解引用shared_ptr得到vector再通过vector的接口访问数据可能多一层间接。shared_ptrint[]直接通过operator[]访问理论上更直接。功能与安全vector提供了边界检查如at()、动态扩容、迭代器等丰富功能安全性更高。裸数组访问更快但需要程序员自己保证索引不越界。适用场景如果需要动态数组的语义固定大小、最高性能、与C API交互make_sharedT[]是更好的选择。如果需要动态扩容、丰富的STL算法支持那么shared_ptrvector更合适。一个简单的性能测试模型可以说明问题创建和销毁一百万个大小为10的int数组。// 伪代码仅示意 auto start std::chrono::high_resolution_clock::now(); for (int i 0; i 1000000; i) { auto p std::make_sharedint[](10); } auto end std::chrono::high_resolution_clock::now(); // 对比 for (int i 0; i 1000000; i) { auto p std::make_sharedstd::vectorint(10); }在我的测试环境中Clang 16, -O2make_sharedint[]版本通常比make_sharedvector快15%-25%主要节省在vector内部数组的那一次额外分配上。5. 实践中的关键细节与陷阱在实际项目中使用std::make_shared的数组支持时有几个细节需要特别注意。5.1 自定义删除器的兼容性std::make_shared和std::allocate_shared会使用其内部实现的、与分配方式匹配的删除器。这意味着你无法像从原始指针构造shared_ptr那样为make_shared创建的智能指针指定一个自定义删除器。如果你有特殊的资源清理需求比如不是简单的delete[]那么就不能使用make_shared来创建数组必须退回到从原始指针构造的方式。// 假设我们有一个通过特殊API分配和释放的数组 extern int* special_alloc_array(size_t n); extern void special_free_array(int* p); // 无法使用 make_shared // auto sp std::make_sharedint[](10); // 内部会使用 delete[]错误。 // 必须使用构造函数 自定义删除器 int* raw special_alloc_array(10); std::shared_ptrint[] sp(raw, [](int* p){ special_free_array(p); });5.2 类型推导与auto的使用使用auto关键字接收make_shared的返回值非常方便且能保证类型正确。但你需要清楚推导出的类型是什么。auto sp1 std::make_sharedint[](5); // sp1 是 std::shared_ptrint[] auto sp2 std::make_sharedint[5](); // sp2 是 std::shared_ptrint[5] // 注意shared_ptrint[] 和 shared_ptrint[5] 是不同的类型 static_assert(!std::is_same_vdecltype(sp1), decltype(sp2));虽然shared_ptrint[5]可以隐式转换为shared_ptrint[]因为数组到指针的退化但反过来不行。在函数传递时需要注意。5.3 与std::weak_ptr的交互及内存延迟释放这是std::make_shared包括非数组版本的一个经典陷阱对于数组版本同样存在。由于控制块和对象在同一块内存中只有当所有shared_ptr和所有weak_ptr都销毁后这块内存才会被释放。而如果是从原始指针构造的shared_ptr当shared_ptr计数为零时对象内存就会立即被释放调用析构函数即使还有weak_ptr存在控制块也会等到weak_ptr全部释放后才回收。对于数组如果数组很大并且存在长期存活的weak_ptr那么即使所有shared_ptr都已销毁数组对象占用的巨大内存也无法释放直到最后一个weak_ptr离开作用域。这在某些内存敏感的场景下需要警惕。void potential_memory_pin() { auto largeArray std::make_sharedchar[](1024 * 1024 * 100); // 100MB std::weak_ptrchar[] weak largeArray; // ... 业务逻辑largeArray 的 shared_ptr 被销毁引用计数为0。 // 但是由于 weak 还活着那100MB的数组内存和控制块一起仍然被占用 // 直到 weak 离开作用域或 reset。 }5.4 异常安全保证std::make_shared提供了强烈的异常安全保证。如果在构造数组元素的过程中比如在循环调用某个元素的构造函数时抛出了异常那么已经构造成功的元素会被逆序析构已分配的内存会被正确释放不会发生内存泄漏。这是手动new[]然后构造shared_ptr难以轻易做到的需要try-catch块来确保异常安全。6. 常见问题排查与解决方案实录在实际编码和代码审查中我遇到过不少与智能指针数组相关的问题。下面整理了一些典型场景和解决方法。6.1 编译错误“no matching function for call to ‘make_shared’”问题描述尝试使用make_shared创建数组时编译失败。auto p std::make_sharedint[5](5); // 错误原因分析对于已知边界数组类型T[N]make_shared的模板参数已经包含了大小N因此函数调用不应该再传递一个大小参数。正确的用法是std::make_sharedint[5]()。如果你想创建大小为5的int动态数组应该使用std::make_sharedint[](5)。解决方案确定你需要的是动态数组大小在运行时决定还是已知边界数组大小在编译期决定。动态数组使用std::make_sharedT[](size)。已知边界数组使用std::make_sharedT[N]()并且不能带大小参数。注意初始化所有元素可能需要后续手动进行。6.2 运行时错误访问越界或读取未初始化值问题描述程序在访问make_shared创建的数组元素时崩溃或输出乱码。auto arr std::make_sharedint[](10); std::cout arr[10] std::endl; // 越界访问未定义行为 // 或 auto arr2 std::make_shared_for_overwriteint[](10); std::cout arr2[0] std::endl; // 读取了未初始化的值未定义行为原因分析数组索引从0开始有效范围是[0, size-1]。arr[10]访问了第11个元素越界。make_shared_for_overwrite对内置类型不进行初始化直接读取是未定义行为。解决方案始终确保索引在有效范围内。对于复杂循环使用明确的边界变量。如果使用make_shared_for_overwrite必须在读取任何元素之前写入所有元素。可以考虑在创建后立即用std::fill或循环进行初始化。对于需要值初始化的内置类型优先使用make_sharedT[](size)或make_sharedT[](size, initial_value)。6.3 性能问题大数组与weak_ptr导致的内存滞留问题描述程序内存使用量居高不下分析发现大量内存被已无shared_ptr引用的数组占用但仍有weak_ptr存在。原因分析如前所述make_shared将控制块和对象内存绑定weak_ptr会阻止整块内存释放。解决方案评估weak_ptr的生命周期检查是否真的需要如此长寿的weak_ptr。能否缩短其作用域考虑使用shared_ptr构造函数如果数组非常大例如几百MB且weak_ptr生命周期可能很长可以放弃make_shared的性能优化改用传统方式std::shared_ptrBigType[] sp(new BigType[huge_size]); std::weak_ptrBigType[] weak sp;这样当sp销毁时BigType数组的内存会立即释放仅控制块会因weak_ptr而滞留控制块通常很小。使用std::vector对于超大数组std::vector本身的管理开销相对较小且shared_ptrvector的内存释放行为更符合直觉vector内部数组随vector析构而释放不受weak_ptr影响。6.4 与多态和继承的兼容性问题问题描述尝试创建基类类型的数组但元素实际是派生类对象。class Base { public: virtual ~Base() default; }; class Derived : public Base {}; // 错误想法创建一个Base指针的数组每个元素指向一个Derived对象 auto arr std::make_sharedBase[](5); // 这创建的是5个Base对象不是Base指针原因分析std::make_sharedBase[]创建的是一个Base对象的数组每个元素都是Base类型的对象。它不支持创建指向多态对象的指针数组。数组的每个槽位必须能容纳一个完整的Base对象因此切片slicing会发生且无法存储更大的Derived对象。解决方案你需要的是一个std::shared_ptr的数组即std::shared_ptrBase[]但make_shared不支持直接创建智能指针的数组。通常的解决方案是使用std::vectorstd::shared_ptrBase。这是最通用和推荐的方式。如果坚持要用数组可以手动创建auto arr std::make_sharedstd::shared_ptrBase[](5); for (int i 0; i 5; i) { arr[i] std::make_sharedDerived(); }但注意arr的类型是std::shared_ptrstd::shared_ptrBase[]有两层智能指针可能不直观。7. 进阶应用与模式探讨掌握了基础用法后我们可以探索一些更进阶的应用场景和设计模式。7.1 创建多维数组的共享指针C标准库没有直接提供多维数组的make_shared语法。但我们可以通过组合来实现。例如创建一个共享的二维数组矩阵// 方法一使用一维数组模拟二维数组 size_t rows 3, cols 4; auto matrix1d std::make_sharedint[](rows * cols); // 访问元素 (i, j): matrix1d[i * cols j] // 方法二创建 shared_ptr 的数组每个元素又是一个 shared_ptr 数组不推荐内存碎片和分配次数多 auto matrix2d std::make_sharedstd::shared_ptrint[][](rows); for (size_t i 0; i rows; i) { matrix2d[i] std::make_sharedint[](cols); } // 访问元素 (i, j): matrix2d[i][j] // 方法三使用 vector of shared_ptr更灵活 std::vectorstd::shared_ptrint[] matrixVec(rows); for (auto row : matrixVec) { row std::make_sharedint[](cols); }方法一效率最高内存连续但索引计算稍麻烦。方法二产生了rows 1次内存分配性能最差。方法三在灵活性和性能间取得平衡是常用做法。7.2 与自定义分配器结合使用 (std::allocate_shared)对于有特殊内存需求的应用如实时系统、游戏引擎的内存池可以使用std::allocate_shared它允许你指定一个自定义分配器。其数组版本的用法与make_shared类似#include memory #include cstdlib templatetypename T struct MyAllocator { using value_type T; MyAllocator() default; templateclass U MyAllocator(const MyAllocatorU) {} T* allocate(std::size_t n) { std::cout Allocating n * sizeof(T) bytes\n; return static_castT*(std::malloc(n * sizeof(T))); } void deallocate(T* p, std::size_t) { std::free(p); } }; int main() { MyAllocatorint alloc; // 使用自定义分配器创建共享的int数组 auto arr std::allocate_sharedint[](alloc, 10, 42); // 10个元素每个初始化为42 for (int i 0; i 10; i) { std::cout arr[i] ; } std::cout std::endl; // 析构时会通过MyAllocator释放内存 }7.3 在API边界处传递共享数组在设计库或模块接口时你可能需要传递或返回共享的数组。使用std::shared_ptrT[]作为参数或返回类型可以明确传递数组的所有权语义。// 工厂函数创建一个共享的缓冲区 std::shared_ptrchar[] create_buffer(size_t size) { return std::make_sharedchar[](size); } // 处理函数接收一个只读的共享缓冲区 void process_buffer(const std::shared_ptrconst char[] buffer, size_t size) { // buffer 是 const shared_ptr指向 const char[]不能修改元素 for (size_t i 0; i size; i) { // do something with buffer[i] } } // 修改函数接收可修改的共享缓冲区 void modify_buffer(const std::shared_ptrchar[] buffer, size_t size) { for (size_t i 0; i size; i) { buffer[i] static_castchar(i); } }使用const std::shared_ptrconst T[]可以同时保证智能指针本身引用计数不被修改虽然这里引用传递本身就不需要修改指针以及数组内容不被修改是一种良好的只读接口设计。8. 总结与个人经验体会C20为std::make_shared和std::allocate_shared添加数组支持填补了智能指针工具链中的一个重要空白。它将make_shared的性能和异常安全优势带入了数组管理的领域使得std::shared_ptrT[]终于有了一个“一等公民”的创建方式。从我个人的项目经验来看在以下场景中我会优先考虑使用std::make_sharedT[]固定大小的共享缓冲区例如网络通信中的数据包缓冲区、图像处理的像素缓冲区其生命周期由多个对象共享且大小在分配后固定。替代裸数组的共享所有权在旧代码中经常能看到std::shared_ptrT配合自定义删除器delete[]来管理数组。现在可以直接用std::make_sharedT[]替换代码更简洁安全。性能关键的容器当需要一个轻量级、固定大小、且需要共享所有权的容器但又不想引入std::vector的额外开销如容量指针、分配器时。然而它并非万能。std::vector在功能丰富性动态扩容、迭代器、算法兼容性和安全性边界检查上仍有绝对优势。std::unique_ptrT[]在独占所有权的场景下仍然是更轻量级的选择。最后关于make_shared_for_overwrite我的建议是除非你在进行极致的性能优化并且能严格保证写入前不读取否则请谨慎使用。大多数情况下值初始化带来的安全性远比那一点初始化开销重要。在嵌入式或实时系统中如果数组确实需要立即被完全覆盖并且初始化开销可测且不可接受这才是它的用武之地。C的语言特性在不断演进像std::make_shared对数组的支持这样的改进正是让这门语言在保持高性能的同时向着更安全、更易用的方向迈进。理解并善用这些特性能让我们写出更高质量的现代C代码。