为什么类型双关被认为是 UB?
Why is type punning considered UB?
想象一下:
uint64_t x = *(uint64_t *)((unsigned char[8]){'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'});
我有 类型的双关语是未定义的行为。为什么?我从字面上看,将 8 个字节的字节重新解释为一个 8 字节的整数。我看不出这与 union
有何不同,除了类型双关语是未定义的行为而 union
s 不是?我亲自问过一位程序员同事,他们说如果你这样做,要么你知道你在做什么非常好,要么你犯了一个错误。但是社区说应该始终避免这种做法吗?为什么?
最终原因是“因为语言规范是这么说的”。你不必为此争论。如果这就是语言的方式,那就是它的方式。
如果您想知道这样做的动机,那就是原始的 C 语言缺乏任何表达两个左值不能相互别名的方式(并且现代语言的 restrict
关键字仍然几乎不为该语言的大多数用户所理解)。无法假定两个左值不能别名意味着编译器无法重新排序加载和存储,并且必须为每次访问对象实际执行加载和存储 from/to 内存,而不是将值保存在寄存器中,除非它知道对象的地址从未被占用。
C 的 type-based 别名规则 通过让编译器假定不同类型的左值不别名来稍微缓解这种情况。
另请注意,在您的示例中,不仅 type-punning 存在错位。 unsigned char
数组没有固有的对齐方式,因此在该地址访问 uint64_t
将是对齐错误(另一个原因是 UB),与任何别名规则无关。
类型双关被认为是 UB,因为标准的作者期望在标准没有强加任何要求的情况下,用于各种目的的质量实现将以“环境特征的文档化方式”表现,但这种行为会达到预期目的。因此,避免对实现施加过于严格的要求比要求它们支持程序员需要的一切更为重要。
为了改编和稍微扩展基本原理中的示例,请考虑代码(为简单起见,假设一个普通的 32 位实现):
unsigned x;
unsigned evil(double *p)
{
if (x) *p = 1.0;
return x;
}
...
unsigned y;
int main(void)
{
if (&y == &x + 1)
{
unsigned res;
x=1;
res = evil((double*)&x);
printf("You get to find out the first word of 1.0; it's %08X.\n", res);
}
else
{
printf("You don't get to find out the first word of 1.0; too bad.\n");
}
return 0;
}
在没有“严格的别名规则”的情况下,处理 evil
的编译器必须考虑到它可能被调用的可能性,如 test
所示,在可能发生的实现中连续放置两个 int
值,使得 double
可以适合由此占用的 space。基本原理的作者认识到,如果编译器返回 if
所看到的 x
的值,在这种情况下结果将是“不正确的”,但即使是大多数类型双关语的提倡者会承认这样做的编译器(在这种情况下)通常比重新加载x
(因此生成效率较低的代码)的编译器更有用。
请注意,所写规则并未描述实现应支持类型双关的所有情况。给出类似的东西:
union ublob {uint16_t hh[8]; uint32_t ww[4]; } u;
int test1(int i, int j)
{
if (u.hh[i])
u.ww[j] = 1;
return u.hh[i];
}
int test2(int i, int j)
{
if (*(u.hh+i))
*(u.ww+j) = 1;
return *(u.hh+i);
}
int test3(int i, int j)
{
uint16_t temp;
{
uint16_t *p1 = u.hh+i;
temp = *p1;
}
if (temp)
{
uint32_t *p2 = u.ww+j;
*p2 = 1;
}
{
uint16_t *p3 = u.hh+i;
temp = *p3;
}
return temp;
}
static int test4a(uint16_t *p1, uint32_t *p2)
{
if (*p1)
*p2 = 1;
return *p1;
}
int test4(int i, int j)
{
return test4a(u.hh+i, u.ww+j);
}
如所写,标准中的任何内容都不会暗示其中任何一个都具有定义的行为,除非它们都定义了行为,但是如果 test1
没有,那么在联合中拥有数组的能力将毫无用处在支持相关类型的平台上定义的行为。如果编译器编写者认识到对公共类型双关结构的支持是一个实现质量问题,他们就会认识到一个实现无法处理前三个问题就没有什么借口了,因为任何不是故意盲目的编译器都会看到证据表明这些指针都与通用类型 union ublob
的对象相关,而没有义务在 test4a
中处理此类可能性,因为在
中不存在此类证据。
想象一下:
uint64_t x = *(uint64_t *)((unsigned char[8]){'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'});
我有 union
有何不同,除了类型双关语是未定义的行为而 union
s 不是?我亲自问过一位程序员同事,他们说如果你这样做,要么你知道你在做什么非常好,要么你犯了一个错误。但是社区说应该始终避免这种做法吗?为什么?
最终原因是“因为语言规范是这么说的”。你不必为此争论。如果这就是语言的方式,那就是它的方式。
如果您想知道这样做的动机,那就是原始的 C 语言缺乏任何表达两个左值不能相互别名的方式(并且现代语言的 restrict
关键字仍然几乎不为该语言的大多数用户所理解)。无法假定两个左值不能别名意味着编译器无法重新排序加载和存储,并且必须为每次访问对象实际执行加载和存储 from/to 内存,而不是将值保存在寄存器中,除非它知道对象的地址从未被占用。
C 的 type-based 别名规则 通过让编译器假定不同类型的左值不别名来稍微缓解这种情况。
另请注意,在您的示例中,不仅 type-punning 存在错位。 unsigned char
数组没有固有的对齐方式,因此在该地址访问 uint64_t
将是对齐错误(另一个原因是 UB),与任何别名规则无关。
类型双关被认为是 UB,因为标准的作者期望在标准没有强加任何要求的情况下,用于各种目的的质量实现将以“环境特征的文档化方式”表现,但这种行为会达到预期目的。因此,避免对实现施加过于严格的要求比要求它们支持程序员需要的一切更为重要。
为了改编和稍微扩展基本原理中的示例,请考虑代码(为简单起见,假设一个普通的 32 位实现):
unsigned x;
unsigned evil(double *p)
{
if (x) *p = 1.0;
return x;
}
...
unsigned y;
int main(void)
{
if (&y == &x + 1)
{
unsigned res;
x=1;
res = evil((double*)&x);
printf("You get to find out the first word of 1.0; it's %08X.\n", res);
}
else
{
printf("You don't get to find out the first word of 1.0; too bad.\n");
}
return 0;
}
在没有“严格的别名规则”的情况下,处理 evil
的编译器必须考虑到它可能被调用的可能性,如 test
所示,在可能发生的实现中连续放置两个 int
值,使得 double
可以适合由此占用的 space。基本原理的作者认识到,如果编译器返回 if
所看到的 x
的值,在这种情况下结果将是“不正确的”,但即使是大多数类型双关语的提倡者会承认这样做的编译器(在这种情况下)通常比重新加载x
(因此生成效率较低的代码)的编译器更有用。
请注意,所写规则并未描述实现应支持类型双关的所有情况。给出类似的东西:
union ublob {uint16_t hh[8]; uint32_t ww[4]; } u;
int test1(int i, int j)
{
if (u.hh[i])
u.ww[j] = 1;
return u.hh[i];
}
int test2(int i, int j)
{
if (*(u.hh+i))
*(u.ww+j) = 1;
return *(u.hh+i);
}
int test3(int i, int j)
{
uint16_t temp;
{
uint16_t *p1 = u.hh+i;
temp = *p1;
}
if (temp)
{
uint32_t *p2 = u.ww+j;
*p2 = 1;
}
{
uint16_t *p3 = u.hh+i;
temp = *p3;
}
return temp;
}
static int test4a(uint16_t *p1, uint32_t *p2)
{
if (*p1)
*p2 = 1;
return *p1;
}
int test4(int i, int j)
{
return test4a(u.hh+i, u.ww+j);
}
如所写,标准中的任何内容都不会暗示其中任何一个都具有定义的行为,除非它们都定义了行为,但是如果 test1
没有,那么在联合中拥有数组的能力将毫无用处在支持相关类型的平台上定义的行为。如果编译器编写者认识到对公共类型双关结构的支持是一个实现质量问题,他们就会认识到一个实现无法处理前三个问题就没有什么借口了,因为任何不是故意盲目的编译器都会看到证据表明这些指针都与通用类型 union ublob
的对象相关,而没有义务在 test4a
中处理此类可能性,因为在