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
- The need for
virtual
functions - Dynamic Dispatcher - Looking Into Assembly
- Virtual Pointers and Virtual Tables
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)`
mov rax, QWORD PTR [rbp-24]
- load the value stored at
[rbp-24]
intorax
rbp-24
is where our variableshape
is located pointing to aTriangle
object in the heapmain: 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 inrbp-24
(i.e. value ofshape
variable), the address of theTriangle
object intorax
register
- load the value stored at
mov rax, QWORD PTR [rax]
- load the value stored at
[rax]
[rax]
is the value of the vtable ofTriangle
, 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
- load the value stored at
mov rdx, QWORD PTR [rax]
- load value stored at
[rax]
- this resembles the previous instruction except it stores inrdx
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()
- load value stored at
mov rdx, QWORD PTR [rax]
- similar to step 1 - store address of
Triangle
object intorax
register
- similar to step 1 - store address of
mov rdi, rax
- In AMD64,
rdi
holds the first argument to pass onto the function - Recall:
this
is a hidden argument to a method
- In AMD64,
call rdx
- invokes
Triangle::draw()
obtained from step 3
- invokes
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 intorax
register - Line 10 states that we move the value in register
rdx
into whererax
register is pointing into, which isthis
, the start of our newly constructed object- recall how the virtual pointer is stored into
edx
register but we are now moving the contents ofrdx
intothis
rather than fromedx
register edx
register is the 32 bit version ofrdx
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
- recall how the virtual pointer is stored into
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.