如何通过不同类型重新解释数据?(类型别名混淆)

6
#include <iostream>

int main(int argc, char * argv[])
{
    int a = 0x3f800000;

    std::cout << a << std::endl;

    static_assert(sizeof(float) == sizeof(int), "Oops");

    float f2 = *reinterpret_cast<float *>(&a);

    std::cout << f2 << std::endl;

    void * p = &a;
    float * pf = static_cast<float *>(p);
    float f3 = *pf;

    std::cout << f3 << std::endl;

    float f4 = *static_cast<float *>(static_cast<void *>(&a));

    std::cout << f4 << std::endl;
}

我从我的可靠编译器中获取了以下信息:

me@Mint-VM ~/projects $ g++-5.3.0 -std=c++11 -o pun pun.cpp -fstrict-aliasing -Wall
pun.cpp: In function ‘int main(int, char**)’:
pun.cpp:11:45: warning: dereferencing type-punned pointer will break strict-aliasing rules [-Wstrict-aliasing]
     float f2 = *reinterpret_cast<float *>(&a);
                                             ^
pun.cpp:21:61: warning: dereferencing type-punned pointer will break strict-aliasing rules [-Wstrict-aliasing]
     float f4 = *static_cast<float *>(static_cast<void *>(&a));
                                                             ^
me@Mint-VM ~/projects $ ./pun
1065353216
1
1
1
me@Mint-VM ~/projects $ g++-5.3.0 --version
g++-5.3.0 (GCC) 5.3.0
Copyright (C) 2015 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

我不太明白为什么有些地方会出现类型转换错误,而有些地方却不会。
因此,严格别名
严格别名是C(或C ++)编译器做出的一种假设,即对于不同类型对象的指针解除引用将永远不会引用相同的内存位置(即别名)。
第11行声称我正在破坏严格别名。我看不到这可能会产生任何伤害的情况——指针“出现”,立即被解除引用,然后被丢弃。很可能,这将编译成零条指令。这似乎完全没有风险——我正告诉编译器我想要什么。
第15-16行继续不引起警告,即使指向同一内存位置的指针现在已经留下来了。这似乎是gcc中的一个错误

第21行引发了警告,这表明这不仅限于reinterpret_cast。

Union也不是更好的选择(强调我的):

……从联合体中读取最近未被写入的成员是未定义的行为。许多编译器实现了一种非标准语言扩展,能够读取联合体中非活动成员的值。

这个链接讨论了使用memcpy,但似乎只是隐藏了你真正想要完成的任务。

对于某些系统,将指针写入int寄存器或接收传入的字节流并将这些字节组装成float或其他非整数类型是必需的操作。

正确的、符合标准的方法是什么?


1
Anton的答案绝对正确。这是唯一可移植的方式,也是唯一保证可行的方式。甚至没有任何性能或内存开销,因为优化器会看到你试图做什么,并且很可能不会复制任何内存。在您多次阅读和理解标准之前,您必须相信我们。 - Richard Hodges
不是问题,但除非你需要它提供的额外功能,否则不要使用 std::endl'\n' 用于结束一行。 - Pete Becker
@RichardHodges,不是要不敬,但我不喜欢“你必须相信我们”的说法——我没有标准的副本,但我已经阅读了cppreference.com上提供的内容(不确定有多接近)。所有这些对我来说都意味着标准中缺乏一个应该解决的好答案。 - bizaff
1
在我看来,标准应该为此提供更好的支持。如果我使用memcpy,我会丢失类型信息,并需要正确调用标准库函数。这似乎比reinterpret_cast更有风险,因为reinterpret_cast的整个目的是“通过重新解释底层位模式在类型之间进行转换”。 - bizaff
@bizaff:不仅如此,而且说一个想要使用类型为T1的指针来访问类型为T2的内容的程序员不能以编译器能够知道只能访问类型为T1和T2的方式来做,而必须以可能与任何地方的任何东西发生别名关系的方式来做,只要其地址已经暴露给外部世界,这似乎不是一个提高性能的方法。 - supercat
显示剩余3条评论
3个回答

9

请注明Anton的贡献。他的答案是第一个,也是正确的。

我发布这篇文章是因为我知道你不会相信他,直到你看到汇编代码:

假设:

#include <cstring>
#include <iostream>

// prevent the optimiser from eliding this function altogether
__attribute__((noinline))
float convert(int in)
{
    static_assert(sizeof(float) == sizeof(int), "Oops");
    float result;
    memcpy(&result, &in, sizeof(result));
    return result;
}

int main(int argc, char * argv[])
{
    int a = 0x3f800000;
    float f = convert(a);


    std::cout << a << std::endl;
    std::cout << f << std::endl;
}

结果:

1065353216
1

使用-O2编译,以下是函数convert的汇编输出,为了更加清晰,添加了一些注释:

#
# I'll give you £10 for every call to `memcpy` you can find...
#
__Z7converti:                           ## @_Z7converti
    .cfi_startproc
## BB#0:
    pushq   %rbp
Ltmp0:
    .cfi_def_cfa_offset 16
Ltmp1:
    .cfi_offset %rbp, -16
    movq    %rsp, %rbp
Ltmp2:
    .cfi_def_cfa_register %rbp
#
# here's the conversion - simply move the integer argument (edi)
# into the first float return register (xmm0)
#
    movd    %edi, %xmm0
    popq    %rbp
    retq
    .cfi_endproc
#
# did you see any memcpy's? 
# nope, didn't think so.
#

为了更加明显地说明问题,这里展示了同一个函数使用-O2和-fomit-frame-pointer编译的结果:
__Z7converti:                           ## @_Z7converti
    .cfi_startproc
## BB#0:
    movd    %edi, %xmm0
    retq
    .cfi_endproc

记住,这个函数仅仅因为我添加了属性才存在,以防止编译器将其内联。实际上,在启用优化的情况下,整个函数将被优化掉。函数中的那3行代码和调用位置处的调用都将消失。
现代优化编译器非常棒。
但是我真正想要的是 std::cout << *reinterpret_cast<float *>(&a) << std::endl;,我认为它完美地表达了我的意图。
嗯,确实如此。但是,C++旨在兼顾正确性和性能。很多时候,编译器希望假设两个指针或两个引用不指向同一块内存。如果可以这样做,它就可以进行各种聪明的优化(通常涉及不必要的读写操作,以产生所需的效果)。但是,由于对一个指针的写入可能会影响到对另一个指针的读取(如果它们确实指向同一个对象),所以为了正确性,在利益方面,编译器可能不会假设这两个对象是不同的,并且必须执行您在代码中指定的每个读取和写入操作——以防一次写入影响后续的读取……除非指针指向不同的类型。如果它们指向不同的类型,则编译器可以假设它们永远不会指向相同的内存——这就是严格别名规则。
当你这样做:*reinterpret_cast<float *>(&a)
你正在尝试通过int指针和float指针读取相同的内存。因为指针是不同类型的,编译器将假定它们指向不同的内存地址——即使在你的脑海中它们并不是。
这就是结构体别名规则。它旨在帮助程序快速而正确地执行。像这样的重新解释转换会阻止这一点。

@RichardHodges:源和目标不会重叠,但是除非编译器知道源指针的“来源”,否则它必须确保在memcpy之前完成对全局变量的任何挂起写入,并且除非它知道目标来自哪里,否则它必须丢弃可能已缓存的任何全局变量值,并且不能将任何全局变量读取移动到memcpy之前。 - supercat
@RichardHodges:给定 uint16_t *buff;,一个调用 inline void store_pair(uint32_t dat) { *((uint32_t*)buff)=dat; buff+=2; } 的循环将是高效的,如果编译器认识到 buff 不能别名自身,但如果赋值被替换为 memcpy,编译器将不得不在每个存储之前存储 buff 并重新加载 buff - 这是一种严重的性能惩罚,可以通过更可用的别名规则避免。 - supercat
转换和存储是一个概念,标准不要求编译器支持,但它非常有用且可以很容易地支持,以至于编译器几乎普遍支持它,直到编译器编写者更喜欢说“由于标准不要求我们支持这个概念,所以我们不会”,从而使得以高效且保证工作的方式表达这样的语义变得不可能。 - supercat
@supercat 避免聊天 - 聯繫詳情和計數器代碼在這裡 :-) http : //tinyurl .com / hswtrh7 - Richard Hodges
显示剩余17条评论

8

使用 memcpy

memcpy(&f2, &a, sizeof(float));

如果你担心类型安全和语义问题,你可以轻松地编写一个包装器:

void convert(float& x, int a) {
    memcpy(&x, &a, sizeof(float));
}

如果您愿意,您可以制作此包装模板以满足您的需求。


1
这对我来说似乎是错误的答案 - 不是因为你错了,而是标准非常缺乏一个好的答案。它通过剥离类型并使用char指针来积极地隐藏信息,使编译器难以理解。如果这是目前符合标准的唯一方法,那就这样吧。 - bizaff
@bizaff 我预料到您可能会有怀疑,因此我提供了一个包含完整证明的答案。 - Richard Hodges
1
正如已经展示的那样,编译器能够优化对 memcpy 的调用,因此它的行为与 reinterpret_cast 完全相同。 - Anton Savin
1
@bizaff:基本上,标准已经破损了,但编译器编写者过去认为标准应该允许一些本不该允许的东西。所有别名规则背后的理论是,编译器不应该假设使用未知来源的 int* 的写入会影响到类型为“double”的命名变量(这可能是一个合理的假设),但该规则已被用来证明即使在指针被强制转换并在同一表达式中使用的情况下,编译器也应忽略别名,这对于任何不故意迂腐的人来说都是显而易见的... - supercat
新铸造的指针很可能与旧类型的对象别名。该规则的假定目的是为了促进优化,但是超现代的解释却相反,因为它不允许构造一个明智的编译器可以识别出可能被别名的特定类型,并且要求使用可以别名任何东西的memcpy。 - supercat
显示剩余6条评论

1

正如您所发现的那样,reinterpret_cast 不能用于类型强制转换。

自 C++20 开始,您可以通过 std::bit_cast 安全地进行类型强制转换。

uint32_t bit_pattern = 0x3f800000U;
constexpr auto f = std::bit_cast<float>(bit_pattern);

目前std::bit_cast只被MSVC支持

在等待其他人实现它的同时,如果你正在使用Clang,可以尝试__builtin_bit_cast。只需要像这样进行转换。

float f = __builtin_bit_cast(float, bit_pattern);

查看在Godbolt上的演示


在其他编译器或旧版C++标准中,您唯一能够使用的方法是通过memcpy
然而,许多编译器有特定于实现的方式来进行类型转换,或者关于类型转换的特定于实现的行为。例如,在GCC中,您可以使用__attribute__((__may_alias__))
union Float
{
    float __attribute__((__may_alias__)) f;
    uint32_t __attribute__((__may_alias__)) u;
};

uint32_t getFloatBits(float v)
{
    Float F;
    F.f = v;
    return F.u;
}

ICC和Clang也支持该属性。请参阅演示

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