Ch18.2: What virtual Really Is

中文(简体)

1. Three Things, in This Order

A virtual function is not magic. It is three mechanical facts:

  1. 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.
  2. 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.
  3. 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?

6. Rules of Thumb