Skip to content
C++
Language
Advanced

Object Model

The rules governing how C++ programs create, represent, access, and destroy typed regions of storage, including layout, lifetime, and aliasing.

Objectsince C++98

An object is a region of storage with a fixed size, alignment requirement, storage duration, lifetime, type, and value; functions, references, enumerators, and types are explicitly not objects.

Overview

The C++ object model is the layer of rules underneath every line of C++ you write. It defines what an object is, how it comes into existence, how its bytes relate to its value, and when accessing it constitutes undefined behavior. Correctness in allocators, serializers, memory-mapped I/O, and any code that touches raw storage depends on understanding this model precisely.

Every object has six mandatory properties and one optional one:

  • Size β€” sizeof(T) bytes; every complete type has a fixed, positive size (except flexible array members).
  • Alignment requirement β€” alignof(T); the address of any object of type T must be a multiple of this value.
  • Storage duration β€” automatic (stack), static (program lifetime), dynamic (new/delete), or thread-local.
  • Lifetime β€” begins when storage is obtained and initialization completes; ends when the destructor runs or storage is released, whichever comes first.
  • Type β€” governs how the object's bytes are interpreted by the abstract machine.
  • Value β€” the abstract value encoded in the object's bits; may be indeterminate for default-initialized scalars.
  • Name (optional) β€” an identifier declared to refer to the object.

The standard is explicit about what is not an object: values, references, functions, enumerators, types, non-static data members (as distinct from the objects that contain them), templates, namespaces, parameter packs, and this.

cpp
int x = 42;        // x is an object (automatic storage duration)
int& r = x;        // r is a reference β€” not an object
void f();          // f is a function β€” not an object
enum Color { Red }; // Red is an enumerator β€” not an object

A variable is an object or a reference introduced by a declaration that is not a non-static data member.

Object Creation

Objects are created by exactly five mechanisms:

  1. Definitions β€” T obj;, T obj{val}, static T obj;
  2. new expressions β€” new T, new (ptr) T (placement new)
  3. throw expressions β€” creates an object of the thrown type in exception storage
  4. Temporary materialization β€” a prvalue is converted to an xvalue when a glvalue is needed; since C++17, mandatory copy elision defers materialization until it cannot be avoided
  5. Union member activation β€” writing to an inactive member ends the lifetime of the active one and begins the new one

Implicit Object Creation (C++20)

Before C++20, reusing raw storage for a new object type without an explicit placement-new call was technically undefined behavior β€” even though every real allocator does exactly that. C++20 standardized implicit object creation for implicit-lifetime types: trivially destructible types whose constructors are trivial or not user-provided, and arrays thereof.

The following operations now implicitly create objects in their storage region:

  • Allocating functions: operator new, operator new[], std::malloc, std::calloc, std::realloc, std::aligned_alloc (C++17)
  • Byte-copying functions: std::memcpy, std::memmove, std::bit_cast (C++20)
  • C++23 explicit: std::start_lifetime_as<T>, std::start_lifetime_as_array<T>
cpp
#include <cstdlib>

struct PacketHeader { uint32_t magic; uint16_t length; uint16_t flags; };

PacketHeader* parse(void* raw) {
    // C++20: malloc implicitly creates a PacketHeader in the allocated storage.
    // Before C++20, the placement-new below was required for defined behavior.
    PacketHeader* h = static_cast<PacketHeader*>(std::malloc(sizeof(PacketHeader)));
    h->magic  = 0xCAFEBABE;
    h->length = 0;
    h->flags  = 0;
    return h;
}

For C++23 and later, std::start_lifetime_as provides explicit, portable lifetime initiation in storage you already own:

cpp
#include <memory>  // std::start_lifetime_as β€” C++23

alignas(PacketHeader) unsigned char buf[sizeof(PacketHeader)];
PacketHeader* h = std::start_lifetime_as<PacketHeader>(buf); // C++23
h->magic = 0xCAFEBABE;

Object Representation and Value Representation

Two layered representations govern how an object's bits map to its value:

  • Object representation β€” all sizeof(T) bytes occupied by the object in storage; accessible as unsigned char[].
  • Value representation β€” the subset of bits in the object representation that participate in encoding the value.

Bits in the object representation that are not part of the value representation are padding bits. Their values are unspecified and must not be inspected. This makes byte-wise comparison of structs incorrect in general:

cpp
struct Point {
    char x;
    // 3 bytes of implicit padding (alignof(int) == 4 on x86-64)
    int y;
};
static_assert(sizeof(Point) == 8); // true on typical x86-64

bool equal_wrong(const Point& a, const Point& b) {
    // Padding bytes may differ even when x and y are identical
    return std::memcmp(&a, &b, sizeof(Point)) == 0; // incorrect
}

bool equal_correct(const Point& a, const Point& b) {
    return a.x == b.x && a.y == b.y;
}

For trivially copyable types, the value representation is a contiguous slice of the object representation. Copying the bytes with memcpy produces a valid object with the same value β€” the foundation on which serialization and custom allocators are built.

Subobjects and Layout

An object can contain subobjects:

  • Base class subobjects β€” one per direct and indirect base class
  • Non-static data member subobjects β€” each declared member
  • Array element subobjects β€” each element of an array

An object not contained within any other is a complete object. Lifetimes of subobjects are nested inside their enclosing complete object's lifetime.

Empty Base Optimization and [[no_unique_address]]

Base class subobjects and members marked [[no_unique_address]] (C++20) are potentially overlapping subobjects: they may share storage with adjacent members, enabling the Empty Base Optimization (EBO). An empty base class typically contributes zero bytes to derived class size:

cpp
struct Allocator {};  // stateless allocator

struct Buffer : Allocator {
    char* data;
    std::size_t size;
};
// EBO applies: sizeof(Buffer) == sizeof(char*) + sizeof(size_t)

// C++20: [[no_unique_address]] extends EBO to non-base members
struct Buffer2 {
    [[no_unique_address]] Allocator alloc;  // C++20; takes no space if empty
    char* data;
    std::size_t size;
};

Because potentially overlapping subobjects may share storage, copying them individually via memcpy can corrupt adjacent data. Always copy through the complete object.

Common Pitfalls

Strict Aliasing Violations

Accessing an object through a pointer of an incompatible type is undefined behavior. The only safe aliases are char, unsigned char, std::byte (C++17), and the object's own type (including cv-qualified and signed/unsigned variants). For type punning, use memcpy or std::bit_cast (C++20):

cpp
float f = 3.14f;
uint32_t bits;

// UB: reinterpret_cast<uint32_t*> breaks strict aliasing
// uint32_t bad = *reinterpret_cast<uint32_t*>(&f);

// Correct pre-C++20: memcpy
std::memcpy(&bits, &f, sizeof bits);

// Preferred since C++20: std::bit_cast
bits = std::bit_cast<uint32_t>(f);  // C++20 β€” no UB, often compiles to a single MOV

Indeterminate Values

Default-initializing a scalar leaves its value indeterminate. Reading it is undefined behavior. C++26 introduced erroneous behavior semantics for some such reads, which implementations may handle more predictably, but relying on it is still wrong:

cpp
int x;           // indeterminate value
int y = x + 1;  // UB (erroneous behavior from C++26, but still wrong)

int z{};         // zero-initialized β€” always safe

Placement New and Pointer Provenance

After placement-new, the pointer returned by new must be used to access the object. The original buffer pointer requires std::launder (C++17) before it can validly alias the new object, because the optimizer tracks pointer provenance:

cpp
alignas(int) unsigned char buf[sizeof(int)];

int* p = new (buf) int{42};
int val = *p;  // correct: p points to the new object

// Wrong without std::launder (C++17): optimizer may see through the buffer pointer
int val2 = *std::launder(reinterpret_cast<int*>(buf));  // C++17 β€” correct

Padding and Serialization

Zero-initializing a struct (= {}) zeros padding at construction, but subsequent member writes do not re-zero padding bytes. Never hash, compare, or serialize structs with padding unless you explicitly memset the entire structure first.

Storage Duration vs Object Lifetime

Storage duration and object lifetime are related but distinct concepts that are often conflated.

Storage duration is a property of the storage region: it determines how long the bytes are available to hold an object. The four durations are automatic (local variables on the stack), static (program-lifetime globals and static locals), thread-local (thread_local variables), and dynamic (operator new / operator delete).

Object lifetime is a property of the object: it begins when initialization completes and ends when the destructor runs (or, for trivial types, when storage is released). An object's lifetime is always contained within its storage duration, but the two do not have to coincide.

cpp
alignas(std::string) unsigned char buf[sizeof(std::string)];
// Storage exists (automatic duration) but no std::string object is alive yet.

std::string* p = new (buf) std::string{"hello"};
// A std::string object now exists in buf; its lifetime has begun.

p->~std::string();
// Object lifetime ended; storage is still alive and can be reused.

new (buf) std::string{"world"};  // Start a new lifetime in the same storage.

Placement New and std::launder

Placement new starts a new object's lifetime in pre-allocated storage without allocating memory. It returns a pointer directly to the new object, and that pointer is always valid to use.

The complication arises when you try to reach the new object through a different pointer β€” typically the original buffer pointer. The optimizer tracks pointer provenance: it records which object a pointer points to at compile time, and reuse of the same address does not automatically update that information. Reading through the stale buffer pointer is undefined behavior because, from the compiler's perspective, the pointer still refers to the old bytes, not to the new object.

std::launder (C++17) solves this by explicitly telling the optimizer that the pointer now refers to the object currently living at that address:

cpp
#include <new>

struct Sensor { const int id; double value; };

alignas(Sensor) unsigned char buf[sizeof(Sensor)];

Sensor* p = new (buf) Sensor{ 1, 0.0 };
// p is always valid β€” it came directly from placement new.

// Access via the buffer pointer requires laundering:
Sensor* via_buf = std::launder(reinterpret_cast<Sensor*>(buf));
via_buf->value = 3.14;  // OK β€” std::launder updated provenance

// Without std::launder the reinterpret_cast is UB:
// reinterpret_cast<Sensor*>(buf)->value = 3.14;  // UB

std::launder has one critical precondition: the object it is asked to reach must already be within its lifetime at the call point. It cannot start a lifetime β€” it can only obtain a valid pointer to an object that already exists. Use the pointer returned by placement new directly whenever possible; std::launder is only necessary when that pointer is unavailable (e.g., inside a union or when recovering a pointer from a byte array).

std::launder is also required when a class contains const or reference members, because the compiler may assume such members never change. Placing a new object with different const member values in the same storage without laundering the old pointer is UB even for trivially copyable types.

Padding, Alignment, and Packing in Depth

The compiler inserts implicit padding bytes to satisfy the alignment requirements of each member and to ensure the overall struct size is a multiple of its own alignment (so arrays of the struct remain aligned).

The Alignment Rule

Each member is placed at the lowest offset that is a multiple of alignof(member_type). The struct's overall alignof equals the maximum alignof of all its members. The struct's sizeof is padded at the end to the next multiple of alignof(struct).

cpp
struct Bad {
    char   a;   // offset 0, size 1
                // 3 bytes padding (int requires alignof == 4)
    int    b;   // offset 4, size 4
    char   c;   // offset 8, size 1
                // 3 bytes tail padding (struct alignof == 4)
};
// sizeof(Bad) == 12, but sum of members == 6

struct Good {
    int    b;   // offset 0, size 4
    char   a;   // offset 4, size 1
    char   c;   // offset 5, size 1
                // 2 bytes tail padding
};
// sizeof(Good) == 8 β€” same data, 33% smaller

The rule of thumb: declare members from largest to smallest alignment to minimize padding.

alignas for Over-Alignment

alignas strengthens the alignment of an object or type beyond its natural requirement. Common use cases are SIMD buffers (16 or 32-byte alignment), cache-line isolation to prevent false sharing (typically 64 bytes), and hardware registers.

cpp
#include <new>

// Cache-line-isolated counter avoids false sharing in concurrent code
struct alignas(64) CacheAlignedCounter {
    int value;
    // 60 bytes of padding follow implicitly
};

// SIMD: 32-byte-aligned buffer for AVX2 intrinsics
alignas(32) float vec[8];

alignas cannot reduce alignment below the natural alignof β€” the compiler will reject or silently ignore that.

#pragma pack and __attribute__((packed))

Both directives remove implicit padding, producing the minimum-size layout. They are used for binary file headers, network packets, and hardware register maps where the layout is dictated by an external specification.

cpp
#pragma pack(push, 1)
struct EthernetHeader {
    uint8_t  dst[6];
    uint8_t  src[6];
    uint16_t ethertype;
};  // sizeof == 14 β€” exactly as the spec requires
#pragma pack(pop)

// GCC/Clang alternative:
struct [[gnu::packed]] EthernetHeader2 {
    uint8_t  dst[6];
    uint8_t  src[6];
    uint16_t ethertype;
};

Risks of packed structs:

  • Unaligned member access: A pointer to a packed member (e.g., &hdr.ethertype) may be misaligned. Dereferencing such a pointer via * is undefined behavior on strict-alignment architectures (ARM, RISC-V) and causes performance penalties via microcode fixup on x86. Always use memcpy to read or write individual members.
  • Pointer passing: Passing &packed_struct.member to any function expecting an aligned pointer (including std::atomic, most SIMD intrinsics, and mmap-backed structures) produces undefined behavior.
  • No ABI guarantee: The packed layout is a non-standard GCC/Clang extension. MSVC uses #pragma pack only.

Polymorphic Object Layout

A class is polymorphic if it has at least one virtual function (including a virtual destructor). The compiler adds an invisible pointer member β€” the vptr β€” to the object's representation. On all mainstream 64-bit ABIs (Itanium, MSVC x64) the vptr occupies the first 8 bytes of the object.

cpp
struct Base {
    virtual void f();
    int x;
};
// Layout: [vptr: 8 bytes][x: 4 bytes][4 bytes padding]
// sizeof(Base) == 16 on 64-bit

struct NonVirtual {
    int x;
};
// Layout: [x: 4 bytes]
// sizeof(NonVirtual) == 4

The vptr points to the class's vtable: a read-only, per-class array of function pointers (and RTTI data) stored in the data segment. Every instance of the same concrete type shares the same vtable. The vtable is set by each constructor and updated as the object moves through construction layers, so virtual calls during construction dispatch to the class being constructed, not the most-derived class.

Single Inheritance vtable Layout

Under single inheritance the derived class's vtable is a superset of the base class's vtable, with overriding entries replaced. The derived object layout begins with the base class subobject (including its vptr slot), so a Base* pointing to a Derived object needs no pointer adjustment:

cpp
struct Base { virtual void f(); virtual void g(); };
struct Derived : Base { void f() override; virtual void h(); };

// Derived vtable (conceptually):
//   [0] &Derived::f   // overrides Base::f
//   [1] &Base::g      // inherited unchanged
//   [2] &Derived::h   // new slot

// Object layout:
//   [vptr -> Derived vtable][Base data members][Derived data members]

A virtual call compiles to roughly: load vptr from object, index into vtable, indirect call. That is one extra load and one indirect branch compared to a non-virtual call β€” usually 1–5 ns on modern hardware, plus branch-predictor pressure.

Multiple Inheritance vtable Layout

With multiple inheritance, an object contains one subobject per direct base class, each with its own vptr if the base is polymorphic. Only the primary base (the first non-virtual polymorphic base) shares its vptr with the derived class; secondary bases have their own vptr at a non-zero offset:

cpp
struct A { virtual void fa(); };
struct B { virtual void fb(); };
struct C : A, B { void fa() override; void fb() override; };

// C object layout (Itanium ABI):
//   offset 0:  A subobject [vptr-A]
//   offset 8:  B subobject [vptr-B]
// sizeof(C) == 16 on 64-bit

C c;
A* pa = &c;  // pa == &c, no adjustment needed (A is primary base)
B* pb = &c;  // pb == reinterpret_cast<char*>(&c) + 8 β€” compiler adjusts

When calling B::fb() through a B* that actually points to a C, the vtable entry for that slot contains a thunk β€” a tiny stub that subtracts the secondary-base offset from this before jumping to C::fb. This is transparent to the caller but adds one pointer-arithmetic instruction per call.

dynamic_cast and RTTI Cost

dynamic_cast traverses the RTTI type graph at runtime to check or find a valid base-to-derived or cross-cast relationship. For a downcast (Base* to Derived*) it walks the inheritance graph and compares type descriptors. The cost varies:

  • Downcast under single inheritance (no virtual bases): typically a few nanoseconds β€” one RTTI lookup and a pointer adjustment.
  • Downcast with multiple or virtual inheritance: more expensive; the runtime must search multiple base paths. In pathological deep hierarchies this can take tens of nanoseconds.
  • dynamic_cast to void*: always O(1) β€” returns the address of the most-derived object without searching.
  • Failed casts: a failed cast to a pointer type returns nullptr; a failed cast to a reference throws std::bad_cast. Both cost the same as a successful cast.
cpp
struct Shape { virtual ~Shape() = default; };
struct Circle : Shape { double radius; };
struct Square : Shape { double side; };

void process(Shape* s) {
    if (Circle* c = dynamic_cast<Circle*>(s)) {
        // safe downcast succeeded
        use(c->radius);
    }
    // If s is a Square, dynamic_cast returns nullptr β€” no UB
}

Alternatives to dynamic_cast when performance matters: a discriminated union (or std::variant), an explicit type tag in the base class, or the Visitor pattern β€” each avoids RTTI overhead at the cost of adding a new type to the hierarchy.

See Also

  • reference/language/memory-model β€” the concurrency rules layered on top of the object model
  • reference/language/abstract-classes β€” polymorphic object layout, vtable pointers, and virtual dispatch
  • reference/language/aggregate-initialization β€” rules governing initialization of aggregates and their subobjects
  • reference/language/alignas β€” over-aligning objects for SIMD, cache-line separation, and hardware requirements
  • reference/idioms/value-semantics β€” designing objects with predictable copy, move, and equality semantics