Object Layout and Runtime Dispatch
Learning Objectives
Predict object size, alignment and padding.
Explain base subobjects, the vptr and the vtable as an implementation model.
Use empty-base optimization and [[no_unique_address]].
Understand ABI stability and the Pimpl idiom.
Prerequisites: Modules 7, 8, 12.
Intuition and Definition
An object is a block of memory. Its size is the sum of its members plus padding inserted so each member sits at an address that is a multiple of its alignment; the whole object is padded to a multiple of its largest member alignment so arrays stay aligned.
Polymorphic classes (with virtual functions) typically get a hidden vptr — a pointer to the class's vtable, a table of function pointers — usually at offset 0. A derived object contains its base subobject first, then its own members. These are implementation details of the common Itanium C++ ABI (Linux, macOS, MinGW) and the MSVC ABI; the standard doesn't mandate them.
The ABI (application binary interface) fixes layout, name mangling and calling conventions. Changing a class's members or virtual functions changes its layout and breaks binary compatibility with already-compiled code — which is why libraries hide members behind a Pimpl pointer.
Why It Matters
Layout determines memory usage, cache behaviour and binary compatibility. In embedded systems, a few bytes of padding per object × thousands of objects matter; in libraries, accidental ABI breaks crash customers' applications.
Syntax and Language Notes
Managed languages (Java, C#, Python, JavaScript) hide layout; objects carry headers (type pointer, lock/GC bits). C# structs and [StructLayout] give some control for interop.
static_assert(sizeof(Frame) == 16, "protocol frame must stay 16 bytes");
static_assert(alignof(double) == 8);
std::size_t off = offsetof(Plain, b); // standard-layout types onlyComplete Example
Measured with clang 21 targeting x86-64 Windows (MinGW/Itanium ABI); other platforms may differ in the details.
#include <cstddef>
#include <cstdint>
#include <iostream>
struct Wasteful { char a; double b; char c; }; // 1 + 7 pad + 8 + 1 + 7 pad
struct Packed { double b; char a; char c; }; // 8 + 1 + 1 + 6 pad
struct Frame { std::uint32_t id; std::uint8_t dlc; std::uint8_t data[8]; };
struct NoVirtual { int x; };
struct WithVirtual { int x; virtual ~WithVirtual() = default; }; // + hidden vptr
struct Empty {};
struct HoldsEmpty { Empty e; int x; }; // an empty member still takes space
struct InheritsEmpty : Empty { int x; }; // empty base optimization: no space
struct UsesAttribute { [[no_unique_address]] Empty e; int x; };
struct Base { int b = 1; virtual ~Base() = default; };
struct Derived : Base { int d = 2; };
int main() {
std::cout << "Wasteful " << sizeof(Wasteful) << ", Packed " << sizeof(Packed)
<< " (same members, reordered)\n";
std::cout << "offsets in Wasteful: a=" << offsetof(Wasteful, a) << " b=" << offsetof(Wasteful, b)
<< " c=" << offsetof(Wasteful, c) << "\n";
std::cout << "Frame " << sizeof(Frame) << " (alignof " << alignof(Frame) << ")\n";
std::cout << "NoVirtual " <<
…Wasteful 24, Packed 16 (same members, reordered)
offsets in Wasteful: a=0 b=8 c=16
Frame 16 (alignof 4)
NoVirtual 4, WithVirtual 16 (vptr 8 bytes + padding)
Empty 1, HoldsEmpty 8, InheritsEmpty 4, [[no_unique_address]] 4
Derived 16; base subobject at the same address: 1Step-by-Step Walkthrough
Wasteful puts a char before a double: 7 bytes of padding keep b 8-aligned, and 7 more at the end make the size a multiple of 8 — 24 bytes for 10 bytes of data. Ordering members largest-first gives 16.
Frame has 4 + 1 + 8 = 13 bytes of data, padded to 16 because uint32_t needs 4-byte alignment.
Adding a virtual destructor adds a pointer-sized vptr (8 bytes on x86-64); with alignment the object grows from 4 to 16 bytes.
An empty class has size 1 (distinct objects need distinct addresses). As a member it costs space (plus padding); as a base class or with [[no_unique_address]] it can occupy no space.
With single inheritance the base subobject sits at offset 0, so converting Derived* to Base* doesn't change the address (multiple inheritance can adjust it — Module 56).
flowchart LR
subgraph Obj[Derived object: 16 bytes]
V[vptr 8 B] --- B[Base::b 4 B] --- D[Derived::d 4 B]
end
V --> VT[Derived vtable: destructor, ...]Common Mistakes
Assuming sizeof = sum of member sizes. Sending structs over a network or CAN bus with memcpy — padding bytes, alignment and endianness differ between platforms; serialize field by field (or use packed, explicitly laid-out types with static_assert checks).
Changing a public class's members in a shared library without bumping the major version — ABI break.
Relying on vtable layout (offsets, order) — compiler-specific.
Trade-offs and Alternatives
Reorder members to reduce padding; group hot fields together for cache locality.
Pimpl: stable ABI and faster rebuilds at the cost of a heap allocation and an indirection per access.
#pragma pack reduces padding but can cause slow or faulting unaligned accesses on some CPUs.
Real-World Use
Protocol frames and memory-mapped hardware registers (static_assert on size and offsets), game engines optimizing component layout, library authors maintaining ABI (Qt and KDE use Pimpl/"d-pointers" everywhere).
Practice
Q1. (Easy) Why is sizeof(Empty) 1, not 0?
Answer: Every complete object must have a unique address; two distinct empty objects can't share one.
Q2. (Medium) Reorder struct S { char a; int b; char c; short d; } to minimise size and give both sizes (typical 4-byte int).
Answer: As written: a(1)+3 pad, b(4), c(1)+1 pad, d(2) = 12. Reordered { int b; short d; char a; char c; } = 4+2+1+1 = 8.
Q3. (Advanced) Why does adding a private member to a class in a shared library break applications that don't use that member?
Answer: Applications compiled against the old header baked in the old sizeof and member offsets (for stack allocation, inline functions and member access). The new library code assumes the new layout, so objects are too small and fields are read at wrong offsets. Pimpl keeps the public class's layout (one pointer) constant.
Interview Question
Q1. "How is virtual dispatch typically implemented, and what does it cost?"
Answer: Each polymorphic class has a vtable of function pointers (one slot per virtual function); each object stores a vptr to its class's vtable, set by constructors (which is why virtual calls in constructors see the constructor's class). A virtual call loads the vptr, loads the slot, and calls indirectly. Costs: 8 bytes per object, an indirect branch that's hard to inline (devirtualization is possible when the type is known), and possible cache misses on the vtable. With multiple inheritance there are several vptrs and this adjustments via thunks.
Quick Quiz
Q1. Where is the vptr usually stored?
Answer: At the start of the object (offset 0) in common ABIs.
Q2. What does [[no_unique_address]] allow?
Answer: An empty member to occupy no storage (like empty-base optimization).
Q3. Which idiom keeps a class's ABI stable when its private members change?
Answer: Pimpl.
Summary
Layout = members + padding for alignment; virtual functions add a vptr.
Empty bases cost nothing; empty members do unless [[no_unique_address]].
Layout is part of the ABI; Pimpl protects it.
Related: Modules 56, 63, 70.