在 C++20 中双关 uint64_t 作为两个 uint32_t

Type-pun uint64_t as two uint32_t in C++20

由于严格的别名规则,将 uint64_t 读取为两个 uint32_t 的代码是 UB:

uint64_t v;
uint32_t lower = reinterpret_cast<uint32_t*>(&v)[0];
uint32_t upper = reinterpret_cast<uint32_t*>(&v)[1];

同理,这段写上下两部分的代码uint64_t也是UB,同理:

uint64_t v;
uint32_t* lower = reinterpret_cast<uint32_t*>(&v);
uint32_t* upper = reinterpret_cast<uint32_t*>(&v) + 1;

*lower = 1;
*upper = 1;

如何在可能使用 std::bit_cast 的现代 C++20 中以一种安全、干净的方式编写此代码?

使用std::bit_cast:

Try it online!

#include <bit>
#include <array>
#include <cstdint>
#include <iostream>

int main() {
    uint64_t x = 0x12345678'87654321ULL;
    // Convert one u64 -> two u32
    auto v = std::bit_cast<std::array<uint32_t, 2>>(x);
    std::cout << std::hex << v[0] << " " << v[1] << std::endl;
    // Convert two u32 -> one u64
    auto y = std::bit_cast<uint64_t>(v);
    std::cout << std::hex << y << std::endl;
}

输出:

87654321 12345678
1234567887654321

std::bit_cast 仅在 C++20 中可用。在 C++20 之前,您可以通过 std::memcpy 手动实现 std::bit_cast,但有一个例外,即这种实现不像 C++20 变体那样 constexpr :

template <class To, class From>
inline To bit_cast(From const & src) noexcept {
    //return std::bit_cast<To>(src);
    static_assert(std::is_trivially_constructible_v<To>,
        "Destination type should be trivially constructible");
    To dst;
    std::memcpy(&dst, &src, sizeof(To));
    return dst;
}

对于这种特定的整数情况,相当理想的做法是进行位 shift/or 运算,将一个 u64 转换为两个 u32,然后再转换回来。 std::bit_cast 更通用,支持任何简单的可构造类型,尽管 std::bit_cast 解决方案应该与现代编译器上的位算术具有相同的最佳优化水平。

位运算的一个额外好处是它可以正确处理字节序,它与字节序无关,不像 std::bit_cast

Try it online!

#include <cstdint>
#include <iostream>

int main() {
    uint64_t x = 0x12345678'87654321ULL;
    // Convert one u64 -> two u32
    uint32_t lo = uint32_t(x), hi = uint32_t(x >> 32);
    std::cout << std::hex << lo << " " << hi << std::endl;
    // Convert two u32 -> one u64
    uint64_t y = (uint64_t(hi) << 32) | lo;
    std::cout << std::hex << y << std::endl;
}

输出:

87654321 12345678
123456788765432

注意! 作为@Jarod42 ,移位的解决方案不等同于memcpy/bit_cast解决方案,它们的等价性取决于关于字节顺序。在 little endian CPU memcpy/bit_cast 中给出最低有效的一半 (lo) 作为数组元素 v[0] 和最高有效的 (hi) 在 v[1] 中,而在 big endian 最不重要 (lo) 进入 v[1],最重要进入 v[0]。虽然位移解决方案与字节顺序无关,但在所有系统上,最高有效一半 (hi) 为 uint32_t(num_64 >> 32),最低有效一半 (lo) 为 uint32_t(num_64).

使用std::memcpy

#include <cstdint>
#include <cstring>

void foo(uint64_t& v, uint32_t low_val, uint32_t high_val) {
    std::memcpy(reinterpret_cast<unsigned char*>(&v), &low_val,
                sizeof(low_val));
    std::memcpy(reinterpret_cast<unsigned char*>(&v) + sizeof(low_val),
                &high_val, sizeof(high_val));
}

int main() {
    uint64_t v = 0;
    foo(v, 1, 2);
}

使用 O1,编译器将 foo 减少为:

        mov     DWORD PTR [rdi], esi
        mov     DWORD PTR [rdi+4], edx
        ret

意思是没有额外的拷贝,std::memcpy只是给编译器一个提示。

in a safe and clean way

不要使用 reinterpret_cast。不要依赖于依赖于某些特定编译器设置和可疑、不确定行为的不明确代码。使用具有众所周知的定义结果的精确算术运算。 类 和运算符重载都在那里等着你。比如一些全局函数:

#include <iostream>

struct UpperUint64Ref {
   uint64_t &v;
   UpperUint64Ref(uint64_t &v) : v(v) {}
   UpperUint64Ref operator=(uint32_t a) {
      v &= 0x00000000ffffffffull;
      v |= (uint64_t)a << 32;
      return *this;
   }
   operator uint64_t() {
      return v;
   }
};
struct LowerUint64Ref { 
    uint64_t &v;
    LowerUint64Ref(uint64_t &v) : v(v) {}
    /* as above */
};
UpperUint64Ref upper(uint64_t& v) { return v; }
LowerUint64Ref lower(uint64_t& v) { return v; }

int main() {
   uint64_t v;
   upper(v) = 1;
}

或接口对象:

#include <iostream>

struct Uint64Ref {
   uint64_t &v;
   Uint64Ref(uint64_t &v) : v(v) {}
   struct UpperReference {
       uint64_t &v;
       UpperReference(uint64_t &v) : v(v) {}
       UpperReference operator=(uint32_t a) {
           v &= 0x00000000ffffffffull;
           v |= (uint64_t)a << 32u;
       }
   };
   UpperReference upper() {
      return v;
   }
   struct LowerReference {
       uint64_t &v;
       LowerReference(uint64_t &v) : v(v) {}
   };
   LowerReference lower() { return v; }
};
int main() {
   uint64_t v;
   Uint64Ref r{v};
   r.upper() = 1;
}

std::bit_cast 是不够的,因为结果会因系统的字节顺序而异。

幸好<bit>还包含std::endian.

请记住,优化器通常会在编译时解析 if 始终为 true 或 false 的值,我们可以测试字节顺序并采取相应措施。

我们只知道如何处理大端或小端。如果不是其中之一,bit_cast 结果不可解码。

另一个可能破坏事物的因素是填充。使用 bit_cast 假定数组元素之间的填充为 0。

所以我们可以检查是否没有padding和字节顺序是大还是小,看是否可以转换。

  • 如果它不可施放,我们会按照旧方法进行一系列转换。 (这可能会很慢)
  • 如果字节顺序是 big -- return 结果 bit_cast.
  • 如果字节序是little -- 颠倒顺序。 与 c++23 byteswap 不同,因为我们交换元素。

我武断地认为 big-endian 的顺序正确,高位在 x[0]。

#include <bit>
#include <array>
#include <cstdint>
#include <climits>
#include <concepts>

template <std::integral F, std::integral T>
    requires (sizeof(F) >= sizeof(T))
constexpr auto split(F x) { 
    enum consts {
        FBITS=sizeof(F)*CHAR_BIT,
        TBITS=sizeof(F)*CHAR_BIT,
        ELEM=sizeof(F)/sizeof(T),
        BASE=FBITS-TBITS,
        MASK=~0ULL >> BASE
    };
    using split=std::array<T, ELEM>;
    const bool is_big=std::endian::native==std::endian::big;
    const bool is_little=std::endian::native==std::endian::little;
    const bool can_cast=((is_big || is_little)
        && (sizeof(F) == sizeof(split)));

    // All the following `if`s should be eliminated at compile time
    // since they are always true or always false
    if (!can_cast)
    {
        split ret;
        for (int e = 0; e < ELEM; ++e)
        {
            ret[e]=(x>>(BASE-e*TBITS)) & MASK;
        }
        return ret;
    }
    split tmp=std::bit_cast<split>(x);
    if (is_big)
    {
        return tmp;
    }
    split ret;
    for (int e=0; e < ELEM; ++e)
    {
        ret[e]=tmp[ELEM-(e+1)];
    }
    return ret;
}

auto tst(uint64_t x, int y)
{
    return split<decltype(x), uint32_t>(x)[y];
}

我认为这应该是定义的行为。

编辑:将 uint64 基数更改为模板参数并进行了细微的编辑调整

别费心了,反正算术更快:

uint64_t v;
uint32_t lower = v;
uint32_t upper = v >> 32;