The same o->handle() calls a different function depending on which subclass o points to: that is dynamic polymorphism, and virtual functions provide it. This post starts from the two instructions it compiles to: what each object hides, what each class gains, what one virtual call costs, and when the compiler can remove it.
1. Virtual Functions: Pick the Function by the Object’s Type at Run Time
1.1 What It Is
A function declared virtual in a base class, when called through a base-class pointer or reference, runs the version for the object’s actual type. The call site is always the same o->handle(); which function runs is known only at run time, from what o points to.
struct Msg {
virtual ~Msg() = default;
virtual int handle() const = 0; // pure virtual: every subclass implements it
int qty = 1;
};
struct TextMsg : Msg {
int handle() const override { return qty * 2; }
};
struct ImageMsg final : Msg { // final: no further subclasses
int handle() const override { return qty * 3; }
};
int run(const Msg* o) { return o->handle(); } // may be a TextMsg or a ImageMsg
Typical use: a container holds objects of different subclasses (std::vector<std::unique_ptr<Msg>>), and a loop calls handle() on each.
1.2 What It Compiles To: vptr and vtable
run compiles to just two instructions:
run(Msg const*):
movq (%rdi), %rax # 1. load the object's first 8 bytes: the vptr, pointing at its class's vtable
jmp *16(%rax) # 2. load the function address from the table's 3rd slot and jump (indirect jump)
Two things make it work:
- vtable (virtual function table): one per class with virtual functions, in read-only data, listing the address of each virtual function in declaration order. In
TextMsg’s table thehandleslot holdsTextMsg::handle; inImageMsg’s,ImageMsg::handle. (The first two slots are destructors, sohandlesits at offset 16.) - vptr (vtable pointer): a hidden 8-byte pointer at the start of every object, set at construction to its class’s vtable. That’s why
sizeof(TextMsg)is 16: 8 bytes of vptr, 4 ofqty, 4 of padding.
So one virtual call = load the vptr → load the table slot → jump to the loaded address.
Why one vptr is enough: a given virtual function sits in the same slot in every derived class’s table. When compiling run, the compiler only knows o is a const Msg*, not which derived class will show up, so the offset 16 comes from Msg’s declaration: in Msg’s table the first two slots are destructors and handle is the 3rd. Every derived table is laid out on the base’s slots: inherited virtuals keep their slots in the same order, an override replaces that slot with its own address, and new virtuals go after them. So whether o points to a TextMsg or an ImageMsg, 16(%rax) reads that class’s own handle.
Picking the function is split in two: which slot is fixed at compile time from the static type; what the slot holds depends on which table the vptr was set to at construction. That’s why the call site doesn’t need to know the derived classes: a new subclass written later, in another file or even another shared library, lays out its table on Msg’s slots, and jmp *16(%rax) needs no recompiling.
1.3 Where the Cost Is
- The indirect jump has to be predicted: the target is loaded from memory, so the front end relies on the indirect target predictor (#7 Section 1.3). When the object types at one call site follow a pattern (all the same, or a short repeating cycle), it almost never misses; when they’re randomly mixed, it misses often, about 15–20 cycles each time.
- It blocks inlining: the compiler doesn’t know which function will run, so it can’t inline it or optimize across the call (constant propagation, merging computations, vectorization). The smaller the function, the worse this is:
handle()itself is a single multiply, much cheaper than the call. - Two extra memory reads: first the vptr from the start of the object (
movq (%rdi), %rax), then the function address from the vtable slot (the16(%rax)injmp *16(%rax)). If either goes to memory it costs about 70–120 ns, far more than the call itself, but they rarely do, for two different reasons:- The vptr sits in the same cache line as the object’s data. Without virtual functions you still read the object’s members (such as
mult). If the object is cold, both versions pay one cache miss for that line; reading the vptr doesn’t add a second one. What the virtual version adds is only an object 8 bytes bigger, so fewer fit in a line. - There is one vtable per class, not per object. It lives in the read-only data section; two or three classes means two or three cache lines, shared by every call. When calls are frequent something reads it all the time, so it stays in L1, about 1 ns.
- When the prediction is right, the two reads barely slow anything. The CPU doesn’t wait for them to decide where to jump: the predictor supplies the target, the CPU starts executing the callee, and the two reads complete alongside, only to check the guess. On a mispredict, or when this call site has no prediction history yet, it really has to wait: read the vptr, and only then does it know where to read the function address; read that slot, and only then can it jump. The two reads happen one after the other and can’t overlap.
- What’s really expensive is a cold start. After the program has been idle for a while, other code has pushed this code’s cache lines out, and on the next call both the object’s line and the vtable’s line may be missing. Both versions pay for the object; the vtable miss belongs to the virtual version alone and queues behind the vptr read, adding in the worst case one serial miss of about 70–120 ns (estimated from the latency ranges, not measured). Where that one miss matters, switch to static polymorphism, which has no vtable to read.
- The vptr sits in the same cache line as the object’s data. Without virtual functions you still read the object’s members (such as
- 8 more bytes per object: a small object (say a 16-byte message) grows noticeably, so fewer fit in a cache line.
1.4 When the Compiler Removes the Virtual Call (Devirtualization)
When the compiler can prove the actual type, it calls the function directly, or inlines it. Three common cases; the first two are measured:
int run_image(const ImageMsg* o) { return o->handle(); } // ImageMsg is final
int run_local() { TextMsg l; return run(&l); } // the object is right there
final:ImageMsgisfinal, so there can’t be a subclass ando->handle()can only beImageMsg::handle.run_imagecompiles tomovl 8(%rdi), %eax+leal (%rax,%rax,2), %eax: the multiply by 3 is inlined, no jump.- Local objects: in
run_localthe type is written right there; oncerunis inlined, the whole function compiles tomovl $2, %eax. - A single implementation: with
-flto(link-time optimization) the compiler sees the whole program, and a virtual function with only one implementation can be called directly too (not measured here).
1.5 Without Virtual Functions: Static Polymorphism
When the type is known at compile time, you don’t need a vtable: templates and CRTP fix “which function” at compile time and the call inlines; when the set of types is fixed, std::variant dispatches on a type index. Together these are called static polymorphism; what they compile to and what each costs is in Static Polymorphism.
1.6 How to Choose
- The type is only known at run time and the set is open (plugins, read from config): virtual functions.
- The set of types is fixed and objects share one container:
std::variant+std::visit. - The type is known at compile time: templates or CRTP; mark classes
finalwhere you can so the compiler can call directly. - Most of the time: virtual functions read and change most easily, so start with them. Only where calls are very frequent and the function body is tiny does the virtual-call cost show, and then switch to static polymorphism.
2. Composition and Inheritance: Reuse an Implementation as a Member
class InheritanceInbox : public std::vector<Msg> { // inheritance: "is" a vector
};
class CompositionInbox { // composition: "has" a vector
std::vector<Msg> msgs_;
public:
auto size() const noexcept { return msgs_.size(); } // expose only what's needed
};
They compile the same: member access and size() both read the field inside msgs_ directly, and size() inlines. The difference is all design; publicly inheriting std::vector has three problems:
- The whole interface is exposed: anyone can call
push_backorerase, so invariants like “the inbox is sorted by time” can’t be protected. Composition exposes only the functions you choose. - The destructor isn’t virtual:
std::vector<Msg>* p = new InheritanceInbox;followed bydelete pis undefined behavior. Standard containers aren’t designed to be base classes. - Tied to the implementation: replacing
std::vectorwith another container means changing every caller with inheritance, but only the class’s internals with composition.
The rule: if you want “its implementation,” make it a member (has-a); public inheritance only when you really mean “usable as one” (is-a) and the base is designed for it (it has a virtual destructor).