正确实现类型双关

标题:Type punning done right

日期:2025/04/18

作者:Javier Lopez Gomez

链接:https://www.youtube.com/watch?v=NRV_bgN92DI

注意:此为 AI 翻译生成 的中文转录稿,详细说明请参阅仓库中的 README 文件。


今天我们在这里是为了揭开标准 C++ 中两位新朋友的神秘面纱,即 std::launderstd::start_lifetime_as。但我们也要讨论 reinterpret_cast 中的未定义行为。这是一个技术性很强的演讲,但我尽量让它尽可能简单。让我们把它当作一个游戏。

我会问很多问题。首先,在座的各位,请举手,谁以前做过 reinterpret_cast?好的。可怕。很可能是未定义行为。我只是说说而已。我只是说说而已。

好的。首先,让我简单介绍一下自己。我是谁?正如 Jose Daniel 所说,是的,我在 2017 年到 2020 年期间在这里攻读博士学位,主要从事……嗯,很多主题,但在编译器方面,我从事 C++ 契约(contracts)的实现工作。嗯,可能不是最终进入 C++26 标准的那一个,而是当时的提案。我也在 Cling C++ 解释器上工作过,去年我完全投入其中。最近,从 2024 年开始,我一直在 Simperium 担任编译器工程师,我们构建二进制到二进制的混淆解决方案。这听起来也很可怕。好吧。

所以,是的。我们来问几个问题。给定以下 MyType 的定义,它只包含一个 32 位整数和一个浮点数,我们假设这个结构体的大小是 8,对齐要求是 4。我提出的这个例子是未定义行为,还是良构的?我们有一个 char 数组,包含几个字节的值,然后我对它进行 reinterpret_cast 转换成一个指向 MyType 类型的指针,然后我用那个类型做了一些事情。我引用它。我访问那个整数的值,等等。对吧?请举手。谁认为这是正确的?好的。很好。谁认为这是未定义行为?好的。谁不知道?好的。

结果是这是未定义行为,实际上有三个原因导致了这个问题。它违反了严格别名规则。存储空间可能没有为 MyType 正确对齐,并且你正在访问一个生命周期已结束的 MyType 对象。我们稍后会看到更多相关内容。同样,MyType 的定义相同。这次,我创建了一个 MyType 对象,我称之为 foo,我初始化了它,然后我对它进行 reinterpret_cast 转换成 unsigned char 指针,只是为了检查对象的表示形式。也许做一些序列化之类的。谁认为这是正确的? 谁认为这是未定义行为?好的。谁不知道?好的。

大多数人认为是未定义行为。所以,正如我们稍后将看到的,reinterpret_cast 本身并不是未定义行为。它是良定义的,但解引用结果指针是未定义行为。我们稍后会看到更多相关内容。这被认为是标准中的一个缺陷。

我把它传递给一个函数,该函数在 char 数组上做了一些操作。假设是引用。对 char 数组做一些操作。好的。没有明确说明。你说得对。好的。

另一个例子。同样,我有一个 unsigned char 数组,我用这些字节初始化了它,然后我使用 bit_cast(这里有个拼写错误。顺便说一句,这里应该写 o.i)。使用这个 bit_cast。它是良定义的行为吗?是未定义行为吗?你们怎么看?良定义行为?好的。未定义?好的。我不知道。好的。

这是良定义的。注意字节序(Endianness),对吧?因为根据架构的字节序,你可能会得到不同的结果,但它是良定义的。

所以这是演讲的实际内容。我将做一个简短的介绍,这样我们所有人都在同一页面上,我们说同一种语言,即 C++。我将稍微谈谈类型及其布局和对象生命周期。我将介绍严格别名规则。顺便问一下,这里谁知道严格别名规则?我猜对你的 reinterpret_cast 不太好。好的。然后我将介绍几种进行类型双关的策略,然后如果有时间,再讲一些实际案例和一些结束语。

好的。那么让我们从定义未定义行为开始。这是一个很长的定义。你不必全部读完。但长话短说(TLDR),它是标准没有施加任何要求的情况。它的范围从完全忽略该情况(产生不可预测的结果,显然不好),到在翻译或程序执行期间行为异常或错误,或者终止翻译或执行。不好。我不想处于这种状态。

与此相关,我们还有“病式(ill-formed)”的概念。好的。如果一个程序不是良式的(well-formed),它就是病式的。我正在以多种方式引用标准,如你所见。好的。良式程序是根据语法和语义规则构建的程序。所以病式的东西很可能大多数时候甚至无法编译。因为还有“病式,无需诊断(ill-formed, no diagnostic required)”。这意味着程序是病式的,但不需要编译器提供诊断。这种情况会发生,我想你描述过。如果你违反了单一定义规则(ODR),你有多个内联函数的定义。例如,你就处于这种状态。

好的。更多定义。类型双关(Type Punning)。来自维基百科。在计算机科学中,类型双关是任何颠覆或规避编程语言类型系统的编程技术,以实现在该形式语言范围内难以或不可能实现的效果。好的。很好。它没说明太多。长话短说(TLDR)。这基本上是将某个对象的底层内存表示当作一个完全不同的、不相关的类型来访问。例如,这里我有一个 uint32_t,它是一个 32 位整数,将它作为 char 数组访问,我是说,访问每个独立的字节,那就是类型双关,例如。

我们为什么想要这样做?因为它有时很有用,用于序列化、反序列化,有时用于网络代码,也用于调用你可能在 C 库中的遗留代码或其他东西。但正如我们所见,一些 reinterpret_cast 会导致未定义行为。为什么?C++,为什么?

好的。如果你要从这次演讲中记住一条信息,我会把下一个问题抛给你们。两个不同类型的指针,比如说,指向 int 的指针和一个指向 float 的指针,能真正指向同一个内存区域吗?我的意思是,它们可以,对吧?它们可以有相同的指针值,但这有意义吗?我的意思是,如果我在那个内存区域有一个整数,我就有一个整数;如果我有一个浮点数,我就有一个浮点数。但不可能我同时拥有两者,对吧?而且,实际上,这源于编译器可以基于此进行优化。

我们稍后会讲到这点。在 C++ 中,它在统一行为。嗯,如果你访问联合体(union)的非活跃成员(non-active member),我有点剧透了,但是……

好的,快速回顾一下数据类型、它们的布局等等。所以,C++,如你所知,有基本数据类型(fundamental data types)和复合类型(compound types),复合类型包括数组、指针类型、函数类型、类(classes)等等。类类型有基类子对象(base sub-objects)和成员子对象(member sub-objects)。

例如,这里,我尝试使用指针。所以我可能有一个 MostDerived 结构体的定义,它继承自 DerivedDerived 继承自 BaseBase 什么都不继承,基本上,它们每个都定义了一个不同类型的数据成员。所以编译器在内存中可能这样布局 MostDerived。在偏移量 0 处,它可能遵循继承链并布局 Base 子对象,它只有一个整数,布局在这里。然后,它将布局 Derived,它在这里有 float 数据成员,然后,它只布局 MostDerived 的数据成员,即 double

所以,基本上,当你有一个完整的类型定义时,编译器知道对象所包含的每个子对象的相对偏移量,通过 static_castdynamic_cast,你可以访问其中一个或另一个,对吧?我相信你们都知道,但只是提醒一下。

类型也有大小(size)和对齐要求(alignment)。对齐要求是被大大遗忘的。

标准布局类(Standard layout class)。有很多条件。我认为在 ISO C++ 标准文本中有超过 10 条,但太长了,不用读。它是指所有静态成员都在同一个类中定义的类型。非静态成员也是标准布局类型,并且没有虚函数(virtual functions),没有虚基类(virtual bases)。这是形式化定义,意思是,好吧,你所有的成员都可以被布局。即使有继承链。所有的成员,所有的数据成员都可以被布局,可以被扁平化为一个单一的标准布局结构。所以第一个数据成员将在偏移量 0 开始,其余的紧随其后。

而 POD(Plain Old Data)基本上是一个标准布局类型,同时还是可平凡复制的(trivially copyable)和可平凡构造的(trivially constructible)。

好的。存储期(Storage duration)。你还必须谈谈存储期,对吧?与生命周期(lifetime)相对。它们不一样。我们有,这只是一个总结。存储期。我们有静态(static)、线程局部(thread_local)、自动(automatic)和动态(dynamic)。我不想重复你之前做过的整个演讲。所以,静态的基本上存在于程序的整个持续期间,线程局部存在于线程的生命周期内并且是每个线程的。自动的是与作用域相关的东西,当我们离开作用域时它会被销毁,而动态是在运行时分配和重新分配的东西。好的。

我们还有对象生命周期这个概念。标准说,对象通过定义(definition)、通过 new 表达式、通过隐式创建对象的操作(这是在 C++20 中添加的,我们稍后会看到)、当隐式更改联合体的活跃成员时(这是为了回复刚才提到联合体的人)、或者当临时对象被实质化(materialized)时被创建。好的。我们稍后会明白为什么。

对象的生命周期在以下情况结束时结束:如果是非类类型,对象被销毁。好的。毫不意外。如果是类类型,析构函数开始调用或者对象占用的存储空间被释放或被其他东西重用。好的。

我们将通过一个例子看看这一切意味着什么。所以基本上,我可以定义类型 Tstd::vector<int>,例如。我可以在这里保留一些动态存储空间,它具有 T 的对齐要求和大小。所以 p 指向一个足够大并且对齐良好以容纳 vector<int> 的区域。然后,好吧,我们做一些转换,让它指向 vector<int>,然后尝试 push_back 一些东西。

好吧,我提前剧透,因为这里有注释说这是未定义行为,因为我们为 vector 保留了空间,但我们从未构造 vector 本身。所以这里正确的处理方法是:在指针上执行放置 new(placement new),然后构造 vector,然后我们可以 push_back,然后我们销毁 vector。所以第一个 vector 的生命周期就是这个,它跨越到这里。如果我尝试在生命周期结束后 push_back 一些东西,这也是未定义行为。

同样地,我可以重用存储空间,执行第二次放置 new 来构造第二个 vector,并尝试 push_back 一些东西,销毁 vector,这又将是未定义行为,因为对象已经被销毁了。最后但同样重要的是,我们释放分配的存储空间。

到目前为止一切顺利,对吧?事情是,有问题吗?有什么问题吗?是的,它本身不是 malloc,它是 operator new,但用这个函数分配的存储空间也可以用 free 释放。到目前为止,对于 vector 来说很清楚,我们必须以某种方式构造对象并以某种方式销毁它。但对于平凡类型(trivial types)和基本数据类型也是如此。如果它是一个 int 而不是 vector<int>,我也必须以某种方式创建一个 int

这里的这个例子取自一份论文,嗯,来自这份为纳入 C++20 而提议的论文,基本上我们有一个结构体,只包含几个整数,然后我们使用 malloc 来为至少足够容纳 struct X 的空间保留位置,然后我们解引用指针并对两个成员赋值。

又是未定义行为。请举手,良定义行为?你们怎么看?未定义?良定义?好的。

在 C 中这会是良定义的。在 C++20 以下的 C++ 中,这将是未定义行为。而在 C++20 及更高版本中,它是良定义行为,因为……好吧,请继续,如果还不清楚,也许你可以稍后提问。基本上,C++20 提出了一种叫做对象的隐式生命周期创建(implicit lifetime creation)的东西。所以基本上,如果编译器可以推断对象的类型,并且该指针指向的位置没有该类型的对象,编译器在某些情况下,并且在某些时间点可能发生,它可能会隐式创建该类型的对象。这里,编译器可以通过你使用 malloc 的方式推断出来,因为你转换成了指向 X 的指针,它知道如果指针指向的位置没有 X 对象,它会在那里隐式创建一个。所以这实际上相当于 malloc 加上某种 placement new。是的,非常令人惊讶,对吧?

这更令人惊讶:在特定地方才会发生这种情况:内存分配函数(memory allocation functions)。是的,请讲,如果之后进行放置 new 会怎样?那么如果你在使用 C++20,它会为 X 创建一个隐式生命周期的对象,这个对象之后会死亡吗?因为 new 进行放置 new 时,基本上当你重用存储空间时,它会隐式销毁对象。所以生命周期结束,并开始另一个东西的生命周期。正如我所说,它发生在保留存储空间的函数中,比如 mallocoperator newcalloc 和其他几个函数,但也发生在一些奇怪的其他函数中,如 memcpymemmove。我们不会在演讲中深入探讨,因为那可能需要另外一小时。但如果你好奇,也许可以在谷歌上搜索一下。还有关于 bit_cast

好的,快速提醒一下对齐要求(alignment requirements)。如前所述,被大大遗忘的对齐要求决定了哪些地址对于给定数据类型是合法的。假设我有一个 intint 的大小是 4,该整数的对齐要求是 4,这意味着这样的整数类型可以出现在偏移量 0、偏移量 4、偏移量 8 等处,但不能出现在偏移量 3。更正式地说,偏移量必须是对齐要求的整数倍,否则不好。底层硬件可能实际上不支持这种不对齐的访问。编译器,如果你错位(misalign)了一个类型,这是编译器特定的机制,但如果你错位了一个类型,如果你错位访问一个给定类型的对象,编译器可能会生成必要的指令(这些指令会更多)来正确地访问该值,但即使你处于支持不对齐访问的硬件架构中,它也会更慢。

最后但同样重要的是,在本节中,结构体的填充(padding)和打包(packing)。为了确保这种对齐要求,编译器如你所知可能会插入填充字节(padding bytes)。如果我假设 char 的大小是 1(这是一个相当大的假设,它可能不成立),我在这里放一个 char,然后后面是一个 int,那么编译器可能会插入一些填充字节。假设 int 大小是 4 且必须在 4 对齐,而 char 大小是 1,编译器可能在两者之间插入 3 个填充字节。这些填充字节的值是未指定的(unspecified),它可以是任何值。我们稍后会看到更多相关内容。而打包结构(packed structure)基本上是不以任何方式包含填充的结构。正如我所说,编译器可以自由生成更多指令来访问特定的数据成员。这是编译器特定的。我不认为我们在委员会里有关于这方面的东西,因为使用 alignas 你不能低于类型的自然对齐要求。所以你必须使用 #pragma pack 之类的东西,这无论如何都是编译器特定的。总之,不要这样做。但……不是不符合……嗯,但我们稍后会讨论序列化。还有字节序(endianness)。是的,稍后再谈。嗯,无论如何,如果你知道你在做什么,你可以这样做,但请记住,它是编译器特定的。所以你可能需要使用 __attribute__((packed))(GCC)或 #pragma pack(MSVC),无论是什么,它是编译器特定的。ISO 标准没有提供受祝福的(blessed)方式来做这件事。

现在,严格别名(Strict aliasing)。别名(Aliasing)是什么?它基本上是指通过多个名称(比如指针或引用)引用同一个内存地址。严格别名是指只有相同类型的指针才能实际指向同一个对象(即同一个内存地址)这一事实。标准的规范性文本(normative text)说,如果类型是类型可访问(type accessible)的,则是可以的。另一种说法是,如果类型本身是……该类型本身?所以对一个 int 进行 reinterpret_castint 是安全的,但没用。该类型是该类型的有符号或无符号变体?我和某人讨论过这个,这是正确且允许的。这是一个有趣的情况:任何类型都可以被 charsigned charstd::byte 别名访问。稍后会有更多内容,因为正如我在开头的一个问题中所说,这在一定程度上是未定义行为,你马上就会明白为什么。当然,如果你试图通过一个不是类型可访问的泛左值(glvalue)来访问对象的存储值,其行为是未定义的。所以如果你进行 reinterpret_cast,原则上转换到任何不属于上述情况的东西,都是未定义行为。其中还有一些细节,我们稍后会看到,但这是一个很好的总结。

好的,一个非常明显的例子。我有一个函数……我不知道时间了……好吧。我有一个函数,它接受一个指向 int 的指针和一个指向 float 的指针。该函数基本上……解引用指针 p,递增该值,然后……抱歉,解引用指针 f,我赋值一个值,然后在这里我返回递增之后的值。好的,你凭直觉可能会说,嗯,在这里写入一个 float 不可能改变一个整数的值,对吧?你的直觉会这么说。但如果我这样调用这个函数:我提供一个指向整数的指针,然后是对同一个整数指针进行 reinterpret_cast 得到的 float 指针。所以我在这里递增了整数,然后我在同一个位置覆盖了内存,然后我返回。那会做什么?第一个也是最后一个汇编列表,我保证。使用 -O0(无优化),你看到……这里你要注意。这里你看到编译器获取指针的值,解引用它,将值存储在 ECX 中,递增它,然后再次将该值存储为 *p。然后在返回语句的最后部分,它再次获取指针的值并再次获取该值。所以,顺便说一句,如果这里的 *f = ... 赋值操作由于某种原因改变了那些字节的值,这个操作将会再次重新获取它。但如果我们用 -O2 编译,我们会得到这个……顺便说一下,编译器假设:好吧,我解引用指针 p,将值存储在 ESI 中,递增 ESI 并将其放入 EAX(顺便说一下,EAX 是第一个返回值的返回寄存器)。所以基本上这是对 *f 的赋值写入。所以基本上,如果这个操作覆盖了被整数占用的内存区域,编译器不会再获取一次;它假设我的意思是,如果你指向一个整数,你就指向一个整数,而不是一个浮点数,对吧?听起来合理。

一个更复杂的例子:BSD 套接字 API。有谁熟悉这个?好的,不错,不差。所以你可能知道,我们有 sockaddr,它是描述任何 BSD 套接字支持的地址族中的地址的基础结构。我们有这个结构体,它是用于 AF_INET 地址族的。所以我填充一个这种类型的结构体 sockaddr_in,然后进行 reinterpret_cast,因为 bind 函数……抱歉,我忘了在这里放函数的原型以便你们理解,但它接受一个 sockaddr* 指针。那么,这个 reinterpret_cast 有效吗?它违反严格别名规则吗?我的感觉是,不,它没有违反。好的。我们只是告诉编译器:好吧,给我指针值,就好像它是指向别的东西的指针一样,我们把它给了一个甚至不是 C++ 的函数(它使用 C ABI)。所以到目前为止还好。如果我们从这里用 C++ 引用这个东西,那对你会很不利,尽管 C++ 纯粹主义者会骂我。但有另一种方法可以做到这一点,我稍后会描述。

好的,演讲的主要部分:正确地进行类型双关。做类型双关的人,我见过很多做法。那边有人提到使用联合体(union)。在 C++ 中,不要这样做。我将描述一个特定的有效情况,但一般来说,不要。使用 reinterpret_cast,可能结合 std::launder(我看到没人知道 std::launder 是做什么的,但人们只是把它放进去,因为他们看到别的地方这么用了)。使用 memcpy。使用 std::bit_cast。以及 std::bit_cast 的错误用法(我也见过)。还有 std::start_lifetime_as。其中一些在 C 中是有效的,比如联合体,但在 C++ 中不是。如果你真的想可移植,请记住以下几点:基本数据类型的大小没有明确定义,它在不同架构和目标之间变化。所以你不能假设 char 的大小是 1,int 的大小是 4。我的意思是,在特定平台上,int 的大小可能是 2。如果你真的很在意大小,或者如果你真的很在意类型的长度,请使用固定长度类型,比如 int32_tuint64_t 等等。而且,CHAR_BIT 在某些情况下可能不是 8。我的意思是,ISO C++ 标准规定它必须至少是 8,但只有 POSIX 系统要求它正好是 8。好的,基于联合体(Union-based)。好的。根据标准条款 [class.union] 的一般规则,它说:最多只有一个非静态成员是活跃的(active)。访问联合体的非活跃成员是未定义行为。这意味着,如果我有一个联合体,它有一个整数,并且它与某个 char 数组在内存中重叠,然后我用这个值(一个整数)初始化联合体,那么活跃成员是 i(整数),然后访问 c 将产生未定义行为,因为它不是活跃成员。一个非常不同的做法是赋值给它。标准中有另一个条款说,赋值将隐式开始该成员的生命周期并使其成为活跃成员。所以一般来说,为了回答关于联合体的问题:不要这样做,至少在 C++ 中不要。如果你在写 C,请随意。为什么 int 是活跃成员?因为当我创建一个类型是该联合体的对象时,我用一个整数值初始化了它,所以活跃成员变成了 int。编译器会推断出来。是的,它不可能是 char 数组,语法无论如何都是错的。

标准中唯一的例外……很难做这样的演讲而不引用标准来知道我们在哪里有定义行为,哪里没有。[class.union.general] 说:注意一:为了简化联合体的使用,做出了一个特殊的保证:如果一个标准布局联合体(standard layout union)包含几个共享公共初始序列(common initial sequence)的标准布局结构体(standard layout structs),并且如果这个标准布局联合体类型的对象的某个非静态数据成员是活跃的,并且是这些标准布局结构体之一,那么任何成员(指联合体中其他结构体成员)的公共初始序列都可以被检查。回到 BSD 套接字的例子,基本上……抱歉我忘了在这里放 sockaddrsockaddr_in 的定义,但它们共享一个共同的前缀,比如地址族(sin_familysa_family)等等。所以即使这里的活跃成员是 addr_in(因为我赋值给它),我也可以通过 addr(即 sockaddr*)读取共同前缀,包括 family 字段,因此我可以在这里将 addr 传递给 bind 而无需 reinterpret_cast。公共初始序列是一组非静态数据成员,它们是共同的,并且基本上它们具有相同的类型并以相同的顺序出现在类型定义中。

reinterpret_cast,我最喜欢的一个。将指针 reinterpret_cast 到某个类型只有在以下情况之一时才保证是安全的:如果你进行指针到整数的转换并转回来,这是可以的(如果整数类型足够大)。如果两个对象是指针可互转换(pointer-interconvertible)的,意思是它是同一个对象……好的,但没用。一个对象是联合体对象,另一个是该联合体的非静态数据成员(基本上因为它们共享相同的地址,它们在内存中重叠)。或者……抱歉,你有一个标准布局类对象,另一个是该对象的第一个非静态数据成员(所以对象和第一个数据成员是指针可互转换的)。reinterpret_cast 也是可接受的,如果你转换到的类型是 charunsigned charstd::byte,或者是该对象类型的有符号或无符号变体的指针。好的。所以很可能,如果你没有做上述任何一种情况,很可能你处于未定义行为中。我很抱歉这么说。但事情甚至更糟。让我们看看。好的,正如我所说,如果你有一个整数,获取整数的地址并将其 castfloat*,这是错误的,它违反了严格别名规则。不要,不要这样做。但即使 reinterpret_castunsigned char*,虽然标准说好吧,你可以这样做,但由于我们之前看到的关于对象生命周期的部分,它并不是良定义的。你首先必须创建一个对象才能访问它。所以我现在问你:好吧,我有一个 FooBar 类型的对象,比如 std::vector 什么的,然后我 castunsigned char*,然后我尝试解引用结果指针。我问你:我在那个内存位置开始了一个 char 数组的生命周期吗?当然没有。所以那也是未定义行为。委员会有一份论文(截至今年一月仍在讨论中)来修复这个问题。这被认为是标准中的一个缺陷(bug defect)。

然后我抛出以下问题:这实际上很有趣。我们有 std::as_bytesstd::as_writable_bytes,如果你看一下标准本身,它说它返回一个 span,允许你对某个对象在内存中的对象表示(object representation)进行内省。它被定义为:该 span 的基地址将是该对象的 reinterpret_cast<unsigned char*>。是的,短路(short-circuiting)了,对吧?所以基本上 std::as_bytes 也不是良定义的?我的意思是,你的编译器可能会做正确的事情,因为编译器工程师是非常聪明的人,但它不是良定义的。所以关键思想是……是的,不,它是因为……因为当你对一个 char 序列进行 reinterpret_cast 时,你必须在指针指向的位置开始一个 char 数组的生命周期,而你没有这样做。那里有别的对象,一个 FooBar 类型的对象,并且正如我们一致同意的,由于严格别名规则,同一个内存位置不可能同时存在两个不同类型的对象。后续问题:但是根据严格别名规则,我们被允许拥有指向同一个对象的指针,其中一个是 unsigned char*,但你是说我们实际上不允许使用它,因为那些 char 的生命周期没有开始?我的天啊,是的,是的,是的!

所以在演讲结束时,我想问:在座的各位,谁没有在这里做未定义行为?……大多数人?抱歉我听不到你说什么。所以我的意思是,通常我认为我们需要区分“根据标准的未定义行为”(即标准中的缺陷)和“事实上的”(de facto)行为(即编译器实际如何实现它)。在很多情况下,因为我们最近修复的很多东西,这些修复在标准的早期版本中是不可用的。所以你基本上被迫在某些情况下做未定义行为,而它实际上是定义行为,因为编译器会以某种方式实现它。所以从事实(de facto)上说,你并没有……是的,那是对的。存在未定义行为……我将在那里结束。它是根据标准的未定义行为,仅此而已。它是未定义行为。另一件不同的事情是编译器做了正确的事情,但它是未定义行为。我认为我不同意“根据标准”和“事实上的”之间的区别,因为我们有大约 200 个不同的 C++ 实现。一件事是它在最常见的实现中大多数时候有效,这还不够,因为我们有 200 或 210 个不同的实现?是的,我完全同意。我的意思是,在很多情况下,你面对的是:你不是库作者,你是应用程序代码作者,这意味着你的运行环境是相当固定的。所以你知道在事实上的运行环境中,它是定义行为。当然,如果你把它移到不同的平台,那就是另一回事了。所以我的建议——这是我最后要说的一点——就是写一堆 static_assert,这样它在任何其他平台上都不会编译。请,请一定要这样做。好的,我将继续,其余的问题我们留到演讲结束,因为否则……我的意思是,大约还有 15 分钟,还有很多幻灯片。

好的,std::launder。好的,那是什么?根据 cppreference,它是“关于 p 的虚拟化栅栏(devirtualization fence)”。这到底是什么鬼东西?好吧,我甚至不想继续读了。根据 ISO C++,它是一个“指针优化屏障(pointer optimization barrier)”。这听起来好一点。好吧,我们会看到更多……更多关于这个的内容。

你习惯于认为指针只是一个内存地址,对吧?但它不是。内存地址只是一个指针的值。但在 C++ 中,指针指向一个对象,而不是一个内存地址。编译器可以对该对象做出假设。相反,如果它帮助你(实际上这就是编译器推理你代码的方式),你可以把指针看作是一对地址(address)和来源(provenance),其中来源基本上决定了通过该指针可以访问哪些值。你有洗钱(money laundering),这不是你应该做的事情,顺便说一句,这是隐瞒,是试图隐藏钱的来源,对吧?所以指针洗钱(pointer laundering)将是更新指针的来源,以隐藏它来自哪里,并移除编译器可能对它所指向的对象所做的任何假设。我想这更清楚一些?不过它有一些前提条件。它说:一个对象 x 在其生命周期内(好的),顺便说一下 x 是……是的,它是由参数 p 指向的对象,并且其类型类似于 T 的对象位于地址 a。这意味着你基本上不能清洗任何不在其生命周期内的东西。抱歉,我忘了点东西……是的,我们会看到的。

只是一个例子:我们有一个整数 i,我初始化为 42。好的,很好。然后我将 i 的地址 reinterpret_castfloat*float* p = reinterpret_cast<float*>(&i))。你说过,由于严格别名规则,这是错误的,但如果你不解引用指针并尝试访问对象,那就没问题。所以我只是有一个指针,我不使用它,好的。然后我在指针的地址上执行放置 new 并创建一个 float 对象(new (p) float{3.14f})。这就是 float 对象诞生的时候。然后,好吧,我尝试使用它(*p),但猜猜看?未定义行为!因为指向 float 的指针 p 并不指向一个 float 对象,因为 float 对象是在我们获得指针之后构造的。当我们获得指针时,指针指向的东西仍然是一个整数。所以我们必须清洗(launder)这个指针(float* q = std::launder(p); *q),这样编译器就会忘记它对该指针所知道的任何事情,然后解引用它,那样就可以了,我们会访问浮点数。如你所见,这是一个相当底层的操作,不是你日常工作中可能想用的东西。我们将把问题留到最后,否则我就没时间了。

更复杂的例子,我会很快过一遍。所以基本上我有一个复合类型 MyStruct,它只有一个 float 数据成员。我用这个值(12.34f)初始化它(MyStruct s{12.34f})。获取指向它的指针(MyStruct* p = &s)。打印 p->f 的值,当然是 12.34 左右。然后我重用存储空间(s.~MyStruct(); new (p) MyStruct{56.78f}),我在相同的内存位置创建一个新的 MyStruct,但它的 f 成员初始化为其他值。如果我在这里尝试打印 p->fstd::cout << p->f;),编译器会从中推断出什么?它会打印什么?因为它是 const?我的意思是,不允许更改?所以它只是在这里抓取值(假设编译器在优化时缓存了初始值)。如果我在这里重用存储空间,编译器可以自由地……我的意思是它知道,对吧?所以你必须清洗指针(MyStruct* q = std::launder(p); std::cout << q->f;)。好的,忘记你对该指针所知道的任何事情,因为有一个对象取代了另一个对象。你清洗它,你就移除了你所做的任何假设,然后解引用 f。这是可以的,这将打印新值(56.78)。

下一位朋友:memcpymemcpy 覆盖不同的对象。我们只是将一个对象的对象表示复制到另一个对象。只要源和目标都是可平凡复制的(trivially copyable)类型,这是可以的。无论是不同的基本数据类型还是它们的数组。它不违反严格别名规则。在对其和对齐要求以及生命周期规则方面也是可以的(前提是目标存储空间对齐正确且大小足够)。好的。如果你担心性能,别担心,编译器大多数时候可以优化它们。现在的编译器很聪明。你甚至可以在 Godbolt 上看到。

下一位朋友:如何快速……std::bit_cast。好的。所以它告诉我它返回一个类型为 To 的对象(To 基本上是……这里,它基本上是 bit_cast<To>(from) 这个模板的类型名),并且该对象的值表示(value representation)中的每个位(bit)将等于源值(from)的对象表示(object representation)中的相应位。填充位(padding bits)是未指定的,但这很常见,对吧?好的。如果我这样使用它:我有一个 float,我把它 bit_cast 成一个 std::array<char, sizeof(float)>auto c = std::bit_cast<std::array<char, sizeof(float)>>(f)),那么这是可以的。我可以检查对象表示。它不违反严格别名规则,基本上因为 c 将是一个不同的对象,它不会指向相同的内存位置(它是一个副本)。满足对齐要求(std::array 的对齐至少和 char 一样弱)和生命周期规则(c 是一个新对象)。所以好的。非常严格地说,这基本上是一种受 ISO C++ 祝福(blessed)的方式来做之前的 memcpy。它是一个 constexpr 函数,所以如果可能的话会被优化。

请不要这样做:我见过有些人在这里传递指针类型(auto p = std::bit_cast<int*>(some_float_ptr))。这应该接受值类型,而不是指针类型。如果你传递一个指针,这将等同于对指针进行 memcpy 到一个不同类型的指针,但你是……我的意思是,那个指针解析到的东西类型是错误的,所以你违反了严格别名规则。在引入 std::bit_cast 的论文中,这最初是被允许的,但最后被删除了,因为显然如果你想搬起石头砸自己的脚(shoot your feet),你需要有能力这样做,不管出于什么原因。不要,不要这样做。

另一个未知的大东西:std::start_lifetime_as。我之前谈到了 C++20 的隐式生命周期(implicit lifetime),在 C++23 中,我们引入了显式生命周期管理(explicit lifetime management)。所以基本上,它允许我们手动在给定存储位置开始一个给定类型对象的生命周期,而不以任何方式改变之前存在的对象表示。通过这个例子会很清楚:我有一个整数 i,我可以在 i 的地址开始一个 float 的生命周期(float* p = std::start_lifetime_as<float>(&i)),然后我得到指向 float 的指针。是的,但是 Javier 你说过有一个叫做严格别名规则的东西,我们不允许访问既是 int 又是 float 的给定位置的东西?当然不允许,这意味着这是一个破坏性的操作(如果你愿意这么说的话)。基本上,你将开始一个 float 的生命周期,这将结束 int 的生命周期。所以我可以在这里将它作为 float 访问(*p),但如果我访问我之前拥有的整数 istd::cout << i),那就是未定义行为。好的。但事情变得更复杂了。所以访问 float 是好的(*p),访问 i 是未定义行为,对吧?然后我可以在相同地址重新开始一个 int 的生命周期(std::start_lifetime_as<int>(&i))。但请注意,我在这里没有像在 p 的情况下那样获取返回的指针,我只是依赖于原始的 i 对象(std::cout << i),因为我将 i 的生命周期重新开始为 int。仍然是未定义行为!为什么?因为 i 对象在我开始 float 的生命周期时就死亡了。我开始了 int 的生命周期,但 这个 i 对象已经死亡了。所以我必须清洗指向 i 的指针(int* q = std::launder(&i); std::cout << *q;)。要么我像这里(float* p = ...)那样获取这个东西的返回指针,要么我必须清洗关于对象 i 的任何假设,然后我才能再次引用它。那样就可以了。事情变得复杂了,对吧?

总结一下:

  • 联合体:请不要用。

  • reinterpret_cast:请不要用,除非你是在做指针到整数的转换,或者它是指针可互转换的(pointer-interconvertible),或者你正在将对象表示作为 char 数组访问(在这种情况下转换本身是好的,但解引用是坏的)。

  • std::as_bytesas_writable_bytes:好,因为编译器知道怎么做,但打上大大的问号,因为它在标准层面上是坏的(它最终是一个 reinterpret_castchar 数组)。

  • 其他方法都很好。

为了节省时间,我将跳过这张幻灯片。序列化的简短示例。如你所知,序列化是将内存中的数据结构布局为一系列字节,我可以传输到其他地方。但请注意,这个数据结构可能包含指向不同结构的嵌套指针或引用。基本数据类型的大小,正如我所说,可能会改变。不要假设 int 的大小是 4,例如。字节序(endianness)也有差异,所以如果你要将数据传输到另一台不同的机器,这是一个问题。类型需要对齐,正如我所说。序列化可以只是一个 memcpy

如果你能确保以下所有属性:

  • 数据结构是可平凡复制的(trivially copyable),好的。

  • 没有指向嵌套数据结构的指针或引用(所以基本上你内存中有什么,你得到的就是什么)。

  • 你使用的是固定宽度类型(如 int32_t)。

  • 不跨越机器边界(所以你是在做进程间通信(IPC)之类的事情),否则你会有字节序和对齐的担忧。

  • 当你反序列化时,对齐要求得到满足(目标存储空间对齐正确)。

否则,请实现适当的数据序列化。

为了举个例子,我有一个结构体,只包含几个成员:64 位整数和 32 位整数。一些静态成员函数:serializedeserialize,它们接受一个对象并序列化到给定的缓冲区,或者接受一个缓冲区并反序列化到一个对象。如果你不跨越机器边界,正如我所说,你可以直接 memcpy,非常简单。正如我第三次提到的,填充字节是未指定的。所以如果你非常偏执于安全性,填充字节实际上可能包含之前内存中的东西。如果你偏执于安全性,请不要这样做,特别是如果你要将此传输到另一台机器,但正如我们所说,由于字节序等问题,无论如何这都不正确。否则,实现适当的序列化:只为每个基本数据类型适当地序列化结构体。这可以是一个可能的实现(伪代码):这是小端序列化(little-endian serialization)的示例,用于 64 位整数和 32 位整数。

正如我所说,一个经验法则是:如果类型是标准布局(standard layout),没有指针或引用,并且不跨越机器边界,那么 memcpy 是好的,并在另一端开始生命周期(在 C++20 之后隐式或在 C++23 之后显式)。否则,请进行适当的序列化。你也可以参考 Boost 序列化库的文档,它非常好。

我将结束,我准时完成了,令人惊讶。带回家的想法:

  • 很可能你的 reinterpret_cast 是未定义行为。即使是 char* 的情况也不是良定义的,正如我们所看到的。

  • 如果你有其他方法可用并且可以使用它们,请使用其他方法。

  • 所以一般来说,类型双关只有在不破坏严格别名规则(正如我们所见,只有通过给定指针可访问且类型可访问的访问才是允许的)、满足对齐要求、被访问的对象在其生命周期内的情况下才是可以的。

  • 再次回答那位关于联合体问题的朋友:不要这样做。

我本想说一些参考文献,但不止几个:ISO C++ 标准和大量论文。是的,你可以在家看看。谢谢。这是二维码(QR code),你可以扫描获取幻灯片的副本,如果你想在家跟着看或只是回忆我们今天看到的内容。谢谢。

(掌声 掌声 掌声 掌声 掌声)

我确信我们有问题。所以我的问题是关于严格别名规则的。假设我们有一个结构体,我们取它的地址,比如指向结构体的指针,也取它第一个成员的指针。那会违反严格别名规则吗?

不,那是我之前定义的叫做指针可互转换(pointer-interconvertibility)的东西,就在这上面的某个地方(指幻灯片)。如果某个东西……它必须是标准布局的,顺便说一下……和第一个非静态成员,所以那将对应于相同的地址,并且是指针可互转换的,进行这种转换是好的。然后……不好。不行。基本上,这是因为第一个成员保证占据相对于该对象位置偏移量 0 的位置。但如果你去第二个成员,那里可能有填充,可能有任何东西,所以……是的,有点。顺便说一下,我有张备份幻灯片,我今天没讲这个。好的,一些编译器允许你禁用严格别名规则。不要链接到这里(指幻灯片),Linux 内核就是这么做的,顺便说一下。但好的,它不是 C++,无论如何我们不在乎。

我有一个……谢谢你的演讲,Javier。我有个问题问你。如果你翻到第 36 张幻灯片(序列化示例),如果你不是有 64 位整数,而是有一个 char,因为那里可能会引入填充,会发生什么?如何做适当的序列化或反序列化?而不是拥有 64 位整数,你有一个 char,比如,因为那里可能会引入填充。如何做适当的序列化或反序列化?

首先,如我所说,如果你不跨越机器边界,如果你在做进程间通信,你可以直接 memcpy。其次,问题是:所以如果你想复制一个 char 数组,比如你从一个设备接收到的字节数组,你想把它们注入到你拥有的对象中,那里有几个假设……我们时间不够了,但好的,如果你完全确定对象表示是相同的(因为你在同一台机器上,用相同的编译器编译等等),因为如我所说,行为甚至可能在编译器之间不同,对吧?如果你绝对确定对象表示是相同的,你可以使用 std::start_lifetime_as 作为一个不同的对象开始生命周期,对吧?如果你没有 C++23,有一种方法可以做到,我们可以稍后讨论。或者你可以直接 memcpy 到该类型的一个对象,就像……我想……是的,就像这里(指序列化代码)做的那样。

好的,谢谢你的演讲。当然。我的问题是关于类的公共前缀的。我知道这个技巧(在联合体中)。我们已经避免了 reinterpret_cast,我认为如果我们有一个类有一些私有成员,通过具有相同成员的另一个结构体通过联合体访问它们可能是未定义行为(UB)?因为它们会有相同的前缀,我理解是这样?

是的,但我总结了一些东西,因为联合体方法(指幻灯片)说的是一个标准布局的对象,其结构体也是标准布局的。为了缩短演讲,我在这里没有讲述整个故事,但标准布局的东西没有可见性(visibility)问题?所以如果你有私有的东西,它就不是标准布局的,所以你不能这样做。但你没在这里看到私有成员?是的,这是一个总结,所以我写了“太长不读”(TLDR)。所以私有成员会使你的类不是标准布局的。我的意思是,标准中的条件大概有 15 或 20 条,其中一条说:你不能有访问说明符(visibility specifiers)。好的,谢谢。

谢谢你让我大吃一惊(Blowing my mind)。所以问题是:所有这些链接(指对象、类型、生命周期、指针来源的关联)和对你的代码进行推理听起来很有趣。如果我尝试在编译时做这样的转换和计算,使用 constexprconstexpr 变量,我真的能捕获未定义行为吗?编译器会报告可能发生的未定义行为吗?

哦,我在编译时不能使用 reinterpret_cast。是的,它是运行时的。

所以有没有另一个工具可以帮助我?实际上提供编译器认为发生了什么的信息?

从编译器工程师的角度,我可以告诉你,Clang 基本上使用 LLVM 基础设施来进行优化过程(optimization passes),你实际上可以在每个优化过程停止,并查看你的代码在代码生成阶段是如何演变的。所以你可以看到你的代码是如何被你的优化过程转换和影响的。

你能做到吗?听起来像是纯前端发生的事情?我无法想象这些东西发生在中端(middle end)?

基本上是的。

只是一个简单的问题:你认为像使用消毒器(sanitizers)这样的东西能捕获其中一些错误吗?或者不能保证它会捕获,因为它可能取决于代码生成?

不能保证。所以基本上你有 -fsanitize=undefined-fsanitize=ub,但它不能捕获所有东西。所以它只是提供一些关于众所周知未定义行为的提示,但还有其他未定义行为是未定义行为消毒器(UBSan)捕获不到的。谢谢。

更多?哦,你能翻到第 25 张幻灯片吗?好的。所以 i 的析构函数在放置 new 之前没有被调用?我的意思是,对于一个整数来说没关系,但在泛型代码中,你能在调用析构函数之前就做放置 new 到一个地址吗?你能使用……抱歉,i 的析构函数在做放置 new 之前没有被调用,但对于整数来说没关系,我猜?但在泛型代码中,你想在做放置 new 之前调用析构函数,对吧?

是的,对的。因为它是基本数据类型,它是平凡类型(trivial type),而平凡类型的析构函数是空操作(no-op)。哇,这个演讲引发了很多讨论。所以我现在的印象是,std::start_lifetime_as 做了 std::launder 所做的一切,甚至更多。那么为什么,什么时候应该使用 std::launder?或者我的印象是错误的?有点错误。我会说……让我看看 std::launderstd::launder 的前提条件:一个在其生命周期内并且类型类似于 T(这是模板参数的类型名)的对象位于给定地址。所以如果没有对象,你就违反了前提条件。它不会开始对象的生命周期。

不,我的问题是 start_lifetime_as 似乎比 launder 更强大(mighty)。在什么意义上?

所以当你能够使用 launder 时,你总是可以使用 start_lifetime_as 吗?我会说它们用途不同,但是的……好的。不同的问题:我必须在哪些地方使用 std::launder 而不是 start_lifetime_as?或者这些函数调用的结果在哪里不同?

基本上,std::launder 用于移除编译器可能对指针指向的对象所做的任何假设,但它被假定为已经存在一个给定类型的对象。而 start_lifetime_as 所做的是在给定的确切地址开始某个对象的生命周期。

我不在乎那里有没有对象……如果那里有一个对象,我做 start_lifetime_as,那么它和 std::launder 一样吗?

再说一遍:如果那里有一个对象,我看不出有什么理由不能使用 start_lifetime_as。我现在没明白你的观点。稍后再说?好的。好的。所以在同一个例子中,例如,i 有一个析构函数(在这个例子中它是 int),但如果碰巧是一个有析构函数的对象,当你调用 start_lifetime_as 时,它会调用析构函数吗?

不会,它只是开始一个对象的生命周期。但如果它没有平凡的析构函数(trivial destructor),你必须手动调用它。好的。

如果你使用 start_lifetime_as 的地址没有对齐会怎样?比如你从一个套接字读取,那里没有填充?

这是一个很好的问题,因为我认为一个前提是内存地址必须是对齐的?这是一个很好的问题,因为编译器会假定存储空间是适当对齐的?

不完全是。你可以保留你的缓冲区。我在今天的第一个问题中有一个例子。当你分配缓冲区时,你可以确保它对该类型是适当对齐的,然后你可以在那里开始它的生命周期。但你只确保了第一个消息的对齐。如果你在一次读取中接收多个消息,第二个消息在那个缓冲区中可能就不会对齐。 在那种情况下,留一些填充?好的。

我认为可能还有更多问题,但我们已经够多了。所以如果你有更多问题,我相信你可以在咖啡休息时间线下找 Javier,但现在是下一个演讲的时间了。

非常感谢 Javier。非常好的演讲。