A Look Into Virtual Table via Assembly

August 20, 2025

Report a bug

Polymorphism is an important feature of Object Oriented Programming whereby one can represent an object in different forms, hence the name. However, its implementation between Java and C++ which could bite you if you are not aware. This blog is going to explore the following topics:

Static Dispatch

Here’s a snippet of Java code that would behave differently in C++:

class Shape {
    public void draw() {
        System.out.println("Shape");
    }
}

class Triangle extends Shape {
    public void draw() {
        System.out.println("Triangle");
    }
}

public class Main {
    public static void main(String[] args) {
        Shape shape = new Triangle();
        shape.draw();   // prints "Triangle"
    }
}

Output: Triangle

In Java, the base (parent) class can be utilized to represent different forms. Although shape is of type Shape, its value is of type Triangle. This is completely legal as Triangle is a child class (the derived class) of Shape (i.e. Triangle is a Shape). Note that the relation is one way akin to the relationship between a Rectangle and a Square, a square is a rectangle but a rectangle is not a square. This is one of the power of polymorphism, the ability to have a base class act as a generic container that can represent different things.

Here’s a typical C++ implementation a novice would write:

#include <iostream>
class Shape {
public:
    void draw() { std::cout << "Shape\n"; }
};

class Triangle : public Shape {
public:
    void draw() { std::cout << "Triangle\n"; }
};

int main() {
    
    Shape *shape = new Triangle();

    shape->draw(); // prints "Shape"
}

Output: Shape

Unlike the Java Implementation, the novice C++ conversion gives a different output as it’s missing one important keyword.


Virtual Functions - The Missing Key Ingredient to Polymorphism

For polymorphism to work in C++, the virtual keyword was introduced to allow dynamic dispatching of function calls (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,

#include <iostream>
class Shape {
public:
    virtual void draw() { std::cout << "Shape\n"; }
};

class Triangle : public Shape {
public:
    void draw() { std::cout << "Triangle\n"; }
};

int main() {
    Shape *shape = new Triangle();

    shape->draw();
}

Output: Triangle


A Peak Under The Hood - Assembly

The behavior of the object slicing will make have the following:

mov     rax, QWORD PTR [rbp-8]
mov     rdi, rax
call    Shape::draw()

Note: GCC 15.2 amd64 with default compiler settings (i.e. no optimization) with C++ demangling enabled on Godbolt.

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 but it would generate significantly more code as it has to invoke the correct method during runtime and therefore does more jumping to get to the correct function (i.e. virtual dispatch):

mov     rax, QWORD PTR [rbp-24]
mov     rax, QWORD PTR [rax]
mov     rdx, QWORD PTR [rax]
mov     rax, QWORD PTR [rbp-24]
mov     rdi, rax
call    rdx

Here’s some interesting snippet of the code. Do not worry about understanding all of it, it’s just there to illustrate how much more code is generated to get this (note: scrollable):

Shape::Shape() [base object constructor]:
        push    rbp
        mov     rbp, rsp
        mov     QWORD PTR [rbp-8], rdi
        mov     edx, OFFSET FLAT:vtable for Shape+16
        mov     rax, QWORD PTR [rbp-8]
        mov     QWORD PTR [rax], rdx
        nop
        pop     rbp
        ret
        .set    Shape::Shape() [complete object constructor],Shape::Shape() [base object constructor]
Triangle::Triangle() [base object constructor]:
        push    rbp
        mov     rbp, rsp
        sub     rsp, 16
        mov     QWORD PTR [rbp-8], rdi
        mov     rax, QWORD PTR [rbp-8]
        mov     rdi, rax
        call    Shape::Shape() [base object constructor]
        mov     edx, OFFSET FLAT:vtable for Triangle+16
        mov     rax, QWORD PTR [rbp-8]
        mov     QWORD PTR [rax], rdx
        nop
        leave
        ret
        .set    Triangle::Triangle() [complete object constructor],Triangle::Triangle() [base object constructor]
main:
        push    rbp
        mov     rbp, rsp
        push    rbx
        sub     rsp, 24
        mov     edi, 8
        call    operator new(unsigned long)
        mov     rbx, rax
        mov     QWORD PTR [rbx], 0
        mov     rdi, rbx
        call    Triangle::Triangle() [complete object constructor]
        mov     eax, 0
        mov     QWORD PTR [rbp-24], rbx
        test    al, al
        je      .L6
        mov     esi, 8
        mov     rdi, rbx
        call    operator delete(void*, unsigned long)
.L6:
        mov     rax, QWORD PTR [rbp-24]
        mov     rax, QWORD PTR [rax]
        mov     rdx, QWORD PTR [rax]
        mov     rax, QWORD PTR [rbp-24]
        mov     rdi, rax
        call    rdx
        mov     eax, 0
        mov     rbx, QWORD PTR [rbp-8]
        leave
        ret
vtable for Triangle:
        .quad   0
        .quad   typeinfo for Triangle
        .quad   Triangle::draw()
vtable for Shape:
        .quad   0
        .quad   typeinfo for Shape
        .quad   Shape::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"

Let’s go line by line of the virtual dispatch to understand what is going on:

mov     rax, QWORD PTR [rbp-24] ; rax = address of `Triangle` object
mov     rax, QWORD PTR [rax]    ; rax = address of the vtable for `Triangle`
mov     rdx, QWORD PTR [rax]    ; rdx = address of `Triangle::draw()``
mov     rax, QWORD PTR [rbp-24] ; rax = address of `Triangle` object
mov     rdi, rax                ; rdi = address of `Triangle` object acting as our first hidden `this` argument
call    rdx                     ; call  `Triangle::draw(this)`
  1. mov rax, QWORD PTR [rbp-24]
    • load the value stored at [rbp-24] into rax
    • rbp-24 is where our variable shape is located pointing to a Triangle object in the heap
       main:
         push    rbp
         mov     rbp, rsp
         push    rbx
         sub     rsp, 24 ; <- allocates `shape` into the stack
         mov     edi, 8
         call    operator new(unsigned long)
         ...
      
    • [rbp-24] will give us the value located in rbp-24 (i.e. value of shape variable), the address of the Triangle object into rax register
  2. mov rax, QWORD PTR [rax]
    • load the value stored at [rax]
    • [rax] is the value of the vtable of Triangle, the first field in the class
    • we refer this as the virtual pointer which points to the virtual table, more specifically to the first virtual function in the table
  3. mov rdx, QWORD PTR [rax]
    • load value stored at [rax] - this resembles the previous instruction except it stores in rdx register
    • [rax] will give us the first function pointer in the virtual table (i.e. vtbl_ptr[0])
    • first function pointer in virtual table is Triangle::draw()
  4. mov rdx, QWORD PTR [rax]
    • similar to step 1 - store address of Triangle object into rax register
  5. mov rdi, rax
  6. call rdx
    • invokes Triangle::draw() obtained from step 3

Virtual Pointers and Virtual Tables

Recall that in our object slicing example, the call to draw() generated the following assembly code:

mov     rax, QWORD PTR [rbp-8]
mov     rdi, rax
call    Shape::draw()

However, once we declare draw() to be a virtual function, we observed that the compiler generated significantly more code to dynamically dispatch the correct draw() implementation:

1 mov     rax, QWORD PTR [rbp-24]
2 mov     rax, QWORD PTR [rax]
3 mov     rdx, QWORD PTR [rax]
4 mov     rax, QWORD PTR [rbp-24]
5 mov     rdi, rax
6 call    rdx

Notice how the call to our function is indirect (i.e. calls a function stored in rdx register instead of a named label). Line 2 suggests that the first 8B in memory allocated for our Triangle object is a pointer to our first virtual function in the virtual table (called a virtual pointer). This behavior can be observed by looking at the construction of the Triangle constructor:

Triangle::Triangle() [base object constructor]:
; 1   push    rbp
; 2   mov     rbp, rsp
; 3   sub     rsp, 16
; 4   mov     QWORD PTR [rbp-8], rdi
; 5   mov     rax, QWORD PTR [rbp-8]
; 6   mov     rdi, rax
; 7   call    Shape::Shape() [base object constructor]
  8   mov     edx, OFFSET FLAT:vtable for Triangle+16
  9   mov     rax, QWORD PTR [rbp-8]
 10   mov     QWORD PTR [rax], rdx
;11   nop
;12   leave
;13   ret
;14   .set    Triangle::Triangle() [complete object constructor],Triangle::Triangle() [base object constructor]
  • Line 8 shows that we store a pointer to the virtual table into edx register.
  • Line 9 shows we load this, the address where our object begins into rax register
  • Line 10 states that we move the value in register rdx into where rax register is pointing into, which is this, the start of our newly constructed object
    • recall how the virtual pointer is stored into edx register but we are now moving the contents of rdx into this rather than from edx register
    • edx register is the 32 bit version of rdx whereby the upper bits are zeroed
    • TODO: Figure out why only the vtable address fits in 32 bits
    • AT&T syntax produces: movl $vtable for Triangle+16, %edx instead

Note: The location of virtual pointers is compiler-dependent according to stackoverflow

Let’s disect what the virtual pointer points to seeing that it does not point to the start of the virtual table as evident of the 16B offset.

vtable for Triangle:
        .quad   0
        .quad   typeinfo for Triangle
        .quad   Triangle::draw()
Offset Size Content
+0 8B 0
+8 8B RTTI pointer
+16 8B Triangle::draw()

A 16B offset points to the first virtual function in the table, Triangle::draw(), the desired function to invoke. A virtual table is simply a lookup table generated by the compiler to allow us invoke dynamically the desired function.

Suppose we add a new virtual method erase() to Triangle, we’ll see the following in our virtual table:

vtable for Triangle:
        .quad   0
        .quad   typeinfo for Triangle
        .quad   Triangle::draw()
        .quad   Triangle::erase()

As pointers are 8B, an offset of 8 bytes from the virtual pointer will point us to Triangle::erase() as evident from the additional offset during the dynamic dispatch: add rax, 8

; mov     rax, QWORD PTR [rbp-24]
; mov     rax, QWORD PTR [rax]
add     rax, 8
; mov     rdx, QWORD PTR [rax]
; mov     rax, QWORD PTR [rbp-24]
; mov     rdi, rax
; call    rdx

That will conclude this blog post as we now understand why virtual keyword is needed, what virtual pointers are, and how virtual functions are invoked.


Bonus - Issue With References

Don’t make Shape, our base class, as a C++ 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.

For instance, suppose we introduce a Circle object and use references instead of pointers

#include <iostream>
class Shape {
public:
    virtual void draw() { std::cout << "Shape\n"; }
};

class Triangle : public Shape {
public:
    void draw() { std::cout << "Triangle\n"; }
};

class Circle : public Shape {
public:
    void draw() { std::cout << "Circle\n"; }
};

int main() {
    Triangle tri;
    Circle circ;
    Shape &shape = tri;
    shape.draw(); //displays Triangle

    shape = circ;
    shape.draw(); //displays Triangle
}

Recall that C++ references must always be initialized and therefore a type is binded to references during initialization. i.e. this is a compiler error:

Shape &shape;
shape = tri;

As C++ references cannot change type after initialization, this is what we call object slicing where the extra components from Circle are sliced away and thus retaining only the Shape subobject.

When we reassign shape = circ, the reference is not rebinded but instead we see the following invocation:

leaq    -24(%rbp), %rdx
movq    -8(%rbp), %rax
movq    %rdx, %rsi
movq    %rax, %rdi
call    Shape::operator=(Shape const&)

Specifically, ti calls the copy assignment Shape::operator=(Shape const&) whereby only the Shape components of the circle objct is copied and hence is object sliced.

Here’s a more clearer example of what happens during object slicing:

#include <iostream>
class Shape {
public:
    int x = 0;
    virtual std::string draw() { return "Shape "; }
};

class Triangle : public Shape {
public:
    int x = 1;
    std::string draw() { return "Triangle "; }
};

class Circle : public Shape {
public:
    int x = 2;
    std::string draw() { return "Circle "; }
};

int main() {
    Triangle tri;
    Circle circ;
    Shape &shape = tri;
    shape = circ;
    std::cout << shape.draw() << shape.x;
}

Output: Triangle 0

Recall that we set shape = tri, this performs an object slice to tri to conform to Shape layout. This explains why x is zero instead of 1.

Twitter, Facebook