C++ for C Developers |Lesson 9: Polymorphism: Virtual Functions & Dynamic Behavior

Goal: Understand runtime polymorphism (“many shapes”) in C++, enabling you to treat objects of different derived classes uniformly via base class pointers or references, while ensuring the correct, specific derived class methods are executed. Learn the crucial role of the virtual keyword.

1. The Limitation of Basic Inheritance

In Lesson 8, we saw that a Dog is an Animal. This “is-a” relationship means we should be able to use a pointer or reference to an Animal to refer to a Dog object.

C++
Dog myDog("Labrador", 3, "Bob");
Animal* animalPtr = &myDog; // OK: A Dog IS-AN Animal

animalPtr->eat(); // This works as expected (calls Animal::eat)
// But what about methods overridden in Dog, like sleep()?
animalPtr->sleep(); // Which version gets called? Animal's or Dog's?

Without any special keywords, calling animalPtr->sleep() would typically call the Animal::sleep() version, even though animalPtr actually points to a Dog object. The compiler resolves the function call based on the static type of the pointer (Animal*), not the dynamic type of the object it points to (Dog). This is called static binding or early binding.

2. Enabling Dynamic Behavior: virtual Functions

To achieve dynamic binding (or late binding), where the function call is resolved at runtime based on the object’s actual type, C++ uses the virtual keyword.

You declare a function as virtual in the base class if you expect derived classes to provide their own implementation (override it) and you want these derived versions to be callable through base class pointers/references.

Modified Animal Example:

C++
#include <iostream>
#include <string>
#include <vector>
#include <memory> // For smart pointers (recommended)

// --- Base Class ---
class Animal {
protected:
    std::string m_species;
    int m_age;

public:
    Animal(const std::string& species, int age) : m_species(species), m_age(age) {
         std::cout << "Animal Constructor (" << m_species << ")" << std::endl;
    }

    // *** CRUCIAL: Virtual Destructor ***
    // If a class has ANY virtual functions, it NEEDS a virtual destructor.
    virtual ~Animal() {
         std::cout << "Animal Destructor (" << m_species << ")" << std::endl;
    }

    // *** Declare functions intended for overriding as virtual ***
    virtual void speak() const {
        std::cout << "The " << m_species << " makes a generic sound." << std::endl;
    }

    virtual void move() const {
        std::cout << "The " << m_species << " moves somehow." << std::endl;
    }

    // Non-virtual function - behavior is fixed for all derived types via base pointer
    void eat() const {
        std::cout << "The " << m_species << " is eating." << std::endl;
    }

    std::string getSpecies() const { return m_species; }
};

// --- Derived Classes ---
class Dog : public Animal {
private:
    std::string m_breed;
public:
    Dog(const std::string& breed, int age) : Animal("Dog", age), m_breed(breed) {
         std::cout << "  -> Dog Constructor (" << m_breed << ")" << std::endl;
    }
    ~Dog() override { // Good practice to mark override destructors too
        std::cout << "  -> Dog Destructor (" << m_breed << ")" << std::endl;
    }

    // *** Override virtual functions from base ***
    // 'override' keyword (C++11+) is optional but HIGHLY recommended:
    // 1. It documents intent.
    // 2. Compiler checks: Ensures a base virtual function with the exact same signature exists. Prevents subtle bugs!
    void speak() const override {
        std::cout << "The " << m_breed << " dog says: Woof!" << std::endl;
    }

    void move() const override {
        std::cout << "The " << m_breed << " dog runs." << std::endl;
    }
};

class Cat : public Animal {
private:
    bool m_is_grumpy;
public:
    Cat(int age, bool grumpy) : Animal("Cat", age), m_is_grumpy(grumpy) {
         std::cout << "  -> Cat Constructor" << std::endl;
    }
     ~Cat() override {
        std::cout << "  -> Cat Destructor" << std::endl;
    }

    void speak() const override {
        if (m_is_grumpy) {
            std::cout << "The cat hisses." << std::endl;
        } else {
            std::cout << "The cat says: Meow." << std::endl;
        }
    }

    // Cat doesn't override move(), so it will inherit Animal::move() behavior.
};


int main() {
    // Polymorphism works with pointers or references
    // Using smart pointers (unique_ptr) is preferred over raw pointers
    std::vector<std::unique_ptr<Animal>> zoo;

    // Add different types of Animals to the vector using the base class pointer type
    zoo.push_back(std::make_unique<Dog>("Retriever", 5));
    zoo.push_back(std::make_unique<Cat>(2, false));
    zoo.push_back(std::make_unique<Dog>("Poodle", 1));
    zoo.push_back(std::make_unique<Cat>(7, true)); // A grumpy cat
    zoo.push_back(std::make_unique<Animal>("Parrot", 10)); // Base class object too

    std::cout << "\n--- Iterating through the zoo: ---\n";
    // Process all animals uniformly using the base class interface
    for (const auto& animalPtr : zoo) { // animalPtr is of type const std::unique_ptr<Animal>&
        std::cout << "Species: " << animalPtr->getSpecies() << " -> ";

        // *** Dynamic Dispatch happens here! ***
        // The correct speak() and move() is called based on the ACTUAL object type
        animalPtr->speak(); // Calls Dog::speak, Cat::speak, or Animal::speak

        // Only call move() if it's not the base Parrot example for variety
        if (animalPtr->getSpecies() != "Parrot") {
             animalPtr->move(); // Calls Dog::move or Animal::move (since Cat didn't override)
        }

        animalPtr->eat();   // Calls Animal::eat (non-virtual, always the base version via base pointer)
        std::cout << "---" << std::endl;
    }

    std::cout << "\n--- Zoo vector going out of scope ---\n";
    // Objects deleted automatically by unique_ptr. Crucially, the correct
    // derived destructors are called BECAUSE Animal's destructor is virtual.
    return 0;
}

flowchart LR
    AnimalClass["class Animal"]
    DogClass["class Dog : public Animal"]

    AnimalVTable["Animal VTable"]
    DogVTable["Dog VTable"]

    AnimalClass -->|"has virtual speak()"| AnimalVTable
    DogClass -->|"overrides speak()"| DogVTable

    AnimalVTable --> A1["&Animal::speak"]
    DogVTable --> D1["&Dog::speak"]

    subgraph Object Memory
        AnimalObj["Animal* animal = new Dog()"]
        AnimalObj -->|"vptr"| DogVTable
    end

Key Mechanisms:

  • virtual in Base: Tells the compiler that this function might be overridden and calls should be resolved at runtime for polymorphic calls.
  • override in Derived: Explicitly states the intent to override a base virtual function and enables compiler checks for matching signatures.
  • Dynamic Dispatch: When animalPtr->speak() is called, the program looks at the actual object animalPtr points to (e.g., a Dog object) and finds the appropriate speak method to execute (Dog::speak). This lookup happens at runtime. (Often implemented using a hidden “virtual table” or “vtable” associated with classes that have virtual functions).

3. The Crucial Rule: Virtual Destructors

If a class has any virtual functions, or if you intend to delete objects of derived classes through a pointer to the base class, the base class destructor MUST be declared virtual.

C++
// In Base Class (e.g., Animal):
virtual ~Animal(); // CORRECT

// ~Animal(); // WRONG if deleting Derived via Base* !!

flowchart TD
    P["Animal* ptr = new Dog()"]
    P --> D[delete ptr]

    D -->|If destructor is not virtual| Leak["Only ~Animal() called"]
    D -->|If destructor is virtual| Clean["~Dog() → ~Animal()"]

    class Leak wrong
    class Clean correct

Why? Consider this:

C++
Animal* ptr = new Dog("Beagle", 4);
// ... use ptr ...
delete ptr; // PROBLEM if ~Animal() is NOT virtual!

If ~Animal() is not virtual, delete ptr; only calls the Animal destructor based on the pointer type (Animal*). The Dog destructor (~Dog()) will not be called. If the Dog destructor was responsible for releasing resources specific to Dog (e.g., freeing memory), those resources will be leaked!

If ~Animal() is virtual, delete ptr; performs dynamic dispatch for the destructor call. It sees that ptr points to a Dog, calls ~Dog() first, and then automatically calls ~Animal(). This ensures proper cleanup in the correct order (derived then base).

Failure to provide a virtual destructor in a polymorphic base class leads to undefined behavior and resource leaks.

4. Abstract Classes and Pure Virtual Functions (Briefly)

Sometimes, a base class represents a concept for which providing a default implementation makes no sense. For example, what’s the default way for an abstract Shape to draw() itself?

You can create abstract classes by declaring one or more functions as pure virtual:

C++
class Shape { // Abstract Base Class
public:
    virtual ~Shape() = default; // Virtual destructor still needed! `= default` is ok here.

    // Pure virtual function: No implementation in Shape
    virtual double area() const = 0; // '= 0' makes it pure virtual
    virtual void draw() const = 0;
};

class Circle : public Shape {
private:
    double m_radius;
public:
    Circle(double r) : m_radius(r) {}
    // Circle MUST provide implementations for ALL pure virtual functions from Shape
    double area() const override { return 3.14159 * m_radius * m_radius; }
    void draw() const override { std::cout << "Drawing a circle with radius " << m_radius << std::endl; }
};
  • An abstract class (like Shape) cannot be instantiated directly (Shape s; is an error).
  • Derived classes (like Circle) must override all pure virtual functions inherited from the base class to become concrete (instantiable).
  • Abstract classes are excellent for defining interfaces – contracts that derived classes must fulfill.

Summary of Lesson 9:

  • Polymorphism (runtime) allows treating derived objects via base pointers/references, calling the appropriate derived methods.
  • Achieved using the virtual keyword on methods in the base class.
  • Use the override keyword in derived classes for clarity and compiler checks.
  • CRITICAL: Base classes intended for polymorphic use MUST have a virtual destructor to ensure correct cleanup when deleting derived objects via base pointers.
  • Dynamic Dispatch is the runtime mechanism that selects the correct virtual function to call based on the object’s actual type.
  • Pure virtual functions (virtual ... = 0;) create abstract classes that define interfaces and cannot be instantiated directly.

Next Lesson: We’ll cover C++’s preferred way of handling errors – Exceptions – contrasting them with C’s error codes/errno

More Sources:

cppreference – Virtual Functions: https://en.cppreference.com/w/cpp/language/virtual

Learn C++ – Virtual Functions: https://www.learncpp.com/cpp-tutorial/virtual-functions/

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top