A Call the Compiler Cannot Resolve

In the first lesson of this chapter you saw that an ordinary member function call is just a call to a known label, with this riding in rdi:

struct Dog
{
    int legs() const { return 4; }
};

int dogLegs(const Dog& d)
{
    return d.legs();
}

At -O0:

dogLegs(Dog const&):
    push rbp
    mov  rbp, rsp
    sub  rsp, 16
    mov  QWORD PTR -8[rbp], rdi
    mov  rdi, QWORD PTR -8[rbp]
    call Dog::legs() const
    leave
    ret

The target of the call is a fixed label. The compiler knew exactly which function would run, and the linker patched its address straight into the instruction. This is a direct call, and it is what every call you have read so far has been.

Now add one keyword and the certainty evaporates:

struct Animal
{
    virtual int legs() const { return 4; }
    virtual int wings() const { return 0; }
    int age{0};
};

struct Bird : Animal
{
    int legs() const override { return 2; }
    int wings() const override { return 2; }
};

int countWings(const Animal& a)
{
    return a.wings();
}

countWings might be handed an Animal or a Bird. Which wings() runs depends on the dynamic type of the object, something only known while the program is running. The compiler cannot write a label into the call, because there is no single correct label. Instead it compiles a lookup.

The Hidden vtable Pointer

Before reading the call site, look at what virtual did to the object itself. For every class with virtual functions, the compiler builds a vtable (virtual function table): an array of function pointers, one slot per virtual function, in declaration order. And every object of such a class carries a hidden vtable pointer in its first 8 bytes, pointing at its own class's table.

That hidden pointer changes the layout you learned in Chapter 5. age is the first member you wrote, but it is no longer at offset 0:

int getAge(const Animal& a)
{
    return a.age;
}

At -O2:

getAge(Animal const&):
    mov eax, DWORD PTR 8[rdi]
    ret

The load is 8[rdi], not [rdi]: the vtable pointer owns offsets 0 through 7, and every data member shifts past it. sizeof(Animal) is 16, not 4, because of the 8-byte pointer plus 4 bytes of padding after the int. Note the object holds one pointer no matter how many virtual functions the class has; the functions live in the table, not in the object.

Three Instructions to Make the Call

Here is countWings at -O0, trimmed to its essentials:

countWings(Animal const&):
    push rbp
    mov  rbp, rsp
    sub  rsp, 16
    mov  QWORD PTR -8[rbp], rdi
    mov  rdi, QWORD PTR -8[rbp]
    mov  rax, QWORD PTR [rdi]
    mov  rax, QWORD PTR 8[rax]
    call rax
    leave
    ret

The last three instructions before leave are the entire mechanism of virtual dispatch. Read them one at a time:

  1. mov rax, QWORD PTR [rdi]. rdi holds the address of the object, and its first 8 bytes are the hidden vtable pointer. After this load, rax points at the vtable of whatever class the object actually is.
  2. mov rax, QWORD PTR 8[rax]. This indexes into the vtable. wings is the second virtual function declared, so it occupies slot 1, and slot n lives at byte offset 8*n, exactly the constant-index array access from Chapter 5. After this load, rax holds the address of the right wings function.
  3. call rax. An indirect call: instead of jumping to a label baked into the instruction, the CPU jumps to whatever address rax happens to hold. rdi still holds the object's address, so this is already in place.

If the caller passed an Animal, step 1 fetched the Animal vtable and step 2 fetched Animal::wings. If the caller passed a Bird, the same two loads fetched the Bird vtable and Bird::wings. The instructions never change; the data they load through decides which function runs. That is the whole trick: virtual dispatch is a double indirection driven by the object itself.

Where vtables Live

The vtable is not stored per object; there is exactly one per class, sitting in read-only data (.rodata) alongside string literals and floating-point constants. Its label is mangled with a _ZTV prefix followed by the same length-prefixed class name you met in the constructors lesson: _ZTV6Animal, _ZTV4Bird. Run those through c++filt and you get vtable for Animal and vtable for Bird. Demangled, Bird's table looks like this:

vtable for Bird:
    .quad 0
    .quad typeinfo for Bird
    .quad Bird::legs() const
    .quad Bird::wings() const

The first two entries are metadata used by features like dynamic_cast; the pointer stored in each object points just past them, at the first function slot, which is why legs is slot 0 and wings is slot 1.

So who writes the hidden pointer into the object? The constructor. Even when you write no constructor at all, the compiler-generated one starts by installing the table:

lea rdx, 16+vtable for Bird[rip]
mov QWORD PTR [rax], rdx

The address is computed RIP-relative, like any global, and the +16 skips the two metadata entries. This is also why an object's dynamic type is simply "whichever constructor ran": constructing a Bird stamps the Bird table into the first 8 bytes, and every later virtual call reads that stamp.

What It Costs, and When It Disappears

A direct call needs no memory reads to find its target. A virtual call needs two dependent loads (the slot cannot be fetched until the vtable pointer arrives) plus an indirect call, whose target the CPU must predict; a mispredicted indirect branch costs a pipeline flush. The quieter cost is that an indirect call usually cannot be inlined, which blocks further optimization.

But the compiler only pays when it must. If it can prove the dynamic type, it can devirtualise:

int birdWings()
{
    Bird b{};
    return b.wings();
}

At -O2:

birdWings():
    mov eax, 2
    ret

No vtable load, no call rax, no call at all. b is a local Bird, its dynamic type is beyond doubt, so the compiler called Bird::wings directly and then inlined it down to a constant. virtual costs something only where genuine uncertainty about the type survives to the call site.

Key Takeaways

  • A non-virtual call is a direct call to a fixed label; a virtual call through a base pointer or reference cannot name its target, so it compiles to a double indirection.
  • A class with virtual functions gets a hidden vtable pointer in the first 8 bytes of every object, shifting all data members (the first member moves to offset 8).
  • The call-site shape is three instructions: load the vtable pointer from the object (mov rax, [rdi]), load the function pointer from slot n (mov rax, 8*n[rax]), then call rax.
  • There is one vtable per class, in read-only data, labelled _ZTV... ("vtable for ..."); the object stores only a pointer to it.
  • The constructor installs the vtable pointer, which is why the dynamic type is whichever constructor ran last.
  • The cost is two dependent loads plus an indirect call (and lost inlining); when the optimizer can prove the dynamic type, it devirtualises back to a direct call or inlines the body entirely.