C++ 的严格别名规则 - 'char' 别名豁免是两条路吗?

C++'s Strict Aliasing Rule - Is the 'char' aliasing exemption a 2-way street?

就在几周前,我了解到 C++ 标准有严格的别名规则。基本上,我问了一个关于移位位的问题——而不是一次移位一个字节,为了最大限度地提高性能,我想加载处理器的本机寄存器(分别为 32 位或 64 位)并执行 4/8 的移位字节全部在一条指令中。

这是我想避免的代码:

unsigned char buffer[] = { 0xab, 0xcd, 0xef, 0x46 };

for (int i = 0; i < 3; ++i)
{
  buffer[i] <<= 4; 
  buffer[i] |= (buffer[i + 1] >> 4);
}
buffer[3] <<= 4;

相反,我想使用类似的东西:

unsigned char buffer[] = { 0xab, 0xcd, 0xef, 0x46 };
unsigned int *p = (unsigned int*)buffer; // unsigned int is 32 bit on my platform
*p <<= 4;

有人在评论中指出我提出的解决方案违反了 C++ 别名规则(因为 p 是 int* 类型,缓冲区是 char* 类型,我取消引用 p 来执行转换.(请忽略可能存在的对齐和字节顺序问题——我会处理此代码段之外的问题)我很惊讶地了解到他的 Strict Aliasing 规则,因为我经常对缓冲区中的数据进行操作,将其从一种类型转换为另一种类型,并且从来没有任何问题。进一步调查显示,我使用的编译器 (MSVC) 没有执行严格的别名规则,而且由于我只是在业余时间开发 gcc/g++ 作为一种爱好,所以我可能只是没有遇到问题了。

然后我问了一个关于 Strict Aliasing Rules 和 C++ 的 Placement new operator 的问题:

IsoCpp.org 提供了有关新展示位置的常见问题解答,他们提供了以下代码示例:

#include <new>        // Must #include this to use "placement new"
#include "Fred.h"     // Declaration of class Fred
void someCode()
{
  char memory[sizeof(Fred)];     // Line #1
  void* place = memory;          // Line #2
  Fred* f = new(place) Fred();   // Line #3 (see "DANGER" below)
  // The pointers f and place will be equal
  // ...
}

这个例子很简单,但我问自己,"What if someone calls a method on f -- e.g. f->talk()? At that point we would be dereferencing f, which points to the same memory location as memory (of type char*. I've read numerous places that there is an exemption for variables of type char* to alias any type, but I was under the impression that it wasn't a "双向街道”——意思是,char* 可以别名 (read/write) 任何类型 T,但如果 T 本身属于 char*,则类型 T 只能用作 char* 的别名。当我输入这个时,那不会对我来说没有任何意义,所以我倾向于相信我的初始(位移示例)违反严格别名规则的说法是错误的。

谁能解释一下什么是正确的?我一直在疯狂地试图理解什么是合法的,什么是不合法的(尽管已经阅读了很多关于该主题的网站和 SO 帖子)

谢谢

事实上,关于通过严格别名进行指针类型双关的标准规则的解释不一定正确或容易理解。标准没有提到'strict aliasing',我觉得原来的标准措辞更容易理解和推理。

从本质上说,你只能通过指向适合访问该对象的相关类型(例如相同类型或相关class类型)的指针或通过指针来访问对象至 char.

如你所见,'two-way street'的问题根本不适用

别名规则意味着语言只承诺你的指针解引用是有效的(即不触发未定义的行为)如果:

  • 您通过兼容 class 的指针访问对象:它的实际 class 或它的超 class 之一,正确转换。这意味着如果 B 是 D 的超class 并且您有 D* d 指向有效的 D,则访问 static_cast<B*>(d) 返回的指针是可以的,但是访问 [=14 返回的指针=] 是 而不是 。后者可能未能说明 B 子对象在 D 中的布局
  • 您可以通过指向 char 的指针访问它。由于 char 是字节大小和字节对齐的,因此您无法从 char* 中读取数据,同时能够从 D*.
  • 中读取数据

也就是说,标准中的其他规则(特别是关于数组布局和 POD 类型的规则)可以理解为确保您可以使用指针和 reinterpret_cast<T*>在 POD 类型和 char 数组之间使用 two-way 别名,前提是您确保具有适当大小 和对齐方式 的 char 数组.

换句话说,这是合法的:

int* ia = new int[3];
char* pc = reinterpret_cast<char*>(ia);
// Possibly in some other function
int* pi = reinterpret_cast<int*>(pc);

虽然此可能调用未定义的行为:

char* some_buffer; size_t offset; // Possibly passed in as an argument
int* pi = reinterpret_cast<int*>(some_buffer + offset);
pi[2] = -5;

即使我们可以确保缓冲区足够大以包含三个 int,对齐也可能不正确。与所有未定义行为的实例一样,编译器可以做任何事情。三种常见情况可能是:

  • 代码可能 Just Work (TM),因为在您的平台中,所有内存分配的默认对齐方式与 int 的对齐方式相同。
  • 指针转换可能会将地址舍入到 int 的对齐方式(类似于 pi = pc & -4),可能使您 read/write 进入 错误的 内存.
  • 指针取消引用本身可能会以某种方式失败:CPU 可能会拒绝未对齐的访问,从而使您的应用程序崩溃。

由于您总是想像驱除恶魔本身一样抵御 UB,因此您需要一个具有正确大小和对齐方式的 char 数组。最简单的方法是简单地从 "right" 类型的数组开始(在本例中为 int),然后通过 char 指针填充它,因为 int 是 POD 类型,这是允许的。

附录: 使用放置 new 后,您将能够调用对象上的任何函数。如果构造正确并且由于上述原因没有调用 UB,那么您已经在所需位置成功创建了一个对象,因此任何调用都可以,即使该对象是非 POD(例如,因为它具有虚函数)。毕竟,任何分配器 class will likely use placement new 都可以在他们获得的存储中创建对象。请注意,仅当您使用 placement new 时,这才必然成立;类型双关的其他用法(例如使用 fread/fwrite 的简单序列化)可能会导致对象不完整或不正确,因为对象中的某些值需要特殊处理以维护 class 不变性。