Virtual Functions and vtables
How a virtual call becomes a double indirection: load the vtable pointer, load the function pointer, call through it.
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:
mov rax, QWORD PTR [rdi].rdiholds the address of the object, and its first 8 bytes are the hidden vtable pointer. After this load,raxpoints at the vtable of whatever class the object actually is.mov rax, QWORD PTR 8[rax]. This indexes into the vtable.wingsis the second virtual function declared, so it occupies slot 1, and slotnlives at byte offset8*n, exactly the constant-index array access from Chapter 5. After this load,raxholds the address of the rightwingsfunction.call rax. An indirect call: instead of jumping to a label baked into the instruction, the CPU jumps to whatever addressraxhappens to hold.rdistill holds the object's address, sothisis 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 slotn(mov rax, 8*n[rax]), thencall 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.
Create an account to track your progress and access interactive exercises. Already have one? Sign in.
Virtual Functions and vtables - Quiz
Test your understanding of the lesson.
Practice Exercises
Override the Square Shape
The describe function takes a Shape& and calls the virtual functions name() and sides(), so the right override is chosen at run time through the vtable. Triangle overrides both, but Square does not yet, so it is reported as a generic shape with 0 sides. Complete Square so the output is correct. Then switch the assembly panel to -O0 and look inside describe: find the load of the hidden vtable pointer from the object and the indirect call through a register that together make virtual dispatch work.
Lesson Discussion
Share your thoughts and questions
No comments yet. Be the first to share your thoughts!