Strip the syntax away and every polymorphism mechanism in mainstream use —
C++ virtual, C’s ops-struct convention, Go interfaces, Rust dyn — compiles
to the same two instructions: load a function pointer from a table, call
through it. Seeing that clearly does two things for you: OOP stops being
magic, and the performance conversation (“are virtual calls slow?”) becomes a
concrete question about loads and branch predictors instead of folklore.
Building It by Hand in C
The kernel has shipped object-oriented C for thirty years. A “class” is a struct of function pointers; an “object” carries a pointer to its type’s table:
struct stream_ops {
ssize_t (*read)(struct stream *, void *, size_t);
ssize_t (*write)(struct stream *, const void *, size_t);
void (*close)(struct stream *);
};
struct stream {
const struct stream_ops *ops; /* the "vptr" */
void *priv; /* the "derived class state" */
};
static const struct stream_ops tcp_ops = { tcp_read, tcp_write, tcp_close };
static const struct stream_ops file_ops = { file_read, file_write, file_close };
n = s->ops->read(s, buf, len); /* a virtual call, spelled out */One static const table per type (not per object — objects share it),
first argument is the explicit this. This is exactly file_operations in
the VFS, net_device_ops in drivers, and half of every C plugin API ever
designed. When you next read kernel source, the pattern is the decoder ring.
What C++ Generates
Mark a method virtual and the compiler builds the same machinery: a hidden
vptr as the object’s first member, pointing to a per-class vtable
holding function addresses (plus RTTI metadata). obj->f() becomes:
mov rax, [rdi] ; load vptr from object
call [rax + 8*slot] ; call through table slotDetails that occasionally matter: the vptr is written during construction —
which is why virtual calls in constructors dispatch to the base version
(the derived vptr isn’t installed yet), a perennial interview question with a
mechanical answer. Each additional base class with virtuals adds another vptr
(multiple inheritance means multiple tables and this-pointer adjustment
thunks). And the vtable is per-class read-only data — a million objects cost
a million vptrs (8 bytes each; sometimes the real memory story) but one
table. Go and Rust rearrange the same parts: the fat pointer
(data, itable/vtable) travels in the reference instead of the object —
two words per reference, zero per object.
The Real Cost of Indirect Calls
The naive accounting — “one extra load” — misses both real costs and overstates the scary one:
- Prediction. An indirect call’s target isn’t known at fetch time; the
indirect branch predictor guesses from history. A call site that’s
monomorphic in practice (always tcp_ops) predicts perfectly and costs
nearly nothing. A megamorphic site (10 types, hot loop, varying) eats
~15–20-cycle mispredicts routinely. Same instruction, 20× spread —
workload shape is everything. (Post-Spectre
retpolinebuilds made indirect calls costlier still; kernels care about this actively.) - The inlining wall. The larger, quieter cost: an opaque call can’t be
inlined, so constant propagation, vectorization, and everything else that
inlining unlocks stops at the boundary. A virtual
get_x()accessor in a loop isn’t slow because of dispatch — it’s slow because the loop can’t be optimized through it.
Compilers fight back with devirtualization: proving the dynamic type
(final classes/methods, whole-program LTO, PGO speculation) and replacing the
indirect call with a direct one — often shaped as
if (ptr == &tcp_read) tcp_read_inlined() else call ptr, a guarded inline
that keeps prediction and optimization. You can hand-roll the same shape in
C when a profile shows one dominant target.
The design guidance falls out mechanically: dispatch at coarse granularity
(a virtual process_batch(items[]), not a virtual call per item per field),
keep per-item hot loops monomorphic — or go data-oriented and switch on a
type tag, which trades the indirect call for a predictable branch and lets
everything inline.
Takeaways
- All dynamic dispatch is load-then-indirect-call; C spells it explicitly, C++/Go/Rust generate it.
- One table per type, one pointer per object (or per reference); constructors dispatch to base because the vptr isn’t swapped yet.
- Cost is bimodal: predictable call sites are ~free, megamorphic ones pay mispredicts — and every opaque call blocks inlining.
- Devirtualization (final, LTO, PGO) and guarded direct calls recover the loss when types are provable or skewed.
- Put dispatch at batch boundaries, not inner loops; the ops-struct pattern is still the best plugin ABI C has.