从 Variant 理解指针可互转换与 std::launder

阅读 stl1weekend 的 Variant.hpp 时,有人建议给从内部缓冲区取得对象指针的 reinterpret_cast 配上 std::launder。困惑在于:对象已经通过 placement new 创建,指针的地址也对,为什么还要多这一步?

关键是区分三个问题:对象是否存在、指针指向哪个对象、旧对象被替换后旧指针是否自动指向新对象。它们分别涉及生命周期、指针可互转换与透明替换。std::launder 能在满足前提时取得指向存活对象的指针,但它不能创建对象。

本文示例使用 C++17 及以后,按缺陷报告修正后的规则解释。存储与对象生命周期的基础见 Vector 中的 placement new;Optional 的存储与对象生命周期已经展示过内部空间与存活对象的区别。

Variant 的存储与对象指针

简化后的 Variant 可以为候选类型预留同一块空间,用标签表示当前存活的是哪一种类型:

alignas(T) std::byte storage[sizeof(T)];
T* created = ::new (static_cast<void*>(storage)) T(/* 参数 */);

storage 提供大小和对齐合适的空间;placement new 在其中创建 T。created 是 new 表达式的返回值,直接指向新对象,所以用 created 访问它不需要为了取得对象指针而调用 launder。

问题发生在不保存 created,而是稍后从缓冲区恢复指针的时候:

T* candidate = reinterpret_cast<T*>(storage);
T* current = std::launder(candidate);

这里的 storage 先转换成指向首个字节元素的指针。reinterpret_cast 使表达式的类型成为 T*;但标准还要回答:它是否指向那个新建的 T? 指针表示相同的地址、静态类型又是 T*,仍不足以自行得到这个结论。

原项目使用 char 数组。本文改用 std::byte:标准的 [intro.object] 专门规定 unsigned char 或 std::byte 数组在满足条件时可以为对象提供存储,而且数组自身继续存活。普通 char 不在该条规定的数组类型中;CWG 2489 也讨论过 char 数组与隐式对象创建规则的不一致。这不意味着仅凭该 issue 就能断言每种在 char 数组上 placement new 的代码都必然未定义。

指针可互转换

指针可互转换是两个对象之间的关系。它的作用是:当指针指向其中一个对象时,在满足相应转换条件下,可以通过指针转换取得指向另一个对象的指针。

struct Point {
    int x;
    int y;
};
 
Point point{10, 20};
Point* whole = &point;
int* first = reinterpret_cast<int*>(whole);
 
*first = 42;  // 修改 point.x

Point 是标准布局类,point 和它的首个非静态数据成员 point.x 指针可互转换。上面的转换结果因此指向 point.x;不需要再用 launder 来寻找这个成员。

Point 对象 point  ──指针转换──>  int 对象 point.x
     ↑                                  ↑
 Point* whole                        int* first

标准的 basic.compound 列举的关系包括:同一个对象;union 对象与其非静态数据成员;标准布局类对象与其首个非静态数据成员或基类子对象;以及通过这些关系得到的传递关系。

指针可互转换意味着对象具有相同地址,但反过来不成立。例如数组对象与它的首元素有相同起始地址,标准却明确指出它们不因此指针可互转换。日常 int* p = array; 之所以指向首元素,是由单独的数组到指针转换规则保证的。

还要把“得到指向对象的指针”和“允许访问对象”分开:union 与成员可以指针可互转换,这并不使未处于生命周期内的成员自动变成活跃成员。

缓冲区为什么不同

字节元素与放在字节数组中的 T 一般不满足上述可互转换关系。对象指针之间的 reinterpret_cast 按规则等价于经由 void* 的相应转换:目标类型匹配且对象指针可互转换时,转换结果可指向目标对象;否则指针值保持不变。对齐不满足时还有单独的规则。

因此,在通常的字节缓冲区场景里,reinterpret_cast<T*>(storage) 的结果虽然具有 T* 类型,却不能仅据此保证指向内部 T。转换表达式本身并非一概有未定义行为;问题出在把没有得到正确指向保证的结果当作目标对象指针使用。

std-discussion 的讨论对相同场景给出了逐条解释。讨论中的 &storage 指向整个数组;本文表达式 storage 经数组转换后指向首元素。两者地址相同,但指针最初指向的对象不同;一般情况下,它们都不能仅靠这个转换保证指向内部的 T。邮件参与者的解释不是委员会正式决议。P3006R1 以类似例子提议减少对 launder 的需求;提案本身不等于现行规范。

std::launder 的前提

std::launder 位于 <new>,作用是取得指向指定地址处对象的指针。对 std::launder<T>(p),关键前提是:该地址处已经有生命周期内、类型与 T 相似的对象;而且结果指针可到达的每个字节都必须能通过输入指针到达。具体条文见 ptr.launder。

T* p = std::launder(reinterpret_cast<T*>(storage));
// 用 p 访问;launder 不会原地改变其参数。

它不会开始生命周期、修复对齐、判断 Variant 的标签,或把错误类型变成 T。因此 get<T>() 必须先检查当前标签;容器内部还必须维持“标签与实际存活对象一致”的不变量。

不可互转换也不等于一定需要 launder:若已经保存 new 返回的 T*,就可以使用这个已指向新对象的指针。反过来,即使地址上确实有活着的 T,仍需检查可达性。

Reachability 的范围

这里的 reachability 是标准对指针定义的可达字节范围,不等于“物理地址相邻就能访问”。简化地说:对于指向对象 x 的指针,考虑与 x 指针可互转换的对象 y;y 的存储可达,若 y 是数组元素,其直接外围数组的存储也可达。launder 的结果不能使这个可达范围扩大。LWG 2859 处理了该定义中指针可互转换与数组关系的遗漏。

先看一维数组:

int a[10]{};
int* p = &a[0];
auto q = std::launder(reinterpret_cast<int (*)[10]>(p)); // OK

p 指向 a[0],其直接外围数组是 a,所以输入指针可达到整个 a。目标是数组对象 a;结果可达范围不超过输入范围。int (*)[10] 表示指向 int[10] 数组的指针。

二维数组只多了一层,但这会改变结果:

int x[2][10]{};
int* p = &x[0][0];
 
auto q = std::launder(reinterpret_cast<int (*)[10]>(p)); // 未定义行为
外层数组 x:int[2][10]
┌────────────────────────────┐
│ x[0]:第 0 行,int[10]     │ ← p 指向其中的 x[0][0]
├────────────────────────────┤
│ x[1]:第 1 行,int[10]     │
└────────────────────────────┘

输入 p 指向 int 对象 x[0][0],它的直接外围数组是第 0 行 x[0],所以输入可达范围包括整行 x[0]。定义不会仅因为 x[0] 也是数组元素,就从这里继续递归扩张到第 1 行。

假如 launder 返回 q,它将指向整个 x[0] 数组。此时 x[0] 自身是外层数组 x 的元素,其直接外围数组是整个 x;结果指针可达范围因而包括 x[1]。例如,一个确实指向 x[0] 的 int (*)[10] 指针加 1,会指向 x[1]。这只是帮助理解对象层级;可达不等于允许任意访问。

输入 p 的可达范围:x[0]
结果 q 的可达范围:x[0] + x[1]
结果范围超出输入范围,因此不满足 launder 前提。

通常的 Variant 缓冲区则不同:从 storage[0] 出发,其外围字节数组的范围已可达,完整放在数组内的普通候选对象不会因此扩大范围。仍要确保目标对象确实存活且地址、类型等前提正确。

透明替换与旧指针

透明替换解决的是另一个问题:一个对象结束生命周期,同一位置创建新对象后,原来指向旧对象的指针是否自动指向新对象?

#include <new>
 
struct Widget { int value; };
Widget w{1};
Widget* old = &w;
 
w.~Widget();
Widget* fresh = ::new (static_cast<void*>(&w)) Widget{2};
 
int a = fresh->value; // new 返回的指针,指向新对象
int b = old->value;   // 此例满足透明替换,旧指针自动指向新对象

对本例中的完整对象,旧对象不是 const 完整对象,新旧类型相同且存储完全重合,所以旧对象被新对象透明替换。子对象、基类子对象等还有额外条件,需查阅适用版本的 basic.life。不满足透明替换时,可直接使用 new 返回的新指针;在 launder 前提满足时,也可以用它取得新对象指针。

指针可互转换侧重对象之间的关系及转换结果;透明替换侧重生命周期变化后旧指针、引用和名字是否自动指代新对象。字节缓冲区恢复 T* 时,手上原本是字节指针,直接问题是如何取得当前 T 的指针;不能简单说它“因为不透明替换所以要 launder”。

P0137R1 与 CWG 1776 的工作重塑了对象与指针规则并引入 launder。其中有关含 const 或引用成员对象的示例是历史规则;P1971R0 后来移除了仅因这些成员存在就排除透明替换的限制。不能笼统地说含 const 成员就必需 launder。

Variant 切换活跃类型

切换 int → std::string 时,仅修改标签再把旧存储当作 string 访问是不够的。必须先结束旧对象生命周期,再创建新对象;如果创建可能抛异常,标签还要能表达没有活跃对象。

假设 storage_ 是一块大小和对齐都适合这两种类型的 std::byte 数组,kind_ 记录当前活跃类型。下面是切换过程的示意片段,并非完整类实现。

第一步:结束旧 int 的生命周期

reset();

reset() 根据 kind_ 判断当前对象类型。假设标签是 integer,它取得指向活跃 int 的指针并销毁对象,然后将标签设为 empty:

// reset() 中 integer 分支的示意代码
int* old = std::launder(reinterpret_cast<int*>(storage_));
std::destroy_at(old);
kind_ = Kind::empty;

std::destroy_at 定义在 <memory>,对这里的 int* 调用会结束所指 int 对象的生命周期。int 不需要释放资源,但它的生命周期仍然结束;如果旧类型是 std::string,相应分支会取得 std::string* 并调用 std::destroy_at,进而调用字符串的析构函数,释放其持有的资源。销毁对象不会释放 storage_ 这块存储。

destroy_at 要求指针指向应当销毁的对象,所以必须先保证标签与实际存活对象一致;launder 不会替 reset() 检查这一点。

第二步:在同一存储中构造 string

std::string* p =
    ::new (static_cast<void*>(storage_))
        std::string(std::forward<Args>(args)...);

真正创建 string 的是这里的 placement new,reinterpret_cast 本身不会构造对象。reset() 后标签为 empty;如果字符串构造抛出异常,就保持这个状态,不去销毁未成功构造的字符串。

第三步:取得指向新对象的指针

上面的 p 直接来自 new 表达式,已经指向新创建的 string,可以直接使用,不需要再调用 launder。

假如容器没有保存 p,之后的 get_string() 则可以在检查 kind_ == Kind::string 后,从缓冲区重新取得指针:

std::string* current =
    std::launder(reinterpret_cast<std::string*>(storage_));

只要容器维护了标签与存活对象一致的不变量,这次检查就能保证当前位置确实有一个活着的 string。此处还要满足前文介绍的地址、类型和可达性前提。

第四步:构造成功后更新标签

kind_ = Kind::string;

这个赋值放在 placement new 之后。这样,标签表示的始终是已成功构造的对象:构造失败时为 empty,成功后为 string。在之后销毁或切换类型时,再按标签调用对应的 std::destroy_at。

构造参数不能依赖 reset() 刚刚销毁的旧对象;切换类型后,原来指向旧对象的指针和引用也不能继续用于访问它。

参考文献