Object lifetime and layout

C++ · Expert · 20 min

Containers, allocators, serialisation and low-level optimisation all depend on the exact rules for when an object exists and how it's laid out.

In C++, storage and object lifetime are separate. Memory can exist without an object in it, and an object's lifetime begins only when it's constructed and ends when its destructor runs (or its storage is reused). Accessing an object outside its lifetime is undefined behaviour, even if the bytes are still there.

That's how std::vector works: it allocates raw capacity, then constructs elements in place with placement new, new (ptr) T(args), or C++20's std::construct_at(ptr, args...), and destroys them with std::destroy_at(ptr) without freeing the memory. If you reuse storage in tricky ways (const members, a different type), std::launder may be needed to obtain a valid pointer to the new object.

Layout. Every type has a size and an alignment (alignof). Members are laid out in declaration order with padding inserted so each is properly aligned, and the struct's size is padded to a multiple of its alignment. Ordering members from largest to smallest alignment often shrinks a struct, which matters for big arrays. alignas(64) raises alignment, for example to give a variable its own cache line.

Type properties decide what's legal:

  • Trivially copyable types (no custom copy, move or destructor) may be copied with memcpy.
  • Standard-layout types have C-compatible layout, and offsetof works on them.
  • Implicit-lifetime types (aggregates of scalars, for example) are created implicitly in memory from malloc or memcpy since C++20; C++23's std::start_lifetime_as does it explicitly.

To reinterpret bits, use C++20's std::bit_cast<To>(from) (or memcpy). Reading a float through a uint32_t* violates strict aliasing and is UB. C++20's [[no_unique_address]] lets an empty member take no space, which is how allocators and comparators get stored for free.

Example

#include <bit>
#include <cstdint>
#include <iostream>
#include <memory>
#include <string>

struct Bad  { char a; double b; char c; };    // 1 + 7 pad + 8 + 1 + 7 pad = 24
struct Good { double b; char a; char c; };    // 8 + 1 + 1 + 6 pad = 16

struct Empty {};
struct WithTag { [[no_unique_address]] Empty tag; int x; };   // C++20: tag uses no space

int main() {
    std::cout << sizeof(Bad) << ' ' << sizeof(Good) << ' ' << alignof(double) << '\n';  // 24 16 8
    std::cout << sizeof(WithTag) << '\n';      // 4 on GCC and Clang

    alignas(std::string) unsigned char storage[sizeof(std::string)];   // storage, no object yet
    std::string* s = std::construct_at(reinterpret_cast<std::string*>(storage), "alive");
    std::cout << *s << '\n';                   // lifetime has begun
    std::destroy_at(s);                        // lifetime ends; storage remains

    float f = 1.0f;
    auto bits = std::bit_cast<std::uint32_t>(f);   // well-defined type punning
    std::cout << std::hex << bits << '\n';         // 3f800000
    // *reinterpret_cast<std::uint32_t*>(&f)       // UB: strict aliasing violation
}

Remember

  • Storage and lifetime are separate: construct_at begins it, destroy_at ends it
  • Padding follows alignment; order members largest-first to shrink structs
  • Use std::bit_cast or memcpy for type punning, never pointer casts

Watch out for

  • Using memcpy on a type that isn't trivially copyable (e.g. one containing a std::string).

Check yourself

  1. What is sizeof(S) for `struct S { char c; int i; char d; };` on a typical platform with 4-byte int alignment?
  2. Which is a well-defined way to get the bit pattern of a float as a uint32_t?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: en.cppreference.com/w/cpp/language/lifetime