在C++中,现代且正确的类型转换方式是什么?

50

看起来C++有两种类型。实用的C++和专业的C++。在某些情况下,将一种类型的位模式解释为另一种类型可能是有用的。浮点数技巧是一个显著的例子。让我们以著名的快速反平方根为例(取自Wikipedia,该页面又引用了here):

float Q_rsqrt( float number )
{
    long i;
    float x2, y;
    const float threehalfs = 1.5F;

    x2 = number * 0.5F;
    y  = number;
    i  = * ( long * ) &y;                       // evil floating point bit level hacking
    i  = 0x5f3759df - ( i >> 1 );               // what the
    y  = * ( float * ) &i;
    y  = y * ( threehalfs - ( x2 * y * y ) );   // 1st iteration
//  y  = y * ( threehalfs - ( x2 * y * y ) );   // 2nd iteration, this can be removed

    return y;
}

撇开细节不谈,它使用IEEE-754浮点位表示的某些属性。这里有趣的部分是从float*long**(long*)转换。在C和C++之间有关于此类重新解释转换的哪种类型被定义为行为的差异,但是在实践中,这种技术经常在两种语言中使用。
问题在于对于这样一个简单的问题,上述方法和其他不同的方法可能会出现很多陷阱。举几个例子: 同时,有很多方法可以执行类型强制转换和与之相关的机制。以下是我能找到的所有内容:
  • reinterpret_cast and c-style cast

    [[nodiscard]] float int_to_float1(int x) noexcept
    {
        return *reinterpret_cast<float*>(&x);
    }
    [[nodiscard]] float int_to_float2(int x) noexcept
    {
        return *(float*)(&x);
    }
    
  • static_cast and void*

    [[nodiscard]] float int_to_float3(int x) noexcept
    {
        return *static_cast<float*>(static_cast<void*>(&x));
    }
    
  • std::bit_cast

    [[nodiscard]] constexpr float int_to_float4(int x) noexcept
    {
        return std::bit_cast<float>(x);
    }
    
  • memcpy

    [[nodiscard]] float int_to_float5(int x) noexcept
    {
        float destination;
        memcpy(&destination, &x, sizeof(x));
        return destination;
    }
    
  • union

    [[nodiscard]] float int_to_float6(int x) noexcept
    {
        union {
            int as_int;
            float as_float;
        } destination{x};
        return destination.as_float;
    }
    
  • placement new and std::launder

    [[nodiscard]] float int_to_float7(int x) noexcept
    {
        new(&x) float;
        return *std::launder(reinterpret_cast<float*>(&x));
    }
    
  • std::byte

    [[nodiscard]] float int_to_float8(int x) noexcept
    {
        return *reinterpret_cast<float*>(reinterpret_cast<std::byte*>(&x));
    }
    
这个问题是哪些方法是安全的,哪些是不安全的,哪些是永远被遗弃的。应该使用哪种方法以及为什么?C++社区接受了一个规范吗?为什么C++的新版本引入了更多的机制,例如C++17中的std::launder或C++20中的std::bytestd::bit_cast
举一个具体的问题:重新编写快速反平方根函数最安全、最高效和最好的方法是什么?(是的,我知道维基百科上有一种方法的建议)。
编辑:为了增加混乱,似乎有一个提案建议添加另一种类型切换机制:std::start_lifetime_as,这也在另一个问题中讨论过。
godbolt

8
你所说的实用和纠结于语言细节其实是关于可移植性的问题。当标准声称某些代码行为未定义时,你可以研究编译器的行为,但那样你就会被限制在特定编译器、编译选项和目标平台上。我不是语言专家,但我必须编写的代码在这里编译后也能在其他地方编译通过。这是非常实用的观点。 - 463035818_is_not_a_number
7
我认为只有 std::bit_castmemcpy 不会导致未定义行为(UB)。 - Jarod42
2
std::bit_cast 仅适用于 C++20 及更高版本...但肯定是现代的方式。 - Serge Ballesta
4
顺便说一下,维基百科上的“然而,在C++中,通过联合进行类型转换被认为是不好的实践。”这句话并不完全正确。在C++中,通过联合进行类型转换是未定义行为,尽管许多编译器将其作为扩展功能提供。 - 463035818_is_not_a_number
3
即使出于优化目的(例如快速计算平方根倒数)而进行此类操作的动机随着时间的推移已经消失,但这并不意味着现代C++程序员永远不需要进行类型转换。在嵌入式开发中,这是非常普遍的。 - Cody Gray
显示剩余7条评论
2个回答

18

首先,你假设 sizeof(long) == sizeof(int) == sizeof(float)。这不总是正确的,而且完全未指定(依赖于平台)。实际上,在我的Windows上使用clang-cl时是正确的,在同一台64位Linux机器上使用相同的编译器却是错误的。同一操作系统/机器上的不同编译器可能会产生不同的结果。至少需要一个静态断言来避免隐蔽的错误。

由于严格别名规则(严谨地说,关于C ++标准,这种程序是不合法的),纯C强制转换、重新解释强制转换和静态强制转换都是无效的。联合解决方案也无效(它仅在C中有效,而不适用于C ++)。只有std::bit_caststd::memcpy解决方案是“安全的”(假设类型的大小在目标平台上匹配)。使用std::memcpy通常很快,因为大多数主流编译器都进行了优化(启用优化后,例如对于GCC / Clang的-O3):可以内联std::memcpy调用并替换为更快的指令。std::bit_cast是新的方法(仅自C ++20起)。最后一种解决方案对于C ++代码更加干净,因为std::memcpy使用不安全的void *类型,从而规避了类型系统。


1
我相信原帖作者已经了解了所有这些。重复问题并不是一个答案。 - Red.Wave

14

这是我使用-O3编译选项在gcc 11.1上得到的结果:

int_to_float4(int):
        movd    xmm0, edi
        ret
int_to_float1(int):
        movd    xmm0, edi
        ret
int_to_float2(int):
        movd    xmm0, edi
        ret
int_to_float3(int):
        movd    xmm0, edi
        ret
int_to_float5(int):
        movd    xmm0, edi
        ret
int_to_float6(int):
        movd    xmm0, edi
        ret
int_to_float7(int):
        mov     DWORD PTR [rsp-4], edi
        movss   xmm0, DWORD PTR [rsp-4]
        ret
int_to_float8(int):
        movd    xmm0, edi
        ret

我不得不添加 auto x = &int_to_float4; 以强制gcc实际发出 int_to_float4 的任何内容,我猜这就是它首先出现的原因。

实时示例

我对 std::launder 不太熟悉,所以无法说出它为什么不同。除此之外,它们是相同的。这是gcc在这种情况下使用那个标志的说法。标准所说的则是另一回事。尽管如此,memcpy(&destination, &x, sizeof(x)); 是明确定义的,并且大多数编译器都知道如何优化它。C++20引入了 std::bit_cast 来使这样的转换更加明确。请注意,在cppreference上的可能实现中,它们使用了 std::memcpy ;).


简而言之

重写快速反平方根函数的最安全、最有效和最好的方法是什么?

std::memcpy 和在C++20及以上版本中 std::bit_cast


6
GCC没有为int_to_float4生成任何代码,因为该函数没有实现任何功能,并且您将其标记为constexpr,因此它总是会内联,而且它也没有被使用,所以不需要为该函数生成代码。作为替代方案,您可以删除constexpr而不是获取函数地址。(请注意,在constexpr函数中忽略[[noinline]]属性。)话虽如此,我看不出列出一个特定编译器��成的汇编列表与解答这个问题有任何关系。 - Cody Gray
4
实际上,仅仅通过一些简单例子的编译器输出来判断是常常会误导人的。其中的一些方法看起来在独立的情况下很好,但是当它们被放到更复杂的代码中时,可能会因为类似严格别名违规等问题而崩溃。 - Nate Eldredge
1
@CodyGray 不是这样的。出于好奇,我查看了gcc对OP代码的输出(没有修改,这就是constexpr[[noinline]]的原因)。与其在评论中分享godbolt链接,就发生了这种情况。确实,给人留下这个gcc输出示例会有助于回答OP问题的印象是不好的,然后除了“使用memcpy”之外就没有别的了。我的错误是没有认识到一个高质量的问题,然后试图将一些不是答案的东西措辞成答案。这需要进行严肃的重写,但需要一些时间。 - 463035818_is_not_a_number

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