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: 8In 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: 8Because 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: 8While 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.
- Primary Base Class: The derived class shares the
vptrof its primary (first-inherited) base class at offset0. - 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:
Base1vptr (8 bytes) - Offset 8:
Base1::x(8 bytes) - Offset 16:
Base2vptr (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
vptrand subsequent members. - Depending on the ABI, the
vptritself might be aligned suboptimally, or subsequent large types may be aligned on unaligned boundaries relative to thevptr.
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.