C++ Inheritance Memory Model
In this article, we’re going to dive deep into how C++ inheritance looks in memory and how polymorphism works under the hood. This is not an article on best practices and motivations for inheritance, but rather how C++ makes such powerful and fast inheritance tools.
Let’s start with single inheritance.
The Derived class inherits all the member variables and functions of Base, and the objects memory looks like this
The Derived object looks similar to the Base object in memory in the beginning, but then has some extra things that it created like
You might also be wondering where are all the functions are being stored in memory. All function instructions are stored in a special place in memory once, and not per object. When things are compiled, those function calls will point to the location of the function instructions.
How to Override a Function
The next important part of inheritance is the ability for the derived class to override functions defined in the base class. To do this, C++ lets us make functions virtual. When a function is virtual it goes on the virtual table (vtable). The vtable stores function pointers for all the virtual functions. There is one vtable per class and all objects of the class have a vtable pointer to it.
The order of the function pointers from the Base class is the same order as the Derived class. However, the Base vtable’s
bar1 function pointer points to
Base::bar1 and the Derived vtable’s
bar1 function pointer points to
Derived::bar1. The Derived vtable will also have all the other virtual functions it created on there, after the virtual functions from the Base class.
A call on a virtual function like
d_ptr->bar() is kind of equivalent to
*((d_ptr->vtable_ptr))(d_ptr). You don’t actually have access to the vtable_ptr, so this equivalence is theoretical.
d_ptr->vtable_ptrgets the vtable_ptr which points to an array of function pointers.
(d_ptr->vtable_ptr)is the first element on the Derived vtable which gets us a function pointer to
*((d_ptr->vtable_ptr))is the dereferenced function pointer.
Derived::bar1and passing in a reference to the object that called it. The compiler always implicitly passes the “this” pointer to member functions.
Virtual functions get looked up on the object’s vtable at runtime. So unlike regular functions that get marked in at compile time, virtual functions will look up the function on the vtable at runtime, and run whatever function the vtable entry is pointing to. This is a powerful tool in C++ which enables polymorphism. Polymorphism is when you decide somethings functionality at runtime. You can have a pointer to an object call
bar1, but the version of
bar1 that is actually invoked depends on the object. That’s why the functionality is only determined at runtime.
How come you can set b_ptr = &d?
Why can a Base pointer point to a Derived object? Let’s first answer why this should be true, and then we’ll get to how C++ memory alignment allows this.
All Derived objects are a Base, but not all Base objects are a Derived. So if you have a Base pointer, you’re only going to call functions that are declared in the Base class. Since Derived inherits all the functions and variables from Base, the Derived object will have access to all the functions that Base calls on it, so this is a desired feature.
In order for this to work, the order of the virtual functions and variables needs to be the same in Base and Derived Objects. When a base pointer looks at a Derived object, it just sees the part of it that’s Base.
The red box indicates what a base pointer sees from a Derived object. The
b_ptr sees the Base view. The Derived object looks the same as Base on top, and then has a few more things that it created below, but those don’t matter to
b_ptr since it isn’t looking at the Base view.
b_ptr->bar1() translates to
*((b_ptr->vtable_ptr))(d_ptr) where its calling the first function pointer on the vtable. Regardless of if the underlying object is Derived or Base, the first entry on the vtable is some version of
bar1. Because the memory layout of variables and function pointers are the same for Base and the beginning of Derived,
b_ptr is able to point to a Derived object and call virtual function
bar1 with no problems.
When you have a Derived class inherit from multiple Bases, the memory gets a little more tricky.
We need to layout the memory so that
b1_ptr only has access to the Base1 interface and
b2_ptr only has access to the Base2 interface. To do this, we split up the vtables.
The first vtable includes Base1’s virtual functions and Derived’s virtual functions. Next thing in the Derived object is all of the Base1’s fields. Next is vtable_ptr2 which points to all the virtual functions from Base2. Then, all of the Base2 fields followed by the Derived fields.
The reason we split up the vtables is so that the
b1_ptr can have a proper Base1 view and the
b2_ptr can have a proper Base2 view. We can’t put Base2’s virtual functions in the first virtual table because
foo2 needs to be the first thing in the virtual table to get a valid Base2 view, but
foo1 is already the first thing in the first virtual table. Thus, we need two virtual tables to support both views.
What’s also interesting is that the address that
b2_ptr is pointing to is actually a larger address than what
b1_ptr is pointing to. In fact,
b2_ptr. The compiler will handle the pointer adjustment for you, so don’t worry about that.
b2_ptr as well, but
d_ptr has a view of the whole Derived object.
Virtual functions are slightly more expensive than a regular function, so if you’re trying to make an ultra low-latency program, this section is important. Calling a virtual function involves dereferencing the vtable pointer which is an extra lookup that regular functions don’t have to do.
The other thing is that compiler optimizations like inlining can’t be run on virtual function. Inlining is when function code is copied and pasted where a function is called which avoids the creation of a stack frame, pushing arguments on the stack, etc. This can’t be done with virtual functions because the function that’s being run is only determined at run time. These are very small performance hits, but worth knowing.