Search code examples
c++performancecastinglanguage-lawyertype-punning

What is the modern, correct way to do type punning in C++?


It seems like there are two types of C++. The practical C++ and the language lawyer C++. In certain situations, it can be useful to be able to interpret a bit pattern of one type as if it were a different type. Floating-point tricks are a notable example. Let's take the famous fast inverse square root (taken from Wikipedia, which was in turn taken from 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;
}

Setting aside details, it uses certain properties of the IEEE-754 floating-point bit representation. The interesting part here is the *(long*) cast from float* to long*. There are differences between C and C++ about which types of such reinterpreting casts are defined behavior, however in practice such techniques are used often in both languages.

The thing is that for such a simple problem there are a lot of pitfalls that can occur with the approach presented above and different others. To name some:

At the same time, there are a lot of ways of performing type punning and a lot of mechanisms related to it. These are all that I could find:

  • 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));
    }
    

The question is which of these ways are safe, which are unsafe, and which are damned forever. Which one should be used and why? Is there a canonical one accepted by the C++ community? Why are new versions of C++ introducing even more mechanisms std::launder in C++17 or std::byte, std::bit_cast in C++20?

To give a concrete problem: what would be the safest, most performant, and best way to rewrite the fast inverse square root function? (Yes, I know that there is a suggestion of one way on Wikipedia).

Edit: To add to the confusion, it seems that there is a proposal that suggests adding yet another type punning mechanism: std::start_lifetime_as, which is also discussed in another question.

(godbolt)


Solution

  • This is what I get from gcc 11.1 with -O3:

    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
    

    I had to add a auto x = &int_to_float4; to force gcc to actually emit anything for int_to_float4, I guess thats the reason it appears first.

    Live Example

    I am not that familiar with std::launder so I cannot tell why it is different. Otherwise they are identical. This is what gcc has to say about it (in this context, with that flags). What the standard says is different story. Though, memcpy(&destination, &x, sizeof(x)); is well defined and most compilers know how to optimize it. std::bit_cast was introduced in C++20 to make such casts more explicit. Note that in the possible implementation on cppreference they use std::memcpy ;).


    TL;DR

    what would be the safest, most performant and best way to rewrite the fast inverse square root function?

    std::memcpy and in C++20 and beyond std::bit_cast.