C++的严格混叠规则 - "char"混叠豁免是双向街道吗?

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

本文关键字:街道 规则 char C++      更新时间:2023-10-16
就在

几周前,我了解到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 来执行移位。(请忽略对齐和字节顺序的可能问题 - 我处理此代码段之外的问题( 得知严格混叠规则让我感到非常惊讶,因为我经常对缓冲区中的数据进行操作,将其从一种类型转换为另一种类型,并且从未遇到任何问题。 进一步的调查表明,我使用的编译器 (MSVC( 没有强制执行严格的别名规则,并且由于我只在业余时间将 gcc/g++ 作为业余爱好进行开发,因此我可能还没有遇到这个问题。

然后我问了一个关于严格别名规则和C++放置新运算符的问题:

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
  // ...
}

这个例子很简单,但我问自己,"如果有人对f调用一个方法怎么办 - 例如 f->talk() ? 此时我们将取消引用 f ,它指向与 memory(类型 char* (相同的内存位置。我读过很多地方,char*类型的变量可以免除对任何类型的别名,但我的印象是这不是一条"双向街道"——这意味着,char*可以别名(读/写(任何类型的T,但类型 T 只能在T本身具有char*的情况下才能用于别名char*。当我输入这个时,这对我来说没有任何意义,所以我倾向于相信我的初始(位移示例(违反了严格的混叠规则的说法是错误的。

有人可以解释一下什么是正确的吗?我一直在疯狂地试图了解什么是合法的,什么是不合法的(尽管已经阅读了许多关于该主题的网站和 SO 帖子(

谢谢

别名规则意味着该语言仅在以下情况下承诺指针取消引用有效(即不会触发未定义的行为(:

  • 您可以通过兼容类的指针访问对象:它的实际类或其超类之一,正确转换。这意味着,如果 B 是 D 的超类,并且您D* d指向有效的 D,则访问 static_cast<B*>(d) 返回的指针是可以的,但访问 reinterpret_cast<B*>(d) 返回的指针则不行。后者可能没有考虑到 D 内部 B 子对象的布局。
  • 您可以通过指向 char 的指针访问它。由于 char 是字节大小和字节对齐的,因此您不可能在从char*读取数据的同时从D*读取数据。

也就是说,标准中的其他规则(特别是关于数组布局和 POD 类型的规则(可以理解为确保您可以使用指针和reinterpret_cast<T*>在 POD 类型和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,对齐也可能不正确。与所有未定义行为的实例一样,编译器绝对可以执行任何操作。三种常见的情况可能是:

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

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

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

事实上,通过严格的混叠来解释关于指针类型双关的标准规则并不一定正确或容易理解。标准没有提到"严格的别名",我发现原始的标准措辞更容易理解和推理。

本质上,它说您只能通过指向适合访问该对象的相关类型的指针(例如相同类型或相关类类型(或通过指向char的指针访问对象。

如您所见,"双向街"的问题甚至不适用。