# Function Pointers to Vtables: Polymorphism Without the Keyword

> C++ virtual calls, Linux file_operations, and Go interfaces are the same machine code: load a pointer, call through it. What a vtable really is, what an indirect call costs, and how devirtualization claws it back.

URL: https://harshith.in/blog/function-pointers-and-vtables/
Author: Harshith Sunku (Staff Engineer) — https://harshith.in/about/
Tags: systems, performance, compilers

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.
