This lesson is a quick introductory tour of polymorphism within C++. It is not intended for those who are entirely new to programming. Rather, the people who may find it useful include:
It summarises several lessons from our introductory course. Those looking for more thorough explanations or additional context should consider completing Chapter 6 of that course.
This lesson relates to an important concept that occurs when we’re working with objects from an inheritance hierarchy.
Let’s imagine we have a Monster
object. Specific subtypes of Monster
inherit from it, and provide their own implementation of the Log
method:
class Monster {
public:
void Log(){ std::cout << "Monster\n"; }
};
class Dragon : public Monster {
public:
void Log(){ std::cout << "Dragon\n"; }
};
We have functions and other systems that work with this hierarchy, and we’d like them to call the version of the function defined for the specific type of object they receive.
That is, we’d like a function like the following to call Monster::Log()
if provided with a reference to a basic Monster
, but Dragon::Log()
if provided specifically with a Dragon
:
void CallMethod(Monster& Object){
Object.Log();
}
If we test our code we’ll see that, by default, this does not happen.
When calling methods through references and pointers, C++ uses early binding by default. That means, the compiler figures out what methods to call at compile time, based on the type defined within the parameter list, even if our object is a more specific subtype:
#include <iostream>
class Monster {
public:
void Log(){ std::cout << "Monster\n"; }
};
class Dragon : public Monster {
public:
void Log(){ std::cout << "Dragon\n"; }
};
void CallMethod(Monster& Object){
Object.Log();
}
int main(){
Monster BaseObject;
std::cout << "The first object is a ";
CallMethod(BaseObject);
Dragon DerivedObject;
std::cout << "The second object is a ";
CallMethod(DerivedObject);
}
The first object is a Monster
The second object is a Monster
Even though the second invocation of CallMethod
is passed a reference to a Dragon
object, our program logs "Monster" on both invocations.
This is because the reference in the CallMethod
function has the type of Monster
, and the Monster::Log
function uses early binding. Early binding means the compiler identifies what function to call at compile time, based on the type defined within the parameter list.
In this case, the type within the CallMethod
function was a Monster
reference, Monster&
, so the compiler binds the Object.Log()
call to the version of that function that is in the Monster
class.
virtual
To flag a method for late binding, we need to add the virtual
specifier to it:
class Monster {
public:
virtual void Log(){
std::cout << "Monster\n";
}
};
This has performance implications, as our program now needs to find the correct function at run time.
But, because it happens at run time, our program can investigate exactly what is stored in that location in memory at the point our function is called. It identifies the second call as receiving not just a plain old Monster
object, but specifically a Dragon
object.
As a result, it calls the more specific function, which was in the Dragon
class. With no further changes to our program, we get the desired output:
The first object is a Monster
The second object is a Dragon
This is an example of a powerful run-time polymorphism a powerful concept that lets our project expand in complexity, without our systems getting more complex.
Our core system (in this case, the contrived CallMethod()
function) has not changed. In fact, that system doesn’t even know there is a class called Dragon
. Yet, they can interact with Dragon
objects, invoking all the dragon-specific behaviors.
We could add 100 more types of Monster
, and our core systems don’t need to change at all to accommodate all that variation. They just call Monster
methods, and our specific Monster
subtypes get to decide what happens, by overriding those methods as required.
override
keywordWhen a child class is overriding a function it is inheriting, we should add the override
specifier to that function:
class Dragon : public Monster {
public:
void Log() override {
std::cout << "Dragon" << endl;
}
};
This is optional, but it is highly recommended. Adding the override
specifier makes our intent clear, both to other developers and the compiler.
If we attempt to override a function, but we misspell its name, or provide a different argument list, the compiler doesn’t know we’ve made a mistake. It assumes we’re just defining a different function, so it lets the bug slip through.
By adding the override
specifier, the compiler will know our intent and will throw an error if the function we’re defining on the derived class is not actually overriding anything on a base class.
dynamic_cast
When we have a pointer or reference of the base type, and we want to access a function or variable on a more derived type, we can use dynamic_cast
:
In this method, we have a parameter of the type Monster&
, but we suspect it might be a more specific Dragon
object, so we downcast it appropriately:
void CallMethod(Monster& Object) {
dynamic_cast<Dragon&>(Object);
}
To use dynamic_cast
, our type must be polymorphic, that is, it must define at least one virtual
method. If we otherwise have no need for any virtual method in the class, conventionally we just mark the destructor as virtual:
class Monster {
public:
virtual ~Monster(){}
};
Dynamic casting will succeed if the reference or pointer really is the derived type we tried to cast it to. In that successful scenario, it will return a reference or pointer to that same memory location, but with an updated type. This allows us to access derived members:
#include <iostream>
class Monster {
public:
virtual void BaseFunction(){
std::cout << "Monster ";
}
};
class Dragon : public Monster {
public:
void DerivedFunction(){
std::cout << "and Dragon";
}
};
void CallMethod(Monster& Obj){
Obj.BaseFunction();
dynamic_cast<Dragon&>(Obj)
.DerivedFunction();
}
int main(){
Dragon DerivedObject;
CallMethod(DerivedObject);
}
Monster and Dragon
Dynamic casting can fail. This happens when the object doesn’t actually have the type we’re trying to cast it to. Below, we’re trying to dynamic cast to a Dragon
, but what we’ve received is just a plain old Monster
:
void CallMethod(Monster& Object) {
dynamic_cast<Dragon&>(Object);
}
int main() {
Monster BaseObject;
CallMethod(BaseObject);
}
If dynamic casting a reference fails, as in the above example, our code will throw an exception. We cover exceptions and how to handle them later in this course.
If dynamic casting a pointer fails, it returns a nullptr
. We can test for null pointers by using an if
statement:
#include <iostream>
class Monster {
public:
virtual ~Monster(){}
};
class Dragon : public Monster {};
void CallMethod(Monster* Obj){
Dragon* DragonPtr{
dynamic_cast<Dragon*>(Obj)
};
if (DragonPtr) {
std::cout << "That's a dragon\n";
} else {
std::cout << "That isn't";
}
}
int main(){
Dragon Scaly;
CallMethod(&Scaly);
Monster Goblin;
CallMethod(&Goblin);
}
That's a dragon
That isn't
Polymorphism is a powerful concept that allows for flexible and extensible code. By utilizing virtual functions and dynamic casting, we can write code that adapts to different object types at runtime. Key takeaways:
override
keyword helps ensure that derived classes properly override base class functionsdynamic_cast
allows for safe downcasting of polymorphic objects, enabling access to derived class membersLearn how to write flexible and extensible C++ code using polymorphism, virtual functions, and dynamic casting
Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games