ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

std::allocator::destroy 被删了:析构与释放分离的演进与迁移指南

std::allocator::destroy 被删了:析构与释放分离的演进与迁移指南 警告刷屏、编译报错、内存泄漏三种看着毫不相干的 bug可能都源自同一个函数std::allocatorT::destroy。它在 C17 里被标了弃用C20 里被直接删除很多老项目因此得翻出一堆调用点来改。但真正把它吃透之后你会发现这个函数改成自由函数std::destroy_at反而是分配器设计里一次相当清爽的进化。这篇文章不打算逐条背标准条文而是想从析构和释放为什么要分开这个根上把std::allocatorT::destroy的来龙去脉、迁移路径、以及实战中那些配错就崩的坑一次讲清楚。适合正在手写自定义容器的朋友也适合编译期刚好报错、还不知道去哪查的人。看完你至少能回答三个问题它到底干了什么、为什么被删、删了之后代码该怎么改。1. 先定位destroy 只负责“拆”不负责“还”1.1 一句话解释它的行为std::allocatorT::destroy是分配器提供的成员函数作用是在一段已经构造好对象的内存上调用对象的析构函数。等价写法就是p-~T()。它不释放内存不做任何记账也不管这块内存之后是死是活。签名变化也值得一说C98 时期void destroy(pointer p)只能销毁value_type类型的对象。C11 起templateclass U void destroy(U* p)变成成员模板可以销毁任意类型U。C11 改成模板的原因很实际容器在重分配时经常需要在新旧内存上移动对象老签名只能处理分配器值类型本身写泛型容器代码时经常卡住。改成成员模板后一个分配器可以顺带管理临时对象的析构虽然这种用法后来被自由函数替代了但当时确实解决了一批问题。1.2 分配器视角下的完整四步曲一个对象在 allocator 管理的内存上完整生命周期是四步顺序不能乱阶段干什么常用调用生活类比allocate(N)申请能放下 N 个 T 的裸内存alloc.allocate(N)租下一间空房construct(p, args...)让空房住进一个 Tallocator_traits::construct/construct_at家具搬进去人住进去destroy(p)让 T 退房allocator_traits::destroy/destroy_at人离开家具清掉deallocate(p, N)把房间退租alloc.deallocate(p, N)退房还钥匙destroy的本质是清理对象而房间本身依然是你的还能继续住下一个对象。这也是它必须和deallocate分开的原因析构对象不等于释放内存。很多人一开始不理解为什么标准库要设计得这么啰嗦非要在delete之外再来一层等你自己实现过容器就知道这层拆分是刚需不是随意设计的。1.3 最小示例手工走一遍完整流程#include memory #include iostream struct Item { int id; ~Item() { std::cout ~Item( id )\n; } }; int main() { std::allocatorItem alloc; Item* p alloc.allocate(1); // 1. 拿内存此时 p 处没有对象 std::allocator_traitsstd::allocatorItem::construct(alloc, p, Item{42}); // 等价于 C20 的 std::construct_at(p, Item{42}); // 等价于老写法 alloc.construct(p, Item{42}); p-id 43; // 2. 正常使用 std::destroy_at(p); // 3. 析构对象等价于 p-~Item() // C17 前常用 alloc.destroy(p) 或 allocator_traits::destroy(alloc, p) alloc.deallocate(p, 1); // 4. 还内存 }输出会有一行~Item(42)且析构发生在deallocate之前。如果把destroy和deallocate的顺序反过来或者干脆漏了destroy对带非平凡析构函数的类型来说都是未定义行为最常见的表现就是资源泄漏或者程序崩溃。2. 为什么非得把析构拆出来从 vector 扩容看清整套逻辑2.1 clear 只清理元素不动容量理解这个设计的最好切入点是std::vector的clear()。下面这行代码很多新手会误以为清空了容器内存也该还了std::vectorItem v(3); v.clear(); // size 变 0capacity 不变三个 Item 被析构内存仍在clear()的真实语义是把所有存活元素依次析构但底层那块 buffer 原封不动留着。vector 的实现里这一步通常调用std::destroy(begin, end)底层逐个destroy_at绝不碰deallocate。这样下次push_back就能直接复用已有 capacity避免反复分配释放。如果当初把destroy和deallocate绑成一个操作clear()就得把整块内存退掉再重新申请capacity 语义直接崩掉。每次清空再填充都伴随两次堆操作性能也是灾难。所以对象清理和内存归还这两个动作从一开始就必须是可独立调用的。2.2 扩容的搬运过程vector 容量不够时内部典型流程是四步alloc.allocate(new_cap)申请更大的裸内存。在新内存上逐个 construct / move 旧元素。旧内存上逐个 destroy 旧元素。旧内存deallocate掉。第 2 步和第 3 步经常被放在同一个循环的两端但它们是两个截然不同的操作。第 3 步尤其说明问题旧对象要死但旧内存马上就要在第 4 步归还析构和释放恰好是两个紧挨着的动作。allocator 接口把这俩拆开实现上才谈得上灵活。你可能会问这么简单的搬运直接用std::move加delete[]不行吗不行。vector 的底层内存来自allocate必须由配套的deallocate归还不能混用delete[]。而且搬运过程中每个对象的构造和析构都需要精确配对尤其当一个对象在构造过程中抛出异常时标准库要求已经搬过去的部分必须全部析构掉——这只能靠逐对象调用析构函数实现。2.3 一个手写固定容量容器验证这套逻辑下面这个简化例子把四步曲落到真实代码里template typename T class TinyStorage { public: explicit TinyStorage(std::size_t cap) : cap_(cap), data_(alloc_.allocate(cap)) {} ~TinyStorage() { while (size_ 0) pop_back(); // 逐个 destroy只清对象 alloc_.deallocate(data_, cap_); // 最后统一归还内存 } void push_back(const T v) { std::allocator_traitsdecltype(alloc_)::construct(alloc_, data_ size_, v); size_; } void pop_back() { --size_; std::destroy_at(data_ size_); // 对象析构内存不动 } std::size_t size() const { return size_; } private: std::allocatorT alloc_; T* data_; std::size_t size_ 0; std::size_t cap_; };pop_back之后data_ size_位置的内存仍然归 TinyStorage 所有再一次push_back时直接 construct 到同一个位置。对象是短期住户内存是长期资源——想明白这个模型后面所有坑基本都能避开。3. C17 弃用、C20 移除一条被吐槽但逻辑自洽的演进路线3.1 为什么好好的成员函数要被踢出去std::allocator::destroy被标记弃用乃至删除理由可以从三个层面理解第一职责越界。分配器的本职是管理内存也就是allocate和deallocate。对象的构造与析构属于生命周期管理不该由分配器来管。把construct/destroy塞进分配器里等于把对象生命周期和内存生命周期强行耦合。设计上的洁癖放到工程里就是实打实的困惑自定义分配器作者经常会问这两个成员到底要不要实现标准说不要也行可 std::allocator 里偏偏又有学起来很拧巴。第二allocator_traits早就接管了这个活。C11 引入std::allocator_traits后容器代码的正确姿势是调用allocator_traits::construct / allocator_traits::destroy由 traits 决定分配器有对应成员就用成员没有就用 placement new 和p-~U()兜底。所以说std::allocator自己的construct/destroy成员是历史遗留的糖缺了它们泛型代码一点也不受影响。第三标准对分配器的要求从 C11 起就不再强制包含construct/destroy成员。既然上层已经全部通过 traits 转发继续保留这俩成员只会增加维护成本和误解空间。C16 提案里它们被正式标为 deprecatedC20 再由清理废弃设施的一组提案P0619R1正式移除。与此同时C17 引入了自由函数std::destroy_at / std::destroy / std::destroy_n把析构对象这个操作从分配器成员中彻底解放出来。3.2 演进时间线标准版本std::allocator::destroy 的状态推荐替代写法C98有效唯一的销毁手段alloc.destroy(p)C11有效变成成员模板allocator_traits 出现allocator_traits::destroy(alloc, p)C17弃用编译出现警告std::destroy_at 进标准std::destroy_at(p)C20删除老代码直接编译失败allocator_traits::destroy/std::destroy_at3.3 自由函数为什么更好std::destroy_at(p)不依赖任何分配器类型一个指针参数搞定std::destroy_at(p); // 析构 *p std::destroy(begin, end); // 析构区间内所有对象 std::destroy_n(first, count); // 析构前 count 个对象自由函数的好处至少有两点。第一泛型代码少一层依赖凡是持有裸指针的地方都能直接调用不必为了析构一个对象专门构造一个分配器对象。第二和construct_at对称构成一对标准的手动生命周期管理工具而allocate/deallocate继续留在分配器上边界清晰。正因为这个分工C20 里std::allocator_traits的construct/destroy默认实现也转而调用construct_at/destroy_at整个体系的内部分工终于一致。老代码虽然要改但改完之后会发现生命周期操作的调用点不再依赖具体的分配器类型可读性和可移植性都上了一个台阶。4. 实战迁移三种写法对照与编译现象4.1 三种写法对照std::allocatorItem alloc; Item* p alloc.allocate(1); // 写法1老式直接调成员C98 风格C20 编译失败 alloc.construct(p, Item{1}); alloc.destroy(p); // 写法2走 allocator_traitsC11 推荐的泛型写法目前仍可用 std::allocator_traitsstd::allocatorItem::construct(alloc, p, Item{1}); std::allocator_traitsstd::allocatorItem::destroy(alloc, p); // 写法3现代自由函数C17 起可用C20 起成为默认 std::construct_at(p, Item{1}); // C20 加入 std::destroy_at(p); // C17 加入在 C17 模式下编译写法1GCC/Clang 默认就会在construct/destroy的调用点给出类似is deprecated [-Wdeprecated-declarations]的警告MSVC 也类似只是警告编号不同。到 C20 模式下libstdc、libc、MSVC 的std::allocator里这两个成员已经删除写法1直接编译失败报错基本是no member named destroy in std::allocatorItem。所以迁移窗口其实很长先靠警告定位再靠删除强制收尾。4.2 两类老代码的不同改法实际迁移时老代码通常分两种第一类直接用std::allocatorT的。这种最简单把alloc.destroy(p)改成std::destroy_at(p)把alloc.construct(p, args...)改成std::construct_at(p, args...)或allocator_traits::construct(...)。destroy_at和旧的成员版本语义完全一致基本是逐字替换。第二类用自定义分配器的。规范写法本来就该走allocator_traits如果之前直接调了a.destroy(p)而分配器恰好又没有实现成员C11 后完全合法这一段代码早就编译不过了。改成allocator_traits::destroy(a, p)不仅解决编译问题还顺手修掉了一个潜在的兼容性隐患。我的建议是泛型容器代码里一律只写allocator_traits::destroy_at/construct永远不要直接调用分配器的成员函数。4.3 一个容易忽略的细节trivial 类型的无操作优化对int、double、POD 这类析构函数平凡trivially destructible的类型std::destroy_at(p)在语义上也要调用析构函数但实际上平凡析构函数什么也不做。标准库的std::destroy/std::destroy_n实现里通常有针对平凡析构类型的专门路径直接跳过整段循环单次destroy_at则会被编译期优化成空操作。所以别担心性能也别脑补平凡类型也强制走一遍析构流程。顺带提一个不太常见的用途std::destroy_at在 C20 起是constexpr的配合 C20 允许的constexpr动态分配可以在编译期求值的代码里管理带析构函数对象的生命周期。日常写业务代码基本用不上但了解这一点遇到相关模板代码时不会误判它的能力边界。4.4 验证生命周期流程的小技巧改完代码怎么确认析构真的在正确时机执行了我习惯放一个探针类型struct Probe { inline static int alive 0; inline static int destroyed 0; Probe() { alive; } Probe(const Probe) { alive; } ~Probe() { --alive; destroyed; } };拿Probe走一遍allocate / construct_at / destroy_at / deallocate打印alive和destroyed构造一次alive加 1destroy_at一次alive减 1、destroyed加 1deallocate不应该改变这两个数字。如果destroyed数量偏少说明析构路径漏了如果alive不为 0说明配对或顺序出问题。这种探针类型在带析构函数对象的容器测试里百试百灵比翻代码盯半天快得多。5. 实战中的坑destroy 该怎么配、不该怎么配5.1 最常见的四个配错方式这几个错误我几乎每年都会在代码评审里看到值得列成一张表错误写法后果destroy 之后忘了 deallocate内存泄漏对象析构了底层内存没还直接 deallocate 不 destroy未定义行为非平凡析构类型的析构不会发生资源泄漏对未 construct 的内存 destroy未定义行为在垃圾数据上调用析构函数同一个指针 destroy 两次未定义行为对已析构对象再次析构这里特别说下第二类。有人觉得反正马上要 deallocate析构就不调了吧对int这种平凡类型可能侥幸没事对带unique_ptr成员、持有文件句柄、维护引用计数的类型就是实打实的资源泄漏加未定义行为。析构函数的调用次数必须和构造次数严格相等这是手动内存管理的铁律。5.2 记住两层配对关系老话说delete配new在 allocator 的世界里要记两层allocate配deallocate内存层的配平。construct配destroy对象生命周期层的配平。一个对象从allocate到deallocate之间construct和destroy必须恰好各执行一次。这也是我前面TinyStorage析构函数先while逐个pop_back、最后才deallocate的原因析构循环负责对象层归还内存负责内存层两层各配平一次互不干扰。5.3 别和 new/delete 混着用同一块内存上如果用了 allocator 的allocate就老老实实走配套的deallocate如果打算用new/delete就别把 allocator 掺进来。std::allocator的默认实现可能基于operator new / operator delete但分配器接口的意义是让调用方与底层内存来源解耦——对象池、竞技场分配器、内存映射区域都是常见自定义场景。竞技场分配器是极端例子deallocate甚至可以是空操作所有内存在最后一次性整体释放。此时destroy_at是唯一负责走析构路径的环节一旦漏了整个对象池的资源泄漏问题会延迟到进程结束才暴露极难排查。在这种设计里析构与释放彻底分开不是抽象概念而是救命的工程决策。5.4 什么时候该自己调 destroy什么时候绝对别碰该自己调的场景很明确你手上持有裸内存且这块内存上对象的生命周期由你管理——自定义容器、对象池、内存映射区域以及实现 vector 类容器的教学代码。绝对别碰的场景同样明确std::vector、std::string、std::unique_ptr、std::shared_ptr管理的内存生命周期交给它们手动destroy_at等于和容器抢生命周期控制权必然出问题。栈上对象析构由编译器在离开作用域时负责手动调用等价于二次析构。挂在智能指针上的裸指针把裸指针交给unique_ptr的那一刻所有权已经转移。一句话总结只有当你是内存的最终负责方时destroy_at才该出现凡是使用别人的容器和智能指针的代码根本不需要认识这个函数。5.5 迁移时顺手处理的几个细节改老代码时我习惯顺手做一次destroy 位置审计看看每个调用点附近有没有成对的 construct / placement new。很常见的历史包袱是有人用alloc.construct构造之后却用p-~T()手写析构或反过来。两者效果等价但写法混杂会显著提高理解成本。统一换成construct_at / destroy_at之后文件里的生命周期管理一眼就能看懂。还有一个容易踩的点std::destroy_at并不像delete那样允许空指针。delete nullptr是安全的但把空指针传给destroy_at是未定义行为。老代码里如果有if (p) alloc.destroy(p);这种带空指针保护的写法迁移后要保留这个判断if (p) std::destroy_at(p);千万别因为换了个函数就把条件丢了。最后说点个人体会。我前几年维护一个手写内存池组件时就是被alloc.destroy的 deprecation 警告逼着去读了一圈标准。读完反而觉得这套设计很顺分配器管内存生命周期工具管对象各司其职。后来组件里所有析构调用都换成了std::destroy_at配上一个探针类型做回归测试再也没出过漏析构或重复析构的事。如果你也在改类似代码与其逐行搜destroy不如先花十分钟把内存生命周期和对象生命周期是两层这个模型想清楚后面基本不会改错。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进