The reason reinterpret_cast causes so much confusion is that it usually works. You cast a void* to a struct pointer, write a value, read it back, and everything looks fine. Then you enable optimizations on Clang and suddenly you’re getting zeros out of fields you just wrote. The bug is real, it was always there, and it has a name: you never started the object’s lifetime.
Andreas Fertig’s recent post on isocpp.org touches on this, but the problem runs deeper than a single cast gone wrong. Understanding it properly requires tracing through the C++ object model, the strict aliasing rule, and why std::start_lifetime_as from C++23 exists as a distinct operation rather than just a cleaner spelling of something you could already do.
The Object Lifetime Model
C++ has a concept called object lifetime that governs when it is legal to read from or write to a piece of memory through a typed pointer. A T object’s lifetime begins when storage with the right size and alignment is obtained and, if the type is non-trivial, a constructor completes. It ends when a destructor runs or the storage is released.
The critical implication: a typed pointer that points to storage not occupied by a live object of that type is not valid to dereference. The standard says so explicitly in [basic.life]. reinterpret_cast changes how you see a pointer. It does not create any object.
This is the code that everyone writes:
void processData(void* data) {
MyType* obj = reinterpret_cast<MyType*>(data); // No object exists here
obj->value = 42; // Undefined behavior
}
The storage exists. The alignment might be correct. But there is no MyType object living in that storage, so accessing obj->value is undefined behavior per the standard, regardless of how the bytes are laid out.
Why GCC Lets This Slide and Clang Does Not
Fertig’s post gives a concrete demonstration. With GCC at -O2, the code above appears to work correctly. With Clang at -O2, making a copy of the object after the write gives 0 instead of 42.
void processData(void* data) {
MyType* obj = reinterpret_cast<MyType*>(data);
obj->value = 42;
auto copy = *obj; // Clang: copy.value is 0, not 42
std::cout << copy.value << '\n';
}
This is not a compiler bug. Clang is conforming. Because no MyType object was ever created at that address, the compiler is permitted to reason that obj->value does not refer to any live object, and therefore reading from it has no defined semantics. The optimizer can propagate whatever value it wants, including zero from a prior zeroing or simply from the knowledge that no store to a valid MyType::value has occurred.
GCC choosing to produce intuitive behavior here is a quality-of-implementation decision, not a guarantee. The fact that it passes your tests means nothing for portability or future compiler versions.
This connects directly to the strict aliasing rule. The compiler assumes that pointers of unrelated types never alias each other. When you cast a void* to MyType* with reinterpret_cast, you’re telling the pointer what type to use for arithmetic and member access, but you’re not making any promise that the memory is occupied by a valid MyType. The optimizer sees an access through a typed pointer with no corresponding object creation and is free to conclude the access is dead.
The Evolution of Type Punning Solutions
Type punning, reading the bit pattern of one type as if it were another, has a long history of workarounds in C++.
The first safe option people reach for is memcpy:
float f = 3.14f;
uint32_t bits;
std::memcpy(&bits, &f, sizeof(bits));
memcpy is guaranteed to produce the right bit pattern in the destination. Compilers are good at optimizing it away when the size is small and statically known. But it requires temporary storage or careful juggling, and for reading hardware register maps or deserializing network packets directly from a buffer, you can’t always afford a copy.
C++20 added std::bit_cast, which handles the value-oriented case cleanly:
float f = 3.14f;
auto bits = std::bit_cast<uint32_t>(f);
This is well-defined, requires both types to be trivially copyable and have the same size, and generates no actual copy at the machine level. For converting between numeric types, it’s the right tool. For pointing a struct pointer at existing memory you received from elsewhere, it doesn’t apply.
That gap is what std::start_lifetime_as fills.
What std::start_lifetime_as Actually Does
std::start_lifetime_as, introduced in C++23 via proposal P0593, begins the lifetime of a T object at the storage pointed to by a void*, without calling any constructor:
void processData(void* data) {
MyType* obj = std::start_lifetime_as<MyType>(data);
obj->value = 42; // Well-defined
auto copy = *obj; // Well-defined, copy.value is 42
std::cout << copy.value << '\n';
}
The bytes in memory are not modified. The values stay exactly as they were. What changes is the C++ object model’s view of that storage: a MyType object now officially exists there, and accessing its members through the returned pointer is legal.
The function has no observable runtime behavior distinct from reinterpret_cast. Implementations will typically compile both to the same instruction sequence. The difference is entirely in what the standard permits the optimizer to assume. With start_lifetime_as, the compiler knows an object exists; with reinterpret_cast, it may assume no valid object exists and optimize accordingly.
There are constraints. T must be an implicit-lifetime type: scalars, arrays of scalars, and aggregate classes that have a trivial destructor and at least one trivial constructor. This covers most structs you’d use for register maps, packet headers, or serialized data formats. A class with a non-trivial constructor, virtual functions, or complex destructor logic is not an implicit-lifetime type, and start_lifetime_as is undefined behavior on it. For those cases, placement new is the correct approach.
The alignment and size requirements still apply. If data doesn’t point to storage of at least sizeof(T) bytes with alignment alignof(T), the behavior remains undefined.
Where This Matters in Practice
The use cases are concentrated in embedded and systems programming: parsing binary network protocols, interpreting memory-mapped hardware registers, working with allocators that hand back raw memory, and reading serialized data.
Consider a hardware register layout:
struct UartControl {
uint32_t baud_rate : 20;
uint32_t parity : 2;
uint32_t stop_bits : 1;
uint32_t reserved : 9;
};
volatile UartControl* uart = std::start_lifetime_as<UartControl>(
reinterpret_cast<void*>(0x40001000)
);
uart->baud_rate = 115200;
Before C++23, this code was technically undefined behavior regardless of how well it worked on every microcontroller you’d ever shipped. The reinterpret_cast on the address literal was already questionable, but the object lifetime issue compounded it. With start_lifetime_as, the struct access is at least well-defined from the lifetime perspective, though the fixed-address cast still requires implementation-defined support for memory-mapped I/O.
For network protocol parsing from a received buffer:
struct EthernetHeader {
uint8_t dst_mac[6];
uint8_t src_mac[6];
uint16_t ethertype;
};
void handle_packet(void* buf, size_t len) {
if (len < sizeof(EthernetHeader)) return;
auto* hdr = std::start_lifetime_as<EthernetHeader>(buf);
// hdr->ethertype is well-defined here
}
Previous code doing this with reinterpret_cast was universally accepted in practice and universally undefined behavior by the standard. start_lifetime_as closes that gap for this entire category of code.
Compiler Support and Adoption Lag
As of early 2026, std::start_lifetime_as is available in GCC 12+, Clang 16+, and MSVC 19.33+. The feature requires compiling with -std=c++23. For embedded targets running older toolchains, it may not be available, and the memcpy-based approach remains the portable fallback.
The irony is that the codebases most likely to need start_lifetime_as are embedded and low-level systems code, exactly the domains with the most conservative compiler upgrade cycles. The C++ committee has been aware of this tension for years; P0593, the proposal that brought implicit lifetime creation and start_lifetime_as into the standard, was first submitted in 2017 and didn’t land until C++23.
The Distinction That Matters
The practical lesson from Fertig’s observation is that reinterpret_cast and start_lifetime_as are solving different problems. reinterpret_cast says “treat this pointer as pointing to a different type.” start_lifetime_as says “a valid object of this type now exists at this address.”
For most of C++‘s history, there was no way to express the second thing. The language let you say the first, and compilers tolerated treating it as if you’d meant the second. As optimizers have grown more aggressive about exploiting the object model, that tolerance has eroded. Clang’s behavior on the copy example is not a regression; it’s the correct application of a rule that was always in the standard.
If your code reads typed structs out of raw buffers, deserializes binary formats in place, or maps structs to hardware addresses, the pattern is the same: reach for start_lifetime_as when targeting C++23+, memcpy into a local variable when you need a portable value copy, and std::bit_cast when converting between two value types of the same size. reinterpret_cast alone, for these cases, is not the cast you’re looking for.