What reinterpret_cast doesn't do: Fertig on std::start_lifetime_as and the C++23 escape hatch
Andreas Fertig’s post “What reinterpret_cast doesn’t do” (re-shared on isocpp.org 2026-05-18) opens with the line C++ teachers have been waiting two decades to say cleanly: “This is not the cast you’re looking for!” The punchline: reinterpret_cast and C++23’s std::start_lifetime_as look interchangeable, but only if you don’t read the abstract-machine fine print. They do different things; the difference is the gap between “undefined behaviour that happens to work on your compiler today” and “the standard guarantees this works.”
What reinterpret_cast actually does
reinterpret_cast<T*>(p) re-interprets the bit pattern at an address as a pointer of a different type. That is a pointer operation, not an object-lifetime operation. The compiler still believes whatever object was previously at that address is whatever it was before. Accessing the new type through the reinterpret_cast-derived pointer is undefined behaviour under strict aliasing for nearly every non-pointer-character type pair.
In practice “it works” because most compilers tolerate the pattern most of the time, until they don’t, usually after an unrelated optimisation pass, on a release build, on a customer’s machine. The standard never promised it would; you got away with it because the compiler chose not to exercise its license to do something else.
The case Fertig builds for std::start_lifetime_as
C++23’s std::start_lifetime_as<T>(ptr) returns a T* and tells the abstract machine “an object of type T now begins its lifetime at this address.” That second half is precisely what reinterpret_cast cannot do.
The cases where this matters in production C++:
- Binary protocol parsers: read N bytes from a socket into a buffer,
start_lifetime_asthe buffer as aHeader. The reflection arc’s JSON deserializer and SBOM emitter both cross this boundary every call. - Embedded MMIO: map a hardware register region into a buffer,
start_lifetime_asit as a peripheral struct. Fertig’s stated motivation: “embedded training audiences where ‘we know the bytes are right, why does the compiler care?’ is the most common pushback.” - Shared-memory IPC: the producer placement-news a struct into the segment; the consumer needs to legally treat the segment as that struct without re-running the constructor.
- Custom allocators: populate raw storage, then formally begin the object’s lifetime there before handing the pointer out.
The before/after pattern in five lines:
// UB (strict aliasing): reinterpret_cast says "trust me" to the compiler,
// which never agreed to trust you.
char buffer[sizeof(Header)];
recv(sock, buffer, sizeof(buffer), 0);
Header* h = reinterpret_cast<Header*>(buffer); // <-- UB on access below
process(*h);
// Defined (C++23): start_lifetime_as actually begins the Header's
// lifetime at the buffer address. The compiler's model agrees.
char buffer[sizeof(Header)];
recv(sock, buffer, sizeof(buffer), 0);
Header* h = std::start_lifetime_as<Header>(buffer); // legal; the spec
process(*h); // says so
Why this matters for reflection-driven code
wro.cpp’s reflection arc emits a lot of “walk the bytes” patterns. The JSON deserializer (post 10) and the parse_struct example in the sanitizers-2026 toolset entry both go through a buffer-to-struct transition. Today the wro.cpp examples sidestep the lifetime question by using std::vector<std::byte> plus member-by-member memcpy into a default-constructed T. That works, but the more idiomatic C++23-and-up form when you have a contiguous byte buffer that already holds the correct representation is start_lifetime_as on the buffer: one call, no per-field copy.
This is a footnote we should ship across the safety cluster. I’m flagging it as the next quarterly-refresh follow-up for the memory-safety, hardened-stdlib, and lifetime-safety-2026 pages. The current “What you CAN flip on today” tables don’t yet have a C++23-stdlib row; start_lifetime_as belongs there alongside [[clang::lifetimebound]] and gsl::not_null.
Read next
- Fertig’s full post: What reinterpret_cast doesn’t do.
- His earlier “The correct way to do type punning in C++: The second act” introduces
start_lifetime_asin depth. - hardened-stdlib: runtime catches for the UB class the
reinterpret_castpattern leaks; complement, not replacement, forstart_lifetime_asat construction time. - lifetime-safety-2026: reflection-driven schema lint that operates one layer above the per-call analyzer where
start_lifetime_aslives.
Update, 7 September 2026: Fertig on implicit move, and what the compiler says
Fertig published “Move in C++ without a std::move”
on 1 September. It was the most-upvoted C++ link of the week, and the argument is a
good one: people reach for std::move in return statements where the language
already moves for them, and writing it by hand can cost you copy elision.
The article attaches the improvement to C++23. We checked, and that attribution is off by a standard for the case most people will hit.
Three variations, one tracer type, GCC 16.1:
| What the function does | C++17 | C++20 | C++23 |
|---|---|---|---|
takes Base&&, returns Base by value |
copy | move | move |
takes Derived&&, returns Base (conversion needed) |
copy | move | move |
returns Base&&, with return v; |
ill-formed | ill-formed | compiles |
The first two rows are the ones the article is about, and they were fixed by
P1825 in C++20, which extended implicit move to returns
that need a conversion. If your codebase is on C++20, you can already delete those
std::move calls today.
What C++23 adds is the third row. P2266 dropped the
two-phase overload resolution that let a failed move quietly fall back to a copy,
and made the returned name an xvalue outright, so a function whose return type is
an rvalue reference can finally return v;. That is a real change, and it is a
narrower one than “switch to C++23 and your returns start moving”.
The practical advice survives intact, which is why this is a footnote and not a
rebuttal: stop writing std::move on return statements. The correction is only to
the version you need before you can act on it, and for most people that version
already shipped.
Also from this week: isocpp.org re-shared Bjarne Stroustrup’s CppCon 2025 “Concept-based Generic Programming” talk on 5/19 (registration for CppCon 2026 in Aurora, CO is open). Tomorrow’s news short covers Barry Revzin’s C++Now keynote “Reflection Is Only Half the Story” (Sat 2026-05-23). Fertig’s same-week sibling posts on std::launder (2026-05-05) and noexcept-move requirements (2026-05-20) are further reading on the same object-lifetime theme.