Before we introduce the next topic, let's take some time to explain the goal.
Ambitious projects can have a lot of systems, and those systems manage complex interactions between objects.
Those systems also often need to support different types of objects, each of which can behave in slightly different ways.
In the context of a game, for example, that might include:
We need ways to implement all this complexity, without our code getting so complex it becomes unmanageable.
Let's take our hypothetical combat system. We want our game to support a large range of enemy types, each being capable of having their unique ways of fighting.
Goblins might charge in and attack at close range. Archers might stand back and fight from range. Dragons might take to the skies.
We don't want to define all those behaviors within the combat system. That would cause the system to get unmanagably complex, and more complex with every new enemy type we add.
Instead, our project is much more manageable if we encapsulate those behaviors within the respective classes. Our dragons’ behaviors should be in the Dragon
class, goblin logic belongs in the Goblin
class, and so on.
In our hypothetical system, the player can run around and choose what enemies to battle with. Therefore, every time our combat system gets used, we don’t necessarily know the types that will be involved.
That is determined by player actions, at run time. Maybe the player will fight some goblins first, or maybe they’ll go for the dragon. So, we need run time polymorphism.
In C++, this is achieved by a combination of three techniques, two of which we’re already familiar with:
Let's see how we might combine these concepts to create the essence of a polymorphic combat system
The first step of the process is establishing a base class, from which all of our combat participants will inherit. Here, we’ll call our base class Character
, and we’ll provide an Act()
function, which will represent a combat action.
Any time we want our character to do something, we’ll call Act()
, and we’ll pass in a pointer to the target that our character is fighting:
class Character {
public:
void Act(Character* Target) {
cout << "Character Acting\n";
}
};
Then, every type of object that we want to be able to participate in our combat system, such as goblins and dragons, will inherit from this base class:
class Character {
public:
void Act(Character* Target) {
cout << "Character Acting\n";
}
};
class Goblin : public Character {};
class Dragon : public Character {};
For the next step, we set up our systems to work with references or pointers to this shared base type.
In this example, we’ll represent our combat system as a simple function called Battle
. Battle receives two Character
pointers, and has them Act()
upon each other:
void Battle(Character* A, Character* B) {
A->Act(B);
B->Act(A);
}
Remember, under the rules of inheritance, Goblins and Dragons are Characters. So each of these pointers could be pointing at a basic Character
, a Dragon
, a Goblin
, or any other subtype of Character
we add in the future.
So far, so good. Below, a Goblin
and a Dragon
enter battle, and both of our combatants are using the Act()
function defined on the basic Character
class:
#include <iostream>
using namespace std;
class Character {
public:
void Act(Character* Target){
cout << "Character Acting\n";
}
};
class Goblin : public Character {};
class Dragon : public Character {};
void Battle(Character* A, Character* B){
A->Act(B);
B->Act(A);
}
int main(){
Goblin A;
Dragon B;
Battle(&A, &B);
}
Character Acting
Character Acting
We want function calls like A->Act()
to have different effects, depending on which subtype of character A
is pointing at. For example, if A
is a Goblin
, the effect of this function call will be different than if A
were a Dragon
.
To achieve this, we need to provide Goblin
and Dragon
-specific implementations of the Act()
function, using the same prototype as Act()
within the Character
class.
This is referred to as overriding the function:
class Goblin : public Character {
public:
void Act(Character* Target){
cout << "Goblin Acting\n";
}
};
class Dragon : public Character {
public:
void Act(Character* Target){
cout << "Dragon Acting\n";
}
};
Then, we need to mark the Character
version of this function as virtual
. We cover the significance of the virtual
keyword in the next section:
class Character {
public:
virtual void Act(Character* Target){
cout << "Character Acting\n";
}
};
With everything in place, we’ve now achieved run-time polymorphism. Without changing any code in our Battle
function, its behavior is now dynamic:
#include <iostream>
using namespace std;
class Character {
public:
virtual void Act(Character* Target){
cout << "Character Acting\n";
}
};
class Goblin : public Character {
public:
void Act(Character* Target){
cout << "Goblin Acting\n";
}
};
class Dragon : public Character {
public:
void Act(Character* Target){
cout << "Dragon Acting\n";
}
};
void Battle(Character* A, Character* B){
A->Act(B);
B->Act(A);
}
int main(){
Goblin A;
Dragon B;
Battle(&A, &B);
}
Goblin Acting
Dragon Acting
With the basic system in place, we’re now free to expand it as needed, whilst effectively managing the complexity.
We can add depth by expanding the core system - that is, by expanding the Character
class and the Battle
function. For example, we introduce the concept of alive and dead below, with battle continuing until one of our combatants is dead:
class Character {
public:
virtual void Act(Character* Target){
cout << "Character Acting\n";
}
bool GetIsAlive(){ return isAlive; }
protected:
bool isAlive{true};
};
void Battle(Character* A, Character* B){
while (A->GetIsAlive() && B->GetIsAlive()) {
A->Act(B);
B->Act(A);
}
}
We can add breadth by adding more and more enemy types, without our combat system getting more and more complex. Our types just inherit from Character
and override functions as needed.
Remember, we can use multiple layers of inheritance if needed. Our types don’t always need to inherit from Character
directly:
class Character {};
class Dragon : public Character {};
class FireDragon : public Dragon {};
class FrostDragon : public Dragon {};
class StormDragon : public Dragon {};
This pattern keeps our project organized and manageable, even as we scale up to hundreds or even thousands of types.
Our source code file might get a little large, but in the next chapter, we’ll see how we can split our project across multiple files. This allows us to keep types in dedicated files like Dragon.cpp
and Goblin.cpp
, helping us keep things organized as our classes get larger and more powerful.
The inclusion of the virtual
keyword in our class function changes calls to that function from being statically bound to being dynamically bound.
By default, C++ binds our function calls to the function definitions in our code at compile time. This is sometimes referred to as static binding, or early binding.
In other words, when the compiler sees an expression like A->Act()
, it investigates the type of A
. In our Battle
function, A
is a Character
, so the compiler binds this call to the Act()
function as defined within the Character
class.
By adding the virtual
specifier, we’re asking the compiler to take a different approach. When we call a virtual
function, the compiler determines what specific type of object our pointer is pointing at.
It then calls the version of the function within that type. If that type doesn’t define it, we then search up the inheritance tree until we find the nearest ancestor that does.
This needs to be done at run time, so is referred to as dynamic binding, or late binding.
Dynamic binding has a small performance impact at run time, which is why it is not used by default. When we need a function to behave this way, we have to explicitly opt it in, by marking it as virtual
virtual
specifierWhat is the effect of the virtual
specifier?
override
SpecifierWhen a class function is overriding a virtual method on a base class, it is considered an override.
When this is happening, we should be explicit, by marking the function with the override
keyword:
class Goblin : public Character {
public:
void Attack() override {
cout << "Goblin Attacking!" << endl;
}
};
This is not required, but we should use it where applicable. It has two benefits:
This second point is especially useful when we come back to update our code in the future. If we change the name or parameters of a function, any child class that was overriding it no longer will be, because the prototype will now be different.
By adding the override
specifier, the compiler will detect that issue, and alert us. This makes sure we know to update those child classes too, preventing any bugs.
override
specifierWhat is the effect of the override
specifier?
final
When our code encounters a virtual function, the process of traversing down the inheritance tree to find the most derived override effectively treats all intermediate functions as virtual, too.
This is the case whether they are marked as virtual
or not. In scenarios where we don't want that to happen, we can mark the function as final
.
Below, our Goblin
class doesn’t want any subtypes from overriding Act()
, so it marks it as final
. When GoblinWarrior
tries, the compiler generates an error:
class Character {
public:
virtual void Act(){}
};
class Goblin : public Character {
public:
void Act() final{}
};
class GoblinWarrior : public Goblin {
public:
void Act() override{}
};
error:
'Goblin::Act': function declared as 'final'
cannot be overridden by 'GoblinWarrior::Act'
final
specifierWhat is the effect of the final
specifier?
It is worth noting that this form of run-time polymorphism only works when we’re passing our objects by reference or pointer.
The following code creates a Goblin
object and then passes that object by value to a Character
parameter:
class Character {};
class Goblin : public Character {
public:
int Damage{10};
};
void Battle(Character Enemy){
// Enemy is always a basic Character
}
int main() {
Goblin Bonker;
Battle(Bonker);
}
Within the Battle()
function, Enemy
is no longer a Goblin
. It is simply a Character
. An object of a subtype can be copied to an object of a base type, and that’s what has happened here.
Any subtype-specific variables, such as the Damage
integer in this case, are simply discarded, leaving us with a plain old Character
.
This scenario is sometimes referred to as slicing. There are legitimate use cases where we may want to do this, but it’s frequently a bug.
In this lesson, we explored the powerful concept of virtual functions and overrides, which are essential for implementing runtime polymorphism. Here are the key takeaways:
override
keyword, while not mandatory, clarifies that a function is intended to override a base class function and ensures safety by enabling compiler checks.final
specifier is used to prevent further overriding of a method in any subclass, ensuring that the function behavior remains consistent in the inheritance hierarchy.In our next lesson, we will delve into the concepts of downcasting and dynamic casting. Here's what we'll cover:
dynamic_cast
and show how to use it effectively in your code.This lesson provides an introduction to virtual functions and overrides, focusing on their role in enabling runtime polymorphism
Become a software engineer with C++. Starting from the basics, we guide you step by step along the way