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:

c
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:

text
  mov  rax, [rdi]          ; load vptr from object
  call [rax + 8*slot]      ; call through table slot

Details 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 retpoline builds 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.