reinterpret_cast Doesn't Start Object Lifetimes, and That's the Whole Problem
Source: isocpp
Andreas Fertig’s recent post on isocpp.org makes a point that keeps surprising people in C++ classrooms: reinterpret_cast does not start an object’s lifetime. Not even a little. This sounds pedantic until you realize how much embedded and systems code silently relies on behavior the standard never guaranteed.
The misunderstanding runs deep because reinterpret_cast looks like it should work. You take a buffer of bytes, cast it to a pointer to your struct, and read the fields. The generated assembly is usually exactly what you’d expect. The optimizer often leaves it alone. The code ships. Then you upgrade your compiler, or enable -O2, or switch from GCC to Clang, and something subtly breaks in a way that’s almost impossible to reproduce without understanding what the standard actually says.
What the Standard Actually Says reinterpret_cast Does
The cppreference definition is deliberately narrow: reinterpret_cast is a compile-time directive that tells the compiler to treat the bit representation of an expression as if it had a different type. It compiles to zero CPU instructions on any normal architecture. It does not create an object. It does not begin a lifetime. It is purely a change in how the compiler reasons about an existing pointer value.
The consequence is the strict aliasing rule. When the compiler sees accesses through pointers of different types, it is entitled to assume those pointers do not refer to the same memory, because the standard says objects of different types cannot alias (with a few explicit exceptions for char, unsigned char, and std::byte). This assumption is not cosmetic. It is the basis for a wide class of load/store reorderings, register caching optimizations, and loop transformations that modern compilers perform automatically at -O2 and above.
The canonical failure mode looks like this:
struct Packet {
uint32_t header;
uint16_t length;
uint16_t checksum;
};
void process(std::byte* raw_buffer) {
// Common embedded pattern, technically undefined behavior
Packet* pkt = reinterpret_cast<Packet*>(raw_buffer);
if (pkt->header == 0xDEADBEEF) {
// ...
}
}
The Packet object does not exist in raw_buffer. You allocated bytes, not a Packet. The compiler knows no Packet object was constructed there, and so it is permitted to reason about pkt->header reads and writes with considerably more latitude than you would like. GCC enables strict aliasing optimizations by default at -O2. There are documented cases where glibc code that had shipped for years silently miscompiled under higher optimization levels for exactly this reason, because the strict aliasing assumption allowed the optimizer to reorder or eliminate memory accesses in ways the author never anticipated.
The Workarounds and Their Trade-offs
Developers discovered three common workarounds before the standard caught up.
The first is memcpy. Copying bytes into a properly constructed object is always defined, because memcpy is explicitly exempted from strict aliasing. The downside is an actual copy, which matters in embedded contexts where you are working with DMA buffers or memory-mapped hardware registers that you cannot or should not copy.
Packet pkt;
std::memcpy(&pkt, raw_buffer, sizeof(Packet));
// pkt.header is now well-defined
C++20 added std::bit_cast<T>, which is a type-safe, constexpr-capable alternative to the memcpy trick. It produces the same result: a copy with defined behavior. Still a copy.
The second workaround is __attribute__((may_alias)) on GCC and Clang, which marks a type as allowed to alias anything. It works, but it is compiler-specific, not portable, and requires modifying your type definition.
The third is -fno-strict-aliasing, which tells the compiler to abandon aliasing optimizations entirely. A significant amount of embedded and kernel code has historically been compiled with this flag precisely because the codebase was too large to audit for aliasing violations. The Linux kernel uses it. The flag works, but you pay for it across the entire translation unit, not just at the sites that needed it.
C++20 Changed the Premise
The committee recognized the problem and addressed it in two stages. The first stage was P0593, “Implicit creation of objects for low-level object manipulation,” which landed in C++20.
P0593 introduced implicit object creation: certain operations, specifically malloc, operator new, memcpy, memmove, and a handful of others, now implicitly create objects of implicit-lifetime types within the storage they operate on. An implicit-lifetime type is any scalar type, array of such types, or class type that has a trivial destructor and at least one trivial eligible constructor.
This retroactively legalized a large class of code that had been technically undefined behavior for decades. After C++20, calling malloc and then casting the result to a struct pointer is legal, provided the struct is an implicit-lifetime type. The object’s lifetime begins implicitly as a consequence of the allocation. This was a pragmatic standardization of what people were already doing and what compilers were already permitting.
But there is a gap. Implicit object creation only covers specific operations. A byte array with static or automatic storage duration does not implicitly create objects within it. Neither does a region of memory obtained from a source the compiler does not recognize as one of the covered operations. reinterpret_cast was not added to the list. The cast still does not start a lifetime.
C++23 Gives You the Explicit Tool
The second stage was std::start_lifetime_as, introduced in C++23. The function signature, defined in <memory>, is:
#include <memory>
template<class T>
T* std::start_lifetime_as(void* p) noexcept;
Calling it explicitly begins the lifetime of a T object at address p. The requirements are that p points to storage of at least sizeof(T) bytes with at least alignof(T) alignment, and that T is an implicit-lifetime type. When those conditions hold, you get back a pointer to a valid T object. The bytes are not modified. The object is not initialized in the constructor sense. Its value representation is whatever bytes were already there.
The corrected version of the earlier example:
void process(std::byte* raw_buffer) {
Packet* pkt = std::start_lifetime_as<Packet>(raw_buffer);
if (pkt->header == 0xDEADBEEF) {
// Defined behavior: the Packet object now exists at raw_buffer
}
}
The difference from reinterpret_cast is not syntactic. It is semantic in a way the optimizer must respect. After start_lifetime_as, the compiler knows a Packet object genuinely exists at that address. It will not apply aliasing assumptions that contradict subsequent accesses through pkt.
There is also std::start_lifetime_as_array<T>(p, n) for the case where you want to overlay an array of T objects onto a buffer, which is useful for parsing arrays of fixed-size records from a network stream or binary file.
The Embedded Case Specifically
Fertig’s point about embedded environments is worth dwelling on. Embedded code is where this pattern is most prevalent and where the combination of factors most often causes trouble. You have hardware registers or DMA buffers at known physical addresses. You have protocol headers arriving in fixed memory regions. You have custom allocators handing you raw storage. In all these cases, you want to overlay a type onto existing bytes without copying, often because the hardware would not tolerate a copy or because there is no destination to copy to.
Before C++23, the least-bad standard-conforming approach was memcpy when you could afford the copy, __attribute__((may_alias)) when you could not, or placement new with a trivial type when you needed something portable. Placement new is defined behavior: constructing an object at a specific address with new (ptr) T() starts its lifetime, and subsequent accesses through the resulting pointer are valid. The catch is that value-initialization with T() zero-initializes trivial types, which is not what you want when the bytes already contain meaningful data from a DMA transfer or deserialized packet.
std::start_lifetime_as threads this needle cleanly. It starts the lifetime without modifying the bytes, gives you a well-typed pointer the compiler will respect, and requires no compiler extension or copy.
Compiler Support
As of 2025, GCC has implemented std::start_lifetime_as in libstdc++ under -std=c++23. Clang has support as well. MSVC had an open feature request for some time, though support has been progressing. If you are targeting a mix of compilers or older toolchain versions, wrapping the call with a feature-detection macro and falling back to the memcpy approach is prudent until support is universal.
One interesting historical detail: an older Stack Overflow thread noted that GCC 10 followed the standard strictly by not starting a new lifetime on reinterpret_cast, while Clang 12 at the time was more permissive and effectively started the lifetime anyway. This divergence was precisely the kind of implementation-defined tolerance that makes relying on compiler behavior rather than the standard so risky. The code that appeared to work on one toolchain was not guaranteed to work on another, and had no guarantee of continuing to work as either compiler’s optimizer matured.
The Larger Pattern
The story of reinterpret_cast, strict aliasing, and start_lifetime_as illustrates a recurring dynamic in C++ standardization. The language had a gap between what programmers needed to express and what the standard permitted them to say. The community filled that gap with compiler extensions, flags, and technically-undefined patterns that happened to work in practice. Eventually the committee formalized what was actually going on, first with the implicit object creation rules of C++20 and then with the explicit control surface of C++23.
The lesson is not that reinterpret_cast is useless. It remains the right tool for pointer-to-integer conversions, for restoring a pointer that was stored as an integer, and for a handful of other cases where you genuinely want a bitwise reinterpretation without any lifetime semantics. The mistake is reaching for it when what you want is to declare that a properly-sized and aligned region of memory now contains an object of a given type. That is what std::start_lifetime_as expresses, and the distinction is not pedantic. The compiler acts on it.