RANDOM BITS

A random site by a random clueless human
Random bits of programming, math, and thoughts By a clueless human          Random bits of programming, math, and thoughts By a clueless human

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:

  1. Retrieve the address pointed by shape (i.e. load the address of triangle)
  2. Retrieve the virtual pointer pointed from the triangle (i.e. get the address of the virtual table)
  3. Retrieve the address of the Triangle::draw() and store into the rdx register (i.e. retrieve the function pointer)
  4. Store address of tri into rax
  5. rdi = rax (i.e. setup the first argument of draw() and all methods have this as their first argument)
  6. 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.