Virtual Functions and Overrides

This lesson provides an introduction to virtual functions and overrides, focusing on their role in enabling runtime polymorphism
This lesson is part of the course:

Intro to C++ Programming

Become a software engineer with C++. Starting from the basics, we guide you step by step along the way

Free, Unlimited Access
3D art showing a programmer
Ryan McCombe
Ryan McCombe
Edited

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:

  • lots of enemy types to fight in our combat system
  • lots of quests to do in our questing system
  • lots of items to select from for our equipment system

We need ways to implement all this complexity, without our code getting so complex it becomes unmanageable.

An Example Use Case

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.

Run Time Polymorphism

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:

  • Inheritance
  • References and Pointers
  • Virtual Functions

Let's see how we might combine these concepts to create the essence of a polymorphic combat system

Step 1: Inheritance

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 {};

Step 2: References and Pointers

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

Step 3: Virtual Functions

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

Expanding the System

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.

Static (Early) and Dynamic (Late) Binding

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

Test your Knowledge

The virtual specifier

What is the effect of the virtual specifier?

The override Specifier

When 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:

  • It informs other developers reading our function that it is an override
  • The compiler ensures that our function really is overriding something. That is, it ensures there is a virtual function on a base class with the same prototype.

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.

Test your Knowledge

The override specifier

What is the effect of the override specifier?

Preventing Overrides using 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'
Test your Knowledge

The final specifier

What is the effect of the final specifier?

Slicing

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.

Summary

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:

  • Virtual Functions: Virtual functions enable runtime polymorphism, allowing subclasses to provide specific implementations for functions declared in a base class.
  • Inheritance and Polymorphism: Using inheritance, we can create a hierarchy of classes where derived classes can override the behavior of base class functions.
  • Override Keyword: The 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: The 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.
  • Object Slicing: We learned about object slicing, a phenomenon where a derived class object is treated as a base class object, leading to the potential loss of derived class information.

Preview: Downcasting

In our next lesson, we will delve into the concepts of downcasting and dynamic casting. Here's what we'll cover:

  • Understanding Downcasting: We'll explain what downcasting is and why it's sometimes necessary, particularly in the context of polymorphism.
  • Dynamic Casting: We'll explore dynamic casting, a safe way to perform downcasting, and understand how it differs from static casting.
  • Risks and Considerations: We'll discuss the potential risks and limitations associated with downcasting, emphasizing the importance of using it judiciously.
  • Practical Scenarios: By examining real-world scenarios, we'll demonstrate where and why downcasting is used in programming.
  • Syntax and Usage: We'll break down the syntax of dynamic_cast and show how to use it effectively in your code.

Was this lesson useful?

Edit History

  • Refreshed Content

  • First Published

Ryan McCombe
Ryan McCombe
Edited
3D art showing a progammer setting up a development environment
This lesson is part of the course:

Intro to C++ Programming

Become a software engineer with C++. Starting from the basics, we guide you step by step along the way

Free, Unlimited Access
Polymorphism
3D art showing a progammer setting up a development environment
This lesson is part of the course:

Intro to C++ Programming

Become a software engineer with C++. Starting from the basics, we guide you step by step along the way

Free, unlimited access

This course includes:

  • 56 Lessons
  • Over 200 Quiz Questions
  • Capstone Project
  • Regularly Updated
  • Help and FAQ
Next Lesson

Downcasting

Get to grips with downcasting in C++, including static and dynamic casts. This lesson provides clear explanations and practical examples for beginners
3D art showing a fantasy monster
Contact|Privacy Policy|Terms of Use
Copyright © 2024 - All Rights Reserved