Polymorphism

An introduction to polymorphism works in C++, and how we can set up our custom types to take advantage of it
This lesson is part of the course:

Making Games with SDL

Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games

02a.jpg
Ryan McCombe
Ryan McCombe
Posted

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:

  • those who have completed our introductory course, but want a quick review
  • those who are already familiar with programming in another language, but new to C++
  • those who have used C++ in the past, but would benefit from a refresher

It summarises several lessons from our introductory course. Anyone looking for more thorough explanations or additional context should consider Chapter 6 of that course.

Previous Course

Intro to Programming with C++

Starting from the fundamentals, become a C++ software engineer, step by step.

Screenshot from Cyberpunk 2077
Screenshot from The Witcher 3: Wild Hunt

Early Binding

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 of reference or pointer:

#include <iostream>

class Monster {
public:
  void Log(){ std::cout << "Monster\n"; }
};

class Dragon : public Base {
public:
  void Log(){ std::cout << "Dragon\n"; }
};

void CallMethod(Base& 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

The output is technically correct, but perhaps not what we were expecting or wanted. The second object is a Monster, but it is specifically a Dragon.

Even though the second invocation of CallMethod is passed a reference to a Dragon object, our program logs Monster both times.

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 specific type of the object.

In this case, the type within the CallMethod function was a Monster object, so the compiler binds the Object.Log() call to the version of that function that is in the Monster class.

Late Binding with 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 systems (in this case, the contrived CallMethod() function) haven’t changed. In fact, those systems don’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.

The override keyword

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

Downcasting with 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

Was this lesson useful?

Edit History

  • — First Published

Ryan McCombe
Ryan McCombe
Posted
This lesson is part of the course:

Making Games with SDL

Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games

  • 25.Making Minesweeper with C++ and SDL2
  • 26.Project Setup
  • 27.GPUs and Rasterization
  • 28.SDL Renderers
DreamShaper_v7_cyberpunk_woman_playing_video_games_modest_clot_0.jpg
This lesson is part of the course:

Making Games with SDL

Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games

Free, unlimited access!

This course includes:

  • 24 Lessons
  • 100+ Code Samples
  • 91% Positive Reviews
  • Regularly Updated
  • Help and FAQ
Next Lesson

Odds and Ends

A quick tour of ten useful techniques in C++, covering dates, randomness, attributes and more
b.jpg
Contact|Privacy Policy|Terms of Use
Copyright © 2023 - All Rights Reserved