How Vptr Offset Affects C++ Struct Alignment and Packing

This article explores how the inclusion and placement of a virtual table pointer (vptr) impacts the memory layout, struct alignment, and internal padding of C++ objects. When a class introduces virtual functions, the compiler automatically injects a vptr to enable dynamic dispatch, altering the struct's base alignment requirement, shifting member offsets, and creating potential memory holes. Understanding this mechanism is essential for writing memory-efficient, cache-friendly code and diagnosing alignment-related performance penalties.

The Virtual Table Pointer and Base Alignment

When a class or struct contains at least one virtual function, the compiler inserts a hidden pointer—commonly known as the vptr—that points to the class's virtual method table (vtable).

Because the vptr is a pointer type (void*), it inherits the architecture's native pointer alignment requirements:

  • On 32-bit architectures, pointers are 4 bytes with a 4-byte alignment.
  • On 64-bit architectures, pointers are 8 bytes with an 8-byte alignment.

By adding a vptr, the minimum alignment requirement (alignof) of the entire struct automatically rises to at least the alignment of a pointer. Even if a struct consists solely of 1-byte char members, adding a single virtual function forces the entire struct to align to an 8-byte boundary on 64-bit systems.

Offset Placement in Single Inheritance

In modern ABIs, such as the Itanium C++ ABI (used by GCC and Clang) and the Microsoft Visual C++ (MSVC) ABI, the compiler typically positions the primary vptr at offset 0 of the object layout.

Placing the vptr at offset 0 provides optimal dynamic dispatch performance, as the program does not need to compute an offset arithmetic operation to locate the vtable. However, this immediate 8-byte occupancy directly influences the placement of subsequent non-static data members:

struct NonVirtual {
    int32_t a; // Offset 0, Size 4
    int32_t b; // Offset 4, Size 4
}; // Total Size: 8 bytes, Alignment: 4

struct Virtual {
    virtual void func();
    int32_t a; // Offset 8, Size 4
    int32_t b; // Offset 12, Size 4
}; // Total Size: 16 bytes, Alignment: 8

In the Virtual example above, the vptr occupies offsets 0 through 7. The member a begins at offset 8. Because b requires 4-byte alignment, it fits naturally at offset 12, resulting in a total object size of 16 bytes with zero internal padding.

Impact of Member Ordering and Padding Holes

When smaller data types follow the vptr ahead of types with stricter alignment requirements, the vptr offset can introduce significant padding.

Consider this structural ordering:

struct Suboptimal {
    virtual void func(); // Offset 0,  Size 8
    char c;              // Offset 8,  Size 1
    // 7 bytes of padding introduced here
    double d;            // Offset 16, Size 8
}; // Total Size: 24 bytes, Alignment: 8

Because vptr occupies 8 bytes, char c sits at offset 8. The member double d requires 8-byte alignment, meaning it cannot begin at offset 9. The compiler is forced to insert 7 bytes of tail padding after c.

If the members are reordered or if members are placed to pack into the 8-byte boundary created after the vptr, space can be reclaimed:

struct Optimized {
    virtual void func(); // Offset 0,  Size 8
    double d;            // Offset 8,  Size 8
    char c;              // Offset 16, Size 1
    // 7 bytes of tail padding to satisfy struct alignment
}; // Total Size: 24 bytes, Alignment: 8

While the total size remains 24 bytes due to struct-level tail padding, up to 7 additional char variables could be inserted after c without increasing the struct's overall footprint.

Multiple Inheritance and Non-Zero Vptr Offsets

The behavior of vptr offsets changes significantly under multiple inheritance. When a class inherits from multiple base classes that each declare virtual functions, a single vptr is no longer sufficient.

  1. Primary Base Class: The derived class shares the vptr of its primary (first-inherited) base class at offset 0.
  2. Secondary Base Classes: Secondary base classes are positioned at subsequent offsets within the struct layout, each maintaining its own secondary vptr.
struct Base1 {
    virtual void f1();
    int64_t x;
}; // Size 16: vptr (8) + x (8)

struct Base2 {
    virtual void f2();
    int64_t y;
}; // Size 16: vptr (8) + y (8)

struct Derived : public Base1, public Base2 {
    int64_t z;
};

The memory layout of Derived is organized as follows:

  • Offset 0: Base1 vptr (8 bytes)
  • Offset 8: Base1::x (8 bytes)
  • Offset 16: Base2 vptr (8 bytes)
  • Offset 24: Base2::y (8 bytes)
  • Offset 32: Derived::z (8 bytes)

The presence of the secondary vptr at offset 16 directly splits member variables, constraining how data members are aligned and packed across base boundaries. When upcasting a Derived* to a Base2*, the compiler performs pointer adjustment by adding 16 bytes to reference the subobject where Base2's vptr resides.

Interaction with #pragma pack

Compilers typically respect packing pragmas (#pragma pack(n)) when laying out user-defined members, but the handling of the vptr requires caution.

If #pragma pack(1) or #pragma pack(4) is applied to a polymorphic class on a 64-bit architecture:

  • The compiler may remove the padding between the vptr and subsequent members.
  • Depending on the ABI, the vptr itself might be aligned suboptimally, or subsequent large types may be aligned on unaligned boundaries relative to the vptr.

Unaligned memory access can degrade runtime performance due to split-cache line reads or cause CPU hardware exceptions on strict-alignment architectures such as certain ARM configurations. Polymorphic classes should generally avoid #pragma pack directives to preserve native pointer alignment.