Introduction to C++ Object Lifetimes in Shared Storage

Managing raw memory in C++ requires a firm grasp of object lifetime rules. When you use placement new to construct objects within an existing memory buffer, the C++ standard enforces strict rules regarding when lifetimes begin, when they end, and how overlapping memory regions interact.

In this article, we break down how placement new manages multiple non-overlapping and overlapping objects in identical byte storage, evaluate whether runtime address calculation changes the behavior, and explore modern alternatives introduced in C++23 such as std::start_lifetime_as.

Analyzing the Example: Is the Code Well-Formed?

Let's evaluate the behavior of the sample code:

std::uint32_t parse(Packet* packet) {
    std::byte* storage = reinterpret_cast<std::byte*>(packet);

    void* aAddress = storage;
    void* bAddress = storage + sizeof(A);
    void* cAddress = storage + sizeof(std::uint32_t);

    auto* a = ::new (aAddress) A{ 10, 20 };
    auto* b = ::new (bAddress) B{ 30, 40 };
    auto* c = ::new (cAddress) C{ 50 };

    return b->First + b->Second + c->Value;
}

The code is well-formed and the comments accurately reflect the C++ object lifetime model according to [basic.life]:

  • Reusing Packet's Storage: The object Packet originally occupies bytes [0..31]. When ::new (aAddress) A{ 10, 20 } is executed, the storage of Packet is reused by another complete object. This immediately ends the lifetime of the packet object.
  • Coexisting Non-Overlapping Objects: Object A occupies bytes [0..7] and object B occupies bytes [8..15]. Because their storage ranges do not overlap, both A and B can coexist safely in memory.
  • Overlapping Storage and Lifetime Termination: Object C is created at offset 4 with a size of 4 bytes, occupying bytes [4..7]. This directly overlaps the storage occupied by A::Second. Under C++ rules, creating an object in memory that overlaps an existing object terminates the lifetime of the overlapped object (A).
  • Reading Active Objects: At the return statement, reading b->First, b->Second, and c->Value is valid because both b and c are within their valid lifetimes. However, attempting to read a->First or access packet would trigger Undefined Behavior (UB).

What Happens If Addresses Are Calculated at Runtime?

Calculating aAddress, bAddress, and cAddress at runtime—whether dynamically from an offset table or network payload—does not change the semantics under standard alignment and bounds conditions:

  • Pointer Arithmetic: Computing offsets using std::byte* remains valid as long as the computed pointer stays within the allocated boundaries of the underlying buffer (e.g., within [0..32) bytes).
  • Alignment Requirements: The runtime address must meet the alignment requirement of the target type (alignof(T)). In the provided code, alignof(Packet) >= alignof(A) ensures that offset 0 is properly aligned for A. If runtime calculations produce an unaligned pointer, placement new results in undefined behavior.

Modern Alternative: std::start_lifetime_as (C++23)

In C++23, P0593R6 introduced std::start_lifetime_as to simplify handling implicit-lifetime types in raw memory buffers without having to invoke constructor syntax via placement new.

Key Differences with std::start_lifetime_as:

  • No Value Re-initialization: Placement new (addr) T{...} explicitly invokes a constructor and initializes data. In contrast, std::start_lifetime_as<T>(addr) simply declares to the compiler that an object of type T now begins its lifetime at addr, preserving whatever byte values were already present in memory.
  • Implicit-Lifetime Types Requirement: std::start_lifetime_as requires T to be an implicit-lifetime type (such as aggregates, scalars, or trivial types).
#include <memory>

std::uint32_t parse_cpp23(Packet* packet) {
    std::byte* storage = reinterpret_cast<std::byte*>(packet);

    // Implicitly begins lifetime without calling constructors
    auto* b = std::start_lifetime_as<B>(storage + sizeof(A));
    auto* c = std::start_lifetime_as<C>(storage + sizeof(std::uint32_t));

    return b->First + b->Second + c->Value;
}

Summary

  1. Placement new reuses storage: constructing an object in an overlapping memory location immediately ends the lifetime of the previous object.
  2. Non-overlapping objects constructed inside the same raw memory block can coexist without issue.
  3. Calculating addresses at runtime is fully valid as long as alignment and memory bounds are respected.
  4. For deserialization and type-punning in C++23, prefer std::start_lifetime_as over manual placement new when working with implicit-lifetime types.