· 6 min read ·

reinterpret_cast Doesn't Do What You Think It Does

Source: isocpp

There is a mental model that most C++ developers carry around without examining it, and it goes roughly like this: reinterpret_cast takes some memory and tells the compiler to treat it as a different type. That model is wrong in a specific, consequential way, and the consequences have been biting embedded developers and low-level systems programmers for decades.

Andreas Fertig’s recent post on the topic frames this well. The core confusion is between a pointer’s type and an object’s lifetime. reinterpret_cast changes the former. It does absolutely nothing about the latter.

What the Standard Actually Says

The cppreference entry on reinterpret_cast is precise: it converts between pointer types by reinterpreting the underlying bit pattern of the pointer itself. The operative word is “pointer”. The object in memory is untouched, its type is untouched, and its lifetime is untouched.

This is where the strict aliasing rule becomes relevant. The rule says a pointer of type T* may only be used to access an object of type T (with narrow exceptions for char*, unsigned char*, and std::byte*). If you reinterpret_cast a float* to a uint32_t* and dereference it, you are accessing memory through a pointer whose type does not match the type of the live object at that address. That is undefined behavior, and the compiler is permitted to optimize on the assumption it never happens.

Here is the classic trap:

float f = 1.0f;
uint32_t bits = *reinterpret_cast<uint32_t*>(&f); // UB

This works in practice under most compilers with default settings because nothing prevents the load from happening at the machine level. But GCC and Clang both perform strict-aliasing-based optimizations at -O2 and above. The code above can produce wrong results when inlined, loop-hoisted, or CSE’d in ways that assume f and bits cannot possibly refer to the same underlying value. The Undefined Behavior Sanitizer will catch it. The optimizer may silently produce code that breaks your assumptions.

The Buffer Case Is Worse

The float-to-int case at least has an existing float object in memory. The situation gets more interesting when there is no object of the target type at all:

alignas(uint32_t) std::byte buffer[sizeof(uint32_t)];
// Fill buffer with bytes from a network packet...
auto* p = reinterpret_cast<uint32_t*>(buffer);
uint32_t value = *p; // UB: no uint32_t lives here

The buffer contains std::byte objects. There is no uint32_t object whose lifetime has started at that address. Dereferencing p accesses a uint32_t that does not exist. The cast produced a uint32_t* pointing at memory where no uint32_t was ever constructed. This is the distinction that matters: the pointer has a new type, but the object does not.

This pattern is everywhere in embedded and networking code. Binary protocol parsing, memory-mapped register access, DMA buffer interpretation, serialization layers, all of them do something like this. Most of the time they “work” because compilers do not optimize this aggressively at function boundaries, or because -fno-strict-aliasing is set globally. Relying on either of those is not writing correct C++.

The Workarounds Before C++23

The standard workaround has been memcpy:

float f = 1.0f;
uint32_t bits;
std::memcpy(&bits, &f, sizeof(bits)); // well-defined

This is genuinely correct. The compiler recognizes it as a type-punning pattern and typically elides the copy entirely, producing the same load instruction you wanted from reinterpret_cast. The memcpy approach has been the consensus answer for years.

C++20 formalized this into std::bit_cast:

#include <bit>
float f = 1.0f;
uint32_t bits = std::bit_cast<uint32_t>(f); // well-defined, constexpr-capable

bit_cast requires both types to be the same size and both to be trivially copyable. It produces a value copy, not a pointer. It is constexpr. It is the right tool when you want to read the bit representation of an existing value.

But bit_cast does not help with the buffer case. You already have raw memory with the right bit pattern. You do not want to copy it; you want to access it in place as a specific type. That is a distinct operation, and it remained technically undefined until C++23.

std::start_lifetime_as

C++23 introduced std::start_lifetime_as<T>(p), added via P0593. The function explicitly starts the lifetime of a T object at address p, without initializing it. The bits in memory are preserved exactly. It returns a properly typed pointer to the now-live object.

alignas(uint32_t) std::byte buffer[sizeof(uint32_t)];
// Fill buffer with bytes...
auto* p = std::start_lifetime_as<uint32_t>(buffer); // starts uint32_t lifetime
uint32_t value = *p; // well-defined

The key constraint is that T must be an implicit-lifetime type. These are scalar types, array types of scalar types, aggregate types, and types with a trivial destructor and at least one trivial or deleted constructor. Types with user-provided constructors are excluded. The function is intended for POD-like structures, not arbitrary class hierarchies.

For the network protocol parsing case:

struct PacketHeader {
    uint32_t magic;
    uint16_t version;
    uint16_t length;
};

void parse(std::byte* raw) {
    auto* header = std::start_lifetime_as<PacketHeader>(raw);
    if (header->magic == 0xDEADBEEF) {
        // process...
    }
}

The memory at raw must be large enough and aligned for PacketHeader. If those preconditions hold, this is fully defined behavior. No copy, no constructor call, no overhead.

Compiler support landed in GCC 12, Clang 15, and MSVC 19.33 (Visual Studio 2022 17.3), all in C++23 mode.

Why This Took So Long

The underlying problem has been known for a long time. Mike Acton’s 2006 article on strict aliasing for Cell/SPU programming documented exactly how aggressive optimizer assumptions were breaking production code in embedded and console environments. The memcpy workaround predates C++11 by years.

The language took until C++23 to address this directly because the problem touches the object model at a fundamental level. P0593 also introduced implicit object creation for certain operations like malloc, memcpy, and memmove, meaning code that uses those to set up buffers gets retroactively correct behavior. start_lifetime_as is the explicit API for cases where you want the programmer to be deliberate rather than relying on implicit creation rules.

The distinction between implicit and explicit lifetime start matters in practice. P0593 made it so that malloc implicitly creates objects of implicit-lifetime types, which is what makes reinterpret_cast on malloc’d buffers technically valid in C++23 in a way it was not before. start_lifetime_as gives you that same control over any void* or std::byte* buffer, including stack-allocated ones, without requiring a heap allocation.

Choosing the Right Tool

For existing type-punning code, the question to ask is: what operation are you actually performing?

If you want to reinterpret the bits of one value as another value of a different type, use std::bit_cast. It handles the common float/uint32_t float-inspection case, IEEE 754 bit manipulation, hash seed generation from pointer addresses, and similar patterns. Both types must be the same size and trivially copyable.

If you have a raw byte buffer that was filled externally, whether from a network socket, a file read, a hardware register, or a shared memory region, and you want to access it as a typed struct without copying, use std::start_lifetime_as. That is exactly the scenario it was designed for.

If you are targeting C++17 or earlier, memcpy into a properly typed variable is correct and the compiler will optimize it away. It is verbose, but it is the behavior the standard specifies.

What you should not do is reach for reinterpret_cast because it looks like it is doing what you want. It changes a pointer type, not an object’s lifetime. Those are different things. The cast will often produce working code in practice because compilers are not always aggressive enough to break it, and because -fno-strict-aliasing is common in embedded toolchains. But code that works by accident is not the same as code that is correct, and the gap tends to surface during compiler upgrades, link-time optimization, or aggressive inlining across translation units.

The object model in C++ is more principled than it looks from the outside. Pointers have types. Objects have lifetimes. A cast changes the former. start_lifetime_as starts the latter. Understanding which one you need is the whole question.

Was this interesting?