The first simple example:
#include <bit>
#include <cstdint>
#include <iomanip>
#include <iostream>
std::uint32_t badBits(const float value)
{
// const prevents writes, but reading a float through a uint32_t pointer
// still violates the strict-aliasing rule.
const auto* bits = reinterpret_cast<const std::uint32_t*>(&value);
return *bits; // Undefined behavior.
}
std::uint32_t goodBits(const float value)
{
// std::bit_cast copies the object representation without violating
// strict-aliasing or object-lifetime rules.
return std::bit_cast<std::uint32_t>(value);
}
int main()
{
constexpr float value = 1.0F;
std::cout << std::hex
<< "bad: 0x" << badBits(value) << '\n'
<< "good: 0x" << goodBits(value) << '\n';
}
When I compile this with
g++ -std=c++23 -O2 -g \
-Wall -Wextra \
-Wstrict-aliasing=2 \
-fsanitize=undefined \
-fno-sanitize-recover=undefined \
-fno-omit-frame-pointer \
a.cpp -o a
I get the following compiler warning:
a.cpp: In function uint32_t badBits(float):
a.cpp:10:67: warning: dereferencing type-punned pointer will break strict-aliasing rules [-Wstrict-aliasing]
10 | const auto* bits = reinterpret_cast<const std::uint32_t*>(&value);
but UB sanitizer does not detect this strict-aliasing violation.
The next example also violates strict aliasing, but it does not generate compiler warnings:
std::uint32_t badBits2(const float value)
{
// Reading an object's representation through std::byte is allowed.
const auto* bytes = reinterpret_cast<const std::byte*>(&value);
// Casting the byte pointer to uint32_t does not create a uint32_t object.
// Dereferencing the resulting pointer still violates strict-aliasing and
// object-lifetime rules.
const auto* bits = reinterpret_cast<const std::uint32_t*>(bytes);
return *bits; // Undefined behavior.
}
In GCC 16 we’ll use std::start_lifetime_as:
std::uint32_t lifetimeBits(float value)
{
static_assert(sizeof(float) == sizeof(std::uint32_t));
static_assert(alignof(float) >= alignof(std::uint32_t));
auto* bytes = reinterpret_cast<std::byte*>(&value);
// Ends the lifetime of float and starts the lifetime of uint32_t
// in the same storage while preserving its object representation.
const auto* bits =
std::start_lifetime_as<std::uint32_t>(bytes);
return *bits;
}
this example requires the source and destination types to have the same size, but different sizes are also possible:
#include <cstdint>
#include <iomanip>
#include <iostream>
#include <memory>
std::uint32_t lifetimeBits(double value) noexcept
{
static_assert(sizeof(double) >= sizeof(std::uint32_t));
static_assert(alignof(double) >= alignof(std::uint32_t));
// Start the lifetime of a uint32_t object in the first four bytes of the
// storage previously occupied by value. The double object's lifetime ends.
const auto* bits =
std::start_lifetime_as<std::uint32_t>(
static_cast<void*>(std::addressof(value)));
// The result contains the first four bytes in memory, so it depends on the
// platform's byte order. This is not a numeric double-to-uint32 conversion.
return *bits;
}
int main()
{
constexpr double value = 1.0;
// On a little-endian system, the first four bytes of double{1.0} are zero.
std::cout << "lifetime: 0x"
<< std::hex
<< lifetimeBits(value)
<< '\n';
}
GCC 16.2 with the following options:
-std=c++23 -O3 -DNDEBUG -Wall -Wextra -Wstrict-aliasing=2
generates the following code:
.globl std::ios_base_library_init()
"lifetimeBits(double)":
movsd QWORD PTR [rsp-8], xmm0
lea rax, [rsp-8]
mov eax, DWORD PTR [rax]
ret
.LC0:
.string "lifetime: 0x"
"main":
sub rsp, 24
mov edx, 12
mov esi, OFFSET FLAT:.LC0
mov edi, OFFSET FLAT:"std::cout"
call "std::basic_ostream<char, std::char_traits<char>>& std::__ostream_insert<char, std::char_traits<char>>(std::basic_ostream<char, std::char_traits<char>>&, char const*, long)"
mov rax, QWORD PTR "std::cout"[rip]
mov rdx, QWORD PTR [rax-24]
mov eax, DWORD PTR "std::cout"[rdx+24]
and eax, -75
or eax, 8
mov DWORD PTR "std::cout"[rdx+24], eax
mov rax, QWORD PTR .LC1[rip]
mov QWORD PTR [rsp+8], rax
lea rax, [rsp+8]
mov esi, DWORD PTR [rax]
mov edi, OFFSET FLAT:"std::cout"
call "std::ostream& std::ostream::_M_insert<unsigned long>(unsigned long)"
mov BYTE PTR [rsp+8], 10
mov rdx, QWORD PTR [rax]
mov rdx, QWORD PTR [rdx-24]
cmp QWORD PTR [rax+16+rdx], 0
je .L4
mov edx, 1
lea rsi, [rsp+8]
mov rdi, rax
call "std::basic_ostream<char, std::char_traits<char>>& std::__ostream_insert<char, std::char_traits<char>>(std::basic_ostream<char, std::char_traits<char>>&, char const*, long)"
.L5:
xor eax, eax
add rsp, 24
ret
.L4:
mov esi, 10
mov rdi, rax
call "std::ostream::put(char)"
jmp .L5
.LC1:
.long 0
.long 1072693248
and the following also possible:
#include <cstdint>
#include <memory>
const std::uint32_t* splitDouble(double& value)
{
static_assert(sizeof(double) >= 2 * sizeof(std::uint32_t));
static_assert(alignof(double) >= alignof(std::uint32_t));
return std::start_lifetime_as_array<std::uint32_t>(
static_cast<void*>(std::addressof(value)),
2);
}
and even this is also possible:
#include <array>
#include <bit>
#include <cstddef>
#include <cstdint>
#include <iomanip>
#include <iostream>
#include <memory>
#include <tuple>
using DoubleParts = std::tuple<std::uint16_t, std::uint16_t, std::uint32_t>;
DoubleParts lifetimeBits(const double value) noexcept
{
static_assert(
sizeof(double)
== sizeof(std::uint16_t) + sizeof(std::uint16_t) + sizeof(std::uint32_t));
// Copy the object representation into aligned byte storage. The byte array
// provides storage for the three implicit-lifetime integer objects.
alignas(std::uint32_t) auto storage =
std::bit_cast<std::array<std::byte, sizeof(double)>>(value);
// Start three independent object lifetimes in non-overlapping regions.
const auto* first =
std::start_lifetime_as<std::uint16_t>(storage.data());
const auto* second =
std::start_lifetime_as<std::uint16_t>(storage.data() + sizeof(std::uint16_t));
const auto* third =
std::start_lifetime_as<std::uint32_t>(
storage.data() + sizeof(std::uint16_t) + sizeof(std::uint16_t));
// Copy the values into the tuple before the local storage is destroyed.
return {*first, *second, *third};
}
int main()
{
constexpr double value = 1.0;
const auto [first, second, third] = lifetimeBits(value);
// The values reflect the platform's byte order. They are not numeric
// conversions from double to integer types.
std::cout << std::hex
<< "first: 0x" << first << '\n'
<< "second: 0x" << second << '\n'
<< "third: 0x" << third << '\n';
}
Notes
Even if I compile the first example with -fno-strict-aliasing as follows:
g++ -std=c++23 -O2 -g
-Wall -Wextra
-fno-strict-aliasing
-fsanitize=undefined
-fno-sanitize-recover=undefined
-fno-omit-frame-pointer
a.cpp -o a
there is still UB, because the lifetime of const std::uint32_t does not start:
std::uint32_t badBits(const float value)
{
// const prevents writes, but reading a float through a uint32_t pointer
// still violates the strict-aliasing rule.
const auto* bits = reinterpret_cast<const std::uint32_t*>(&value);
return *bits; // Undefined behavior.
}

