C++17之std::launder函数
1.背景
理论上,指针并不是一个简单表示地址的整数,而是标识一个对象的东西,并不一定要与地址关联。 因此,指针不会随意的访问到另一个不相关的对象,即使两个指针指向相同的地址。 当对象的生命周期结束后,其指针也会失效,即使后续对象占用原对象的空间,它也不会随意变成新对象的合法指针。
常见的不可重用的例子为:类 T 包含非静态 const 数据成员 或 引用数据成员,则在 x 上创建新的 T 对象,原本 x 的指针、引用都不会重新指向新的对象 。
其实是关于 C++ 17 标准库中 std::optional<T> 实现过程中遇到的问题的讨论。假如你是库的作者,这是一个 std::optional<T> 的实现,你能看出来它存在什么问题吗?
template<typename T>
class optional {
private:T payload;
public:optional(const T& t) : payload(t) {}template<typename... Args>void emplace(Args&&... args) {//结束对象的生存期payload.~T();//原地构造新对象,但使用旧对象的指针::new (&payload) T(std::forward<Args>(args)...); // *}const T& operator*() const & {return payload; // Not OK//return *(std::launder(&payload)); // OK//也可以拿一个指针成员保存 placement new 的返回结果,但显然很麻烦}
};struct A {constexpr A(int &x) : ref(x) {}int &ref;
};int main() {int n1 = 0, n2 = 0;optional<A> opt2 = A(n1);opt2.emplace(n2);std::cout << opt2.ref << std::endl; //?
}
是的,它的问题就是会触发 UB(Undefined Behavior未定义行为),当我们在 payload 的存储位置上创建新对象以后,因为 A 存在引用类型成员,导致通过原始 payload 访问 ref 成员的时候,有可能访问的是原始绑定的 n1。同样,解决方案是引入 std::launder() 函数,如果不使用 std::launder() 函数,则解决方案就只能是这样:
template<typename T>
class optional {
private:T payload;T *p;
public:optional(const T& t) : payload(t) {p = &payload;}template<typename... Args>void emplace(Args&&... args) {payload.~T();p = ::new (&payload) T(std::forward<Args>(args)...); // *}const T& operator*() const & {return *p; // 不使用 &payload}
};
这里引入了一个指针 p,用于保存 placement new 的返回值。因为编译器知道 p 是个新对象的地址,对 p 的访问将会重新开始,不存在之前基于 payload 地址上的优化干扰,行为是明确的,不会产生 UB。但是这种做法也引入了一点开销,这是标准库不能接受的。
2.std::launder简介
std::launder
是 C++17 引入的一个标准库函数模板,主要用于处理对象生命周期结束后,通过指针访问新创建对象时的 ** 指针 “清洗”(pointer laundering)** 问题,避免因编译器优化导致的未定义行为(Undefined Behavior, UB)。
通俗的讲,std::launder() 函数的核心作用是显式告知编译器:指针所指向的内存内容可能已被外部修改(比如对象已经重新构造),需要重新加载数据(不能再使用优化器之前针对这个地址访问的时候产生的缓存),确保后续对指针的访问正确反映新对象的实际类型。
3.可触及性(reachability)限制
通过 std::launder() 函数可以得到源指针被“清洗”后的新指针(指向同一个地址),新指针的访问可触及性不能超过源指针,也就是说,通过源指针不能访问的的位置,通过新指针也是不能访问的。可以这么理解,对一个地址“清洗”的范围是有限的,通过新指针访问超过可触及范围的内容,依然是 UB。这是一个比较让人疑惑的点,我们来看看 std::launder() 函数的原型:
template< class T >
constexpr T* launder( T* p ) noexcept;
既然源指针 p 和返回的新指针都是同一个类型,怎么会产生不同的可触及范围呢?答案是当它遇到 reinterpret_cast 的时候。因为 reinterpret_cast 可以在不改变地址可触及范围的情况下改变指针的类型,比如这段代码:
struct A { int a; int b; };
struct B { int y; };std::byte bytebuf[sizeof(A)];
B* pb = new (bytebuf) B{ 5 };
A* pa = reinterpret_cast<A*>(pb); //UB
虽然使用 reinterpret_cast 可以将 B* 指针转换成 A* 指针,但是结果 pa 的可访问范围仍然是和 pb 的范围一样。当它叠加到 std::launder() 函数的时候,就会出现违反可触及性限制的情况,比如:
A* pa = std::launder(reinterpret_cast<A*>(pb)); //OK
int c = pa->b; //UB
另一个可能违反可触及性的场景是配合数组使用的时候,指向数组元素的指针和数组的地址经常把人搞晕。来看这个例子:
struct Y { int a[10]; double y; } x5;auto p5 = std::launder(reinterpret_cast<int(*)[10]>(&x5.a[0]));
&x5.a[0] 的类型实际上是 int*,所以它能访问的范围就是 x5.a 这个数组,但是我们用 reinterpret_cast 把它强转成 int[10] 类型的指针,但是并不改变它的可触及范围(依然是 x5.a 这个数组)。再用 std::launder() 洗它,得到一个新的 int[10] 类型的指针,它指向的新对象(数组)的位置就位于 x5 内部,地址上与 x5 重叠,理论上,它可以访问整个 x5,这就违反了可触及性限制,会导致 UB。
4.典型应用场景
以下情况通常需要使用 std::launder
:
1)在已销毁对象的内存上构造新对象:当通过 placement new
在原对象内存上构造新对象(类型可能不同)时,需要用 std::launder
获取新对象的有效指针。
2)访问被覆盖的多态对象:如果新对象是原对象的派生类(或反之),或涉及虚函数表(vtable),直接使用原指针可能导致访问错误的虚函数表,需用 std::launder
修正。
尽管上述描述已经对标准上的说法做了重新理解,但是这两句话依然还是有点抽象,我个人喜欢将其转化成以下三种适配场景:
-
在已存在的存储空间上构造新对象(如 placement new);
-
通过 reinterpret_cast 或 memcpy() 函数修改对象的动态类型;
-
多态对象的内存布局变化(如通过 union 修改活跃成员,在 Base 对象存储位置上创建 Derived 对象)。
示例代码如下:
#include <new>
#include <cstddef>
#include <cassert>struct X {const int n; // 注意: X 拥有 const 成员int m;
};struct Y {int z;
};struct A { virtual int transmogrify();
};struct B : A {int transmogrify() override { new(this) A; return 2; }
};int A::transmogrify() { new(this) B; return 1; }static_assert(sizeof(B) == sizeof(A));int main()
{X *p = new X{3, 4};const int a = p->n;X* np = new (p) X{5, 6}; // p 不指向新对象,因为 X::n 为 const ,而 np 指向新对象const int b = p->n; // 未定义行为const int c = p->m; // 未定义行为(即使 m 为非 const ,也不能用 p )const int d = std::launder(p)->n; // OK : std::launder(p) 指向新对象const int e = np->n; // OKalignas(Y) std::byte s[sizeof(Y)];Y* q = new(&s) Y{2};const int f = reinterpret_cast<Y*>(&s)->z; // 类成员访问为未定义行为:// reinterpret_cast<Y*>(&s) 拥有值“指向 s 的指针”// 而非指向 Y 对象const int g = q->z; // OKconst int h = std::launder(reinterpret_cast<Y*>(&s))->z; // OKA i;int n = i.transmogrify();// int m = i.transmogrify(); // 未定义行为int m = std::launder(&i)->transmogrify(); // OKassert(m + n == 3);
}
5.std::launder和reinterpret_cast的区别
std::launder
和 reinterpret_cast
都可以用于指针类型的转换,但它们有以下区别:
功能目的
std::launder
主要用于处理在对象生命周期结束后,在同一内存位置创建新对象时,更新指针的类型信息,以确保对新对象的正确访问,避免编译器优化导致的未定义行为。reinterpret_cast
是一种较为底层的强制类型转换,用于将一种指针类型转换为另一种指针类型,它可以在不同类型的指针之间进行任意转换,包括不相关的类型。
安全性
std::launder
是在遵循 C++ 内存模型和对象生命周期规则的前提下,安全地更新指针类型,只要正确使用,不会导致未定义行为。reinterpret_cast
的转换相对不安全,因为它可以进行各种不保证兼容性的指针转换。如果转换后的指针类型与实际指向的对象类型不匹配,在访问对象时可能会导致未定义行为。
适用场景
std::launder
适用于在特定的内存管理场景中,如在已销毁对象的内存上构造新对象,或者涉及对象类型替换且需要确保编译器正确处理指针类型的情况。reinterpret_cast
适用于一些底层的、与硬件相关的编程,或者在需要对指针进行特殊的、不遵循常规类型规则的转换时使用,但要谨慎使用,以免引发错误。
示例代码:
#include <iostream>
#include <new>struct OldType { int value; };
struct NewType { int value; };int main() {// 分配内存alignas(OldType) unsigned char buffer[sizeof(OldType)];// 构造OldType对象OldType* old_ptr = new(buffer) OldType{42};// 析构OldType对象old_ptr->~OldType();// 在同一块内存构造NewType对象NewType* new_ptr_raw = new(buffer) NewType{100};// 使用std::launder获取正确的指针NewType* new_ptr_launder = std::launder(new_ptr_raw);std::cout << "New value (std::launder): " << new_ptr_launder->value << std::endl;// 使用reinterpret_cast进行转换NewType* new_ptr_reinterpret = reinterpret_cast<NewType*>(old_ptr);// 可能导致未定义行为,因为reinterpret_cast没有考虑对象生命周期和类型变化std::cout << "New value (reinterpret_cast): " << new_ptr_reinterpret->value << std::endl;return 0;
}
在上述代码中,std::launder
能够正确处理指针类型的更新,而 reinterpret_cast
虽然进行了指针类型转换,但没有考虑对象生命周期的变化,可能导致未定义行为。
6.注意事项
1)内存必须重叠:新对象必须完全覆盖原对象的内存空间(即使用同一块 buffer
),否则 std::launder
无法生效。
2)类型需合法:新对象的类型不能是原对象的 “严格别名规则” 禁止的类型(例如不能用 std::launder
将 int*
转为 float*
,除非符合别名规则)。
3)非万能工具:std::launder
不能修复非法内存访问(如越界指针或已释放的内存),仅用于处理 “对象替换后指针类型信息过时” 的问题。
7.总结
std::launder() 函数不会对入参的地址做任何操作,所以 std::launder() 函数总是异常安全的。实际上,std::launder() 函数不会产生任何运行开销,它只是标准库和编译器的一个私有协议,编译器看到 std::launder() 函数就做该做的事情。C++ 很多语言特性是由标准库中的代码提供的,并且库和用户代码是平权的,标准库能做的事情,用户代码也能做。但是也有部分内容是需要库和编译器配合的,比如协程,operator new 之类的,本篇介绍的 std::launder() 函数也算一个。
而且std::launder
是 C++ 中处理低层次内存管理(如自定义容器、内存池)的重要工具,其核心价值是通过显式告知编译器指针的 “类型更新”,避免因优化导致的未定义行为。使用时需严格遵循内存重叠和类型合法性的约束。
参考:
std::launder_C++中文网