Stay in orbit

Independent thoughts, public notes and things I’m building

Follow on Mastodon
Image Description

Transitioning from managed environments like the JVM or CLR to the C++ concept of a class is challenging and we must re-calibrate. In C++, a class is not a managed metadata object. It is a blueprint for memory layout and instruction sequencing. This treatise deconstructs the mechanics of polymorphism and encapsulation, focusing on the overhead of the Virtual Method Table ( vtable ) and the physical reality of the object model.

Low level mechanics & Internal implementation

Consider a non-polymorphic class containing only data members and non-virtual member functions. Its object representation in memory is indistinguishable from a C struct: members are laid out sequentially in declaration order, with padding bytes inserted by the compiler to satisfy the platform`s alignment requirements ( typically 4-byte or 8-byte boundaries on x86-64 ).

A C++ class instance is, in essence, a contiguous block of memory.

A plain struct has a memory footprint exactly equal to the sum of its members. However, the moment a class declares or inherits a virtual function, the compiler introduces the Virtual Pointer ( vptr ).

Accessing a member is simply a offset calculation from the object`s base address – resolved entirely at compile time – so a loda of store executes in a single CPU cycle once the base pointer resides in a register.

When virtual functions enter the picture to enable runtime polymorphism, the compiler inserts a hidden pointer-sized field – the virtual pointer, at offset zero in the object. This vptr is initialized during object construction to point at a static, read-only virtual table ( vtable ) emitted by the compiler into the binary`s.rodata section. The vtable itself is a contiguous array of function pointers, one slot per virtual function declared in the most-derived class`s hierarchy. A virtual call therefore expands to three machine operations: 1) Load the vptr from [object_base + 0], 2) Load the function pointer from [vptr + slot_offset], 3) in indirect call through that pointer. On modern x86-64, this sequence costs two additional cache-line loads compared to a direct static call and introduces a data dependency that can stall the pipeline if the vtable entry misses in L1 or L2 cache. The indirect branch also defeats static branch prediction unless the CPU`s indirect-target predictor has warmed to the call site.

A typical memory layout for Derived ( on a 64-bit system ) is:

+-------------------+

| vptr (8 bytes) | --> points to vtable

+-------------------+

| int x (4 bytes) |

+-------------------+

| padding (4 bytes) |

+-------------------+

| int y (4 bytes) |

+-------------------+

| padding (4 bytes) |

Functions defined in the class are implicitly inline.

Inheritance further refines the layout. In single inheritance, the base-class suboject occupies the initial bytes of the derived object; the derived-class members follow immediately after. A point to the derived object, when upcast to a base pointer, requires no adjustment – the base subobject address coincides with the full object address.

Under multiple inheritance, the memory layout becomes complex. If Class C inherits from Class A and Class B, a pointer to C must be adjusted ( offset ) when cast to a pointer to B. This ensures the vptr being accessed matches B`s expected layout. This pointer fixing is a hardware-level addition/subtraction performed during the cast, a cost entirely absent in single-inheritance models.

In other simpler words

The difference between a virtual and a Normal ( non-virtual ) method in C++

  • A normal method ( without virtual ) is resolved at compile time ( static binding ).
  • A virtual method ( with virtual ) is resolved at runtime ( dynamic binding ) based on the actual type of the object.

Use a normal method when you don`t need polymorphism; you want maximum speed and the method should`t not be overridden in derived classes.

Use a virtual method when you need different classes to behave differently through a base pointer/reference; you work with collections of heterogeneous objects; you have a class hierarchy that requires runtime polymorphism.

Engineering Rationale & Trade-offs

The design of C++ polymorphism was constrained by the requirement to be compatible with C memory layouts. Encapsulation ( public, protected, private ) is a compile-time construct only. Once the code is compiled, these access modifiers vanish; there is no runtime „permission check“ when accessing a memory address.

C++ classes were born from Bjarne Stroustrup`s explicit goal in the late 1970s to add Simula-style object orientation to C without sacrificing the performance or portability that made C the language of Unix. The original Cfront compiler translated C++ classes into C structs plus free functions, proving that the abstraction could be zero-cost when unused. The design therefore enshrines „you don`t pay for what you don't use“: non-virtual member functions are ordinary functions with an implicit this parameter; data members are plain fields; encapsulation is enforced solely by name lookup rules at compile time, incurring zero runtime cost. Virtual functions were introduced only when the programmer explicitly opted in, because the cost model – extra pointer per object, extra indirection per call – was deemed acceptable only when polymorphism was required. Multiple inheritance arrived later ( in the 1989 ARM ) because it complicated layout and required thunks, yet was judged necessary to model real-world „is-a“ relationships without forcing artificial single-inheritance hierarchies. The C++98 through C++20 standards have preserved these semantics verbatism; even C++23`s contract assertions and modules do not alter the code object model, because any change that increased per-object overhead would violate the zero-overhead mandate that distringuishes C++ grom managed languages.

The cost of abstraction is therefore paid only where polymorphism is used. Each virtual function adds one pointer ( 8 bytes on 64-bit ) to every object that participates in the hierarchy, plus one slot in the vtable ( another 8 bytes, shared across instances ). A virtual call replaces a direct branch ( predictable, cache-friendly ) with an indirect branch whose target depends on runtime type, exposing the program to branch misprediction penalties ( 15-20 cycles on recent inter/AMD cores ) and vtable cache misses. In contrast, templates or CRTP ( Curiously Recurring Template Pattern ) achieve static polymorphism with zero runtime cost but explode compile times and binary size. The engineer must therefore weigh per-object memory inflation and call latency against the compile-time bloat of genetics. Encapsulation itself is free: private and protected specifiers disappear after name mangling; the generated code is identical to an all-public class. The only runtime manifestation of access control is the absence of a public symbol for private members, which the linker enforces.

Failure Modes & The „Dark Side“

The „Dark Side“ of C++ classes involves Undefined Behavior ( UB ). The most common pitfall for engineers in the Use-After-Free or Dangling Pointer during polymorphic cleanup.

Undefined behavior and the vtable

If you call a virtual function inside a Constructor or Destructor, C++ does not behave like Java. In C++, while the Base constructor is running, the object is a Base object. The vptr points to the Base vtable. If the function is pure virtual, the program crashes ( or invokes UB ). This is because the Derived portion of the object has not been initialized yet ( in constructors ) or has already been destroyed ( in destructors ).

Cache misses and Data alignment

A common failure in Big Data C++ systems is ignoring Class Alignment. If you order your class members poorly, the compiler inserts „padding“ to align the double to an 8-byte boundary. This increases the object size, reducing the number of objects that fit in an L1 cache line ( 64 bytes ). Should be ordered by size ( descending ) to minimize the bloat.