C++的严格别名规则-‘char’别名免除是否是双向的?

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 *类型而buffer是char *类型,并且我正在对p进行引用以执行移位操作。(请忽略可能的对齐和字节顺序问题--我在此片段之外处理这些问题)我非常惊讶地了解到了严格别名规则,因为我经常操作来自缓冲区的数据,将其从一种类型转换为另一种类型,并从未遇到任何问题。进一步调查发现,我使用的编译器(MSVC)不强制执行严格别名规则,由于我只在业余时间作为爱好者开发gcc / g ++,所以可能还没有遇到这个问题。

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

IsoCpp.org提供了一个有关放置new的FAQ,并提供了以下代码示例:

#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()会怎么样?此时我们将对指向与memory相同的内存位置的f进行解引用(类型为char*)。我已经在许多地方读到类型为char*的变量有例外,可以别名任何类型,但我认为它不是“双向街道”--这意味着char*可以别名(读/写)任何类型T,但只有当T本身是char*类型时,类型T才能用于别名char*。当我打字时,这对我来说毫无意义,所以我倾向于相信我的初始(bit shifting example)违反了严格别名规则的说法是错误的。

请问有人能解释一下什么是正确的吗?尽管已经阅读了许多网站和SO帖子,我仍然试图理解什么是合法的,什么不合法。

谢谢


3
如果调用f的成员函数是未定义行为,那么这会使得放置new变得无用,不是吗? - Barry
“which points to the same memory location as memory (of type char*)”- memory的类型不是char*。在第二行,数组会转化为指针,但这并不意味着memory是一个指针。即使memory是一个指针,它所指向的内存位置的类型也将是char而不是char* - davmac
1
看起来你的问题是关于 f->talk() 是否可行;我认为删除所有前置内容(“So then”之前的内容)会使问题更加清晰。 - M.M
2个回答

7
别名规则意味着语言只承诺您的指针解引用是有效的(即不会触发未定义行为),如果:
  • 您通过兼容类的指针访问对象:其实际类或其超类之一,经过适当的转换。这意味着如果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数组之间进行双向别名,只要您确保具有适当大小和对齐方式的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,但对齐可能不正确。与所有未定义行为的情况一样,编译器可能会执行绝对任何操作。三种常见情况可能是:
  • 代码可能只是工作正常,因为在您的平台上所有内存分配的默认对齐方式与 int 相同。
  • 指针转换可能会将地址舍入到 int 的对齐方式(类似于 pi = pc & -4),可能会导致您读/写到错误的内存。
  • 指针解引用本身可能会以某种方式失败:CPU 可能会拒绝未对齐的访问,从而使您的应用程序崩溃。

由于您总是想避免像魔鬼本身一样的 UB,因此您需要一个具有正确大小和对齐方式的 char 数组。最简单的方法是使用“正确”类型的数组(在这种情况下为 int),然后通过 char 指针填充它,因为 int 是 POD 类型,所以这是允许的。

补充:使用放置 new 后,您将能够在对象上调用任何函数。如果构造正确且由于上述原因不会引发 UB,则已成功在所需位置创建对象,因此任何调用都是可以的,即使对象是非 POD(例如,因为它具有虚函数)。毕竟,任何分配器类可能会使用放置 new 在其获取的存储中创建对象。请注意,只有在使用放置 new 时才必然如此;其他类型的类型转换的用法(例如,使用 fread/fwrite 进行天真的序列化)可能会导致对象不完整或不正确,因为需要特殊处理对象中的某些值以维护类不变量。

1
@Hurkyl new 并不需要返回分配器返回的相同地址(例如,它可能会返回一些偏移量)。因此,我不认为分配器要求保证 new char[3*sizeof(int)] 一定会返回适合 int 的指针对齐方式,即使标准分配器保证如此。 - davmac
2
[expr.new]/11 保证了适当的对齐方式。尽管如此,由于没有任何活动的 int 对象,只有一堆 char,因此它是未定义行为。 - T.C.
1
如果对齐是有保证的,那么将“POD类型”之间进行“reinterpret_cast”不会产生未定义行为(我已更新我的答案)。由于字节顺序等原因,将获得实现定义的值,但不是未定义的。只有当您转移到非POD类型时,才会变成UB,因为您可能会破坏类不变量,缺少vtable指针等。 - Javier Martín
这并没有回答 f->talk() 是否可行的问题。 - M.M
@M.M 如果对象是通过放置new正确创建的,则可以调用函数。我已经在结尾处添加了相关部分。 - Javier Martín
显示剩余4条评论

0
事实上,关于指针类型转换和严格别名规则的解释并不一定正确或易于理解。标准中并没有提到“严格别名”,而我发现原始标准的措辞更容易理解和推理。
本质上,它表明你只能通过适合访问该对象的相关类型的指针(例如相同类型或相关类类型)或通过指向 char 的指针来访问对象。
正如你所看到的,“双向街”的问题甚至都不适用。

1
我不明白为什么双向问题不适用。 - Peter - Reinstate Monica
不,它是通过指向char的指针进行的。您引入了一层间接性。 - davmac

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接