Ch18.2: What virtual Really Is
1. Three Things, in This Order
A virtual function is not magic. It is three mechanical facts:
- Every object of a polymorphic class carries a hidden pointer (the
vptr), which points to its class's table of function pointers. This is why
such an object cannot be smaller than a pointer, and why it is at least
alignof(void*)-aligned. - Every polymorphic class has one table (the vtable) in read-only data, with one slot per virtual function, in declaration order. It is emitted by the compiler; you never write it and cannot edit it.
- A virtual call reads the vptr, then the slot, then jumps. Two dependent loads instead of one direct jump, and the target address is not known until runtime, so nothing can be inlined through it.
That is the whole mechanism. Everything else in this chapter — the bloat, the RTTI,
the __cxa_pure_virtual symbol, the ABI — follows from it.
2. What It Costs in Bytes
struct drawable
{
virtual ::std::string_view kind() const noexcept = 0;
virtual ~drawable() noexcept = default;
};
struct point : drawable
{
double x{}, y{};
point(double px, double py) noexcept : x{px}, y{py} {}
::std::string_view kind() const noexcept override { return "point"; }
};
struct square : drawable
{
double side{};
explicit square(double s) noexcept : side{s} {}
::std::string_view kind() const noexcept override { return "square"; }
};
sizeof(drawable)=8 sizeof(point)=24 sizeof(square)=16 through base=point through object=square
Do the arithmetic: point is two doubles (16 bytes) plus the
vptr (8) = 24. square is one double (8) plus the vptr (8) = 16.
A single double shape doubled in size because one of its functions is
virtual, and it doubled again as a pointer with an allocator header once you put it
behind ::std::unique_ptr
(Ch18.1).
Compare with the type-erased version later in this chapter: one pointer plus one table pointer, 16 bytes, holding any shape at all, with no vptr in the payload and no allocation for the header.
3. Why Virtual Only Pays Off With Allocation
There is a way to use virtual without the heap: put derived objects in a
container by value and call through a reference.
point p{1.0, 2.0};
square q{3.0};
drawable &d = p; // dispatch through a reference
::fast_io::io::println(d.kind()); // "point"
This is correct and allocation-free, but notice what it does not buy you: the
set of types is fixed at compile time, each variable has one static type, and you cannot
put a point and a square into one
::fast_io::vector. The moment you want a container of "any drawable", the
only thing virtual gives you is a base pointer, and a base pointer that
outlives its scope requires an owner, and C++'s owner is
::std::unique_ptr, and ::std::unique_ptr requires
new.
That is the chain. virtual is not the expensive part by
itself; virtual plus heterogeneity is, and heterogeneity is
exactly what people want when they choose OOP. Break the chain at any link —
values instead of pointers, type erasure instead of inheritance — and the whole
cost structure disappears.
4. The Cost Model, Honestly
| What you pay | When | Measured in this chapter |
|---|---|---|
| 8 bytes per object | always | sizeof(square) 8 → 16 |
| a vtable per class | always | vtable for square, 48 bytes of .rodata |
| every virtual function emitted | even if never called | 12,448 bytes of dead code (Ch18.4) |
| an indirect call per dispatch | per call | no inlining, no constant folding, no vectorisation across it |
| RTTI objects per class | always | 6 type-info symbols for 3 classes (Ch18.5) |
__cxa_pure_virtual and its runtime |
any pure virtual | pulls in std::terminate, the demangler, fwrite,
fputs, abort |
For a program with one dispatch site, none of that matters. For a program with a thousand types in a plugin-ish hierarchy, it is the difference between a 200 KB binary and a 2 MB one, and between "starts instantly" and "loads its own type names from disk".
5. So When Is virtual Acceptable?
- When you are reading legacy code. This is the honest answer for
Chapter 18 as a whole:
virtualis a legacy feature. Qt, MFC, COM and three decades of libraries are built on it, and you must be able to read them. - When a library's ABI requires it. If the platform hands you a
structwith virtuals (COM, some driver models), implement it; do not design with it. - When the set of implementations is fixed, closed, and few, and the
objects live on the stack. Then
virtualis a switch statement with nicer syntax — and::std::variantplus::std::visitwould do the same with no vptr at all. - Never in a new abstraction you are designing. For that, use a
function-pointer table (Ch18.6), which
is the same dispatch mechanism with three advantages: you can see it, you can
instantiate it as a constant, and it does not bring RTTI,
virtualABI rules, or__cxa_pure_virtualwith it.
6. Rules of Thumb
- Before writing
virtual, write down the list of types. If the list is fixed and known, you want a tag, avariant, or aswitch— not a vtable. - If the list is open (plugins, user-provided callbacks), you want a table you own, so
that you can version it, keep it in
.rodata, and make it part of your own ABI instead of the C++ one. - Never make a type polymorphic "just in case". The 8 bytes are the cheapest part of the mistake.
- If you must inherit for dispatch, keep the hierarchy shallow, mark everything (Ch18.3), and never let a base pointer escape into your own API.