Demystifying Pre-C++23 start_lifetime_as: How Object Lifetimes and Implicit Creation Work Without UB
Introduction: The Mystery of Pre-C++23 start_lifetime_as
With C++23, developers gained access to std::start_lifetime_as, a utility designed to explicitly begin the lifetime of objects in raw memory buffers without invoking Undefined Behavior (UB). However, prior to C++23, developers working on low-level serialization or network code had to rely on clever tricks to achieve the same result.
In a well-known CppCon talk titled "Taking a Byte Out of C++ - Avoiding Punning by Starting Lifetimes", presenter Robert Leahy shared a pre-C++23 workaround for starting object lifetimes:
template<typename T>
[[nodiscard]]
T* start_lifetime_as(void* mem) noexcept {
auto bytes = new (mem) unsigned char[sizeof(T)];
auto ptr = reinterpret_cast<T*>(bytes);
(void)*ptr;
return ptr;
}At first glance, this function appears to violate strict aliasing and read uninitialized/non-existent objects, triggering UB. How does this code avoid UB under C++20 rules, and why is the seemingly useless (void)*ptr; line included? Let’s break down the C++ memory model rules step-by-step.
1. Implicit Object Creation and P0593R6
To understand why this function works, we must look at C++20 proposal P0593R6 ("Implicit object creation for low-level object manipulation"), which was adopted retroactively into earlier C++ standards.
Prior to P0593R6, C++ required an object to be explicitly constructed (via placement new or constructor call) before its lifetime began. P0593R6 changed this by introducing the concept of implicit-lifetime types (such as aggregate types, scalar types, and trivially copyable types).
The first line of the function:
auto bytes = new (mem) unsigned char[sizeof(T)];is an array placement new of unsigned char. Under P0593R6, operations that allocate byte arrays or copy bytes (like malloc, memcpy, or placement new of unsigned char[]) implicitly create objects of implicit-lifetime types in the specified storage region if doing so would give the program defined behavior.
2. Does reinterpret_cast Start a Lifetime?
No, performing a reinterpret_cast<T*>(bytes) does not start an object's lifetime by itself. It simply converts the pointer value from unsigned char* to T*.
However, because T is assumed to be an implicit-lifetime type, an object of type T was already implicitly created at the memory location during the placement new operation on line 1. The cast simply creates a pointer targeting that implicitly created T object.
3. Why Is (void)*ptr; Necessary?
The line (void)*ptr; dereferences the pointer and immediately discards the result to suppress compiler warnings. But why is it written in the first place?
Under the C++ object model rules introduced by P0593R6, implicit object creation is determined by whether a set of objects can be implicitly created such that the program execution has defined behavior. The dereference *ptr creates an access expression requiring an object of type T to exist at that address.
- By accessing
*ptr, the program asserts an evaluation that requires a valid object of typeTto exist at memory addressmem. - Because array placement
newimplicitly creates objects to give the program defined behavior, the standard selects the object creation scenario where an object of typeTexists. - The
(void)cast avoids compiler warnings about unused expressions while ensuring the access requirement remains in the compiled AST logic.
4. Where Does std::launder Fit In?
Placement new of the unsigned char array ends the lifetime of whatever object previously resided at address mem. If a compiler tracks pointer origins (pointer provenance), it might assume that ptr still points to the byte array rather than the newly created object T.
Technically, using std::launder is the standard-compliant way to break pointer provenance and obtain a pointer to a newly created object at an existing address:
return std::launder(reinterpret_cast<T*>(bytes));In the talk, the presenter avoided std::launder initially to keep the code simpler to explain without diving into pointer provenance right away. However, in strict C++20 practice, using std::launder alongside implicit object creation provides cleaner semantics.
Conclusion: Modern C++23 Recommendation
While the pre-C++23 pattern relies on P0593R6 implicit object creation rules and expression evaluation tricks, modern C++ offers a direct, UB-free, and intention-revealing standard function.
If you are using C++23 or newer, avoid custom workarounds entirely and use the official standard library function:
#include <memory>
T* ptr = std::start_lifetime_as<T>(mem);For legacy codebases constrained to C++20, using placement new of unsigned char[] combined with std::launder remains the safest approach for handling raw byte buffers.