C++ Virtual Function - A Look on Virtual Tables
August 18, 2025
For polymorphism to work in C++, the virtual
keyword was introduced to allow dynamic dispatch (i.e. resolve function calls at runtime).
In simpler terms, virtual
tells the compiler that the function could be overridden in the derived class (i.e. child class). For instance,
let’s suppose we have a base class (i.e. parent class) Shape
that defines a draw()
method with a derived class Triangle
. Notice
how the virtual
keyword is not used in the draw()
definition:
#include <iostream>
class Shape {
public:
void draw() { std::cout << "Shape\n"; }
};
class Triangle : public Shape {
public:
void draw() { std::cout << "Triangle\n"; }
};
int main() {
Triangle tri;
Shape& shape = tri;
shape.draw();
tri.draw();
}
Without the virtual
keyword, we get the following output:
Shape
Triangle
Even though shape
is pointing to a Triangle
object, the compiler will statically point/bind the draw()
call from the Shape
object since shape
is of type Shape &
.
This behavior makes sense when we look at the corresponding assembly code for the following calls:
shape.draw();
tri.draw();
the corresponding assembly code is:
mov rax, QWORD PTR [rbp-8]
mov rdi, rax
call Shape::draw()
lea rax, [rbp-9]
mov rdi, rax
call Triangle::draw()
Note: GCC 15.2 amd64 with default compiler settings (i.e. no optimization).
To bind the correct draw()
call, we need to either tell the compiler the correct method to bind statically (via static_cast<>()
) or via dynamic dispatch.
Simply adding the virtual
keyword will give us the intended result:
Triangle
Triangle
The generated assembly code for tri.draw()
is nothing surprising:
lea rax, [rbp-16] # triangle is stored in rbp-16
mov rdi, rax
call Triangle::draw()
Unlike the call to triangle’s draw function (i.e. call Triangle::draw()
, the call draw()
onto shape
is a bit more complicated as it
involves a virtual dispatch:
mov rax, QWORD PTR [rbp-8]
mov rax, QWORD PTR [rax]
mov rdx, QWORD PTR [rax]
mov rax, QWORD PTR [rbp-8]
mov rdi, rax
call rdx
To disect what is going on, we’ll need to first refer to the declaration of shape
and our triangle object:
# Triangle tr;
mov eax, OFFSET FLAT:vtable for Triangle+16
mov QWORD PTR [rbp-16], rax
#Shape &shape = tri
lea rax, [rbp-16]
mov QWORD PTR [rbp-8], rax
Notice how there’s something called a vtable
. Every class that implements a virtual function will have a virtual table associated with it that acts as a bridge to the correct function call.
Here’s an example of our virtual tables for Shape
and Triangle
:
vtable for Triangle:
.quad 0
.quad typeinfo for Triangle
.quad Triangle::draw()
typeinfo for Triangle:
.quad vtable for __cxxabiv1::__si_class_type_info+16
.quad typeinfo name for Triangle
.quad typeinfo for Shape
typeinfo name for Triangle:
.string "8Triangle"
typeinfo for Shape:
.quad vtable for __cxxabiv1::__class_type_info+16
.quad typeinfo name for Shape
typeinfo name for Shape:
.string "5Shape"
In Triangle
’s virtual table, you can find the correct call to draw
. This is exactly where the compiler will consult to determine the correct address of the function to call.
For instance, if we introduce another virtual function called erase()
, we’ll see the following in our virtual table:
vtable for Triangle:
.quad 0
.quad typeinfo for Triangle
.quad Triangle::draw()
.quad Triangle::erase()
where the call to erase()
is similar to draw()
but with an offset of 8 bytes (i.e. the size of a function pointer): add rax, 8
Classes with virtual functions have a virtual function pointers to its virtual table. In other words, the first item in the address of the object is to its virtual function.
This series of assembly code shown earlier to dynamically dispatch the correct draw()
call is essentially doing the following:
- Retrieve the address pointed by shape (i.e. load the address of triangle)
- Retrieve the virtual pointer pointed from the triangle (i.e. get the address of the virtual table)
- Retrieve the address of the
Triangle::draw()
and store into the rdx register (i.e. retrieve the function pointer) - Store address of tri into rax
- rdi = rax (i.e. setup the first argument of
draw()
and all methods havethis
as their first argument) - call the function whose address is located in rdx (i.e. jump to
Triangle::draw()
)
I’ll do a deeper dive in the coming days on my blog.
Note: Don’t make Shape
a reference if you want to dynamically dispatch different types of shapes like a Circle with the same Shape
object. Use pointers to ensure shape
rebinds to the correct derived object.