C++编程C++ 开发者

为什么在销毁对象的地址上通过 placement-new 访问由此创建的对象会导致未定义行为,即使存储保持有效,且不使用 std::launder?

用 Hintsage AI 助手通过面试

问题的答案

当一个对象被销毁后,通过 placement-new 在相同地址创建新对象时,C++ 的指针来源规则规定,原始指针的值不会自动指向新对象。编译器可能会假设特定类型的指针在对象的生命周期内保持其对象身份,从而基于类型别名分析进行激进优化。std::launder 明确创建一个指向新对象的指针,有效告诉编译器该存储现在包含一个新的、可能具有不同类型或 const/volatile 限定符的对象。如果没有这个干预,解引用旧指针会违反严格别名规则,从而导致未定义行为,即使地址包含有效存储。

生活中的情况

考虑一个实时音频处理引擎,它重用固定的缓冲区池,以最小化 CPU 缓存缺失并避免在现场表演期间出现堆碎片。

解决方案 1:标准堆分配

初始原型为每个处理块分配新的音频帧对象,使用 new。虽然简单明了,但这导致了在垃圾回收暂停期间和访问不连续内存时出现听得见的掉音,使其在专业音频中不可接受。

解决方案 2:使用原始指针的 placement-new

团队切换到预分配的 std::aligned_storage_t 数组,并使用 placement-new 进行原地构造帧。然而,他们在重建后仅仅重用了原始指针值。在使用 Clang 的优化构建中,编译器假设从上一个帧的 const 体积成员的指针仍然有效,导致它重用了寄存器中的陈旧值,而不是从内存中重新加载存储不同数据的新帧。

解决方案 3:std::launder 实现

他们在每次 placement-new 操作后引入了 std::launder,以获取指向新对象生存期的指针。这迫使编译器识别内存现在持有一个具有不同值的新对象,防止从已销毁帧中 const 成员的不正确寄存器缓存。

这个解决方案消除了音频故障,同时保持零分配性能,达到亚毫秒延迟要求。

候选人通常忽视的内容


可以使用 std::launder 在不调用对象析构函数的情况下更改活动对象的类型吗?

不可以,std::launder 并不会扩展或改变对象生存期。标准明确要求在应用 std::launder 之前,旧对象的生存期必须结束(析构函数已调用),并在相同存储中开始新对象的生存期。尝试清洗一个尚未结束生存期的对象的指针会导致未定义行为,因为 C++ 抽象机器认为原始对象仍然存在于该地址。


std::launder 会修改指针的底层位模式吗?

不会,std::launder 生成的指针值与原始地址相等,但携带不同的来源信息。尽管实现通常返回完全相同的位模式,但该操作不仅仅是一个转换——它向编译器的别名分析表明该指针现在指向一个新对象。这一区别在编译器对翻译单元进行全程序优化、跟踪通过复杂控制流的指针值时变得至关重要。


对于简单可销毁类型,std::launder 是不是不必要的,因为它们没有析构函数?

即使对于 简单可销毁 类型,每当对象的生存期结束并在相同存储中创建新对象时,std::launder 都是必需的。当其存储被重用时,该对象的生存期结束,无论析构函数是否运行。没有 std::launder,编译器可能会假设通过旧指针访问旧对象的 const 成员保持不变,即使新对象使用不同的 const 成员值进行 placement-new,从而导致潜在的优化错误。