Understanding Inheritance in C++

by | C++, Object-Oriented Programming, Programming

In this guide, we’ll explore inheritance in C++, one of the fundamental pillars of object-oriented programming. We’ll cover everything from basic concepts to advanced techniques, with practical examples and best practices that you can apply in your own code.

Introduction

Inheritance is a core concept in object-oriented programming that allows us to create new classes based on existing ones. This mechanism promotes code reuse by enabling us to define shared behaviour in a base class and extend or customize it in derived classes. It establishes relationships between classes that share common characteristics, forming a hierarchy that reflects real-world relationships.

Think of inheritance like a family tree: a child inherits traits from their parents, such as eye color or height. Similarly, in programming, a derived class inherits properties and behaviours from its base class, but it can also introduce unique characteristics. For example, a “Vehicle” class might define general attributes like speed or capacity, and a “Car” class can inherit these while adding features specific to cars, like the number of doors.

Inheritance promotes efficiency by enabling shared characteristics to be defined once and reused across related classes, much like documenting common family traits without repeating them for every individual. However, it’s essential to apply inheritance thoughtfully to maintain simplicity and avoid unnecessary complexity in your designs.

💡 Key Insight: While inheritance is powerful, remember that composition is often a better choice for many design scenarios. Use inheritance when you truly have an “is-a” relationship between classes, such as “Car is a Vehicle,” but consider composition when the relationship is “has-a,” such as “Car has an Engine.”

📚 OOP Quick Reference
Inheritance
A mechanism that allows a class to inherit properties and methods from another class, enabling code reuse and establishing parent-child relationships.
Polymorphism
The ability of objects to take multiple forms. In C++, achieved through virtual functions allowing different classes to be treated as instances of the same class.
Virtual Function
A member function declared in a base class that can be redefined in derived classes, enabling runtime polymorphism and dynamic method dispatch.
Pure Virtual Function
A virtual function that must be implemented by derived classes, marked with “= 0”. Makes its class abstract, meaning it cannot be instantiated directly.
Abstract Class
A class containing at least one pure virtual function. Cannot be instantiated on its own and serves as a template for derived classes.
Override
A keyword used to explicitly declare that a function is meant to override a virtual function from a base class, helping catch errors at compile time.

Basic Inheritance Concepts

Inheritance allows us to create a new class (a derived class) that is based on an existing class (a base class), reusing its attributes and behaviours while enabling specific customizations. This example demonstrates how inheritance works in C++ through a base class Animal and a derived class Dog.

The base class Animal encapsulates common properties such as name and age, and provides a generic implementation of the makeSound() method. The derived class Dog inherits these properties and behaviours but overrides the makeSound() method to provide a more specific behaviour for a dog.

Basic Inheritance Example
#include <iostream>
#include <string>

// Base class representing a generic animal
class Animal {
protected:
    std::string name; // Name of the animal
    int age;          // Age of the animal

public:
    // Constructor to initialize the name and age
    Animal(const std::string& name, int age)
        : name(name), age(age) {}

    // Virtual function to make a sound
    // Can be overridden by derived classes
    virtual void makeSound() const {
        std::cout << "Some generic animal sound" << std::endl;
    }

    // Function to display the animal's information
    void displayInfo() const {
        std::cout << "Name: " << name << ", Age: " << age << std::endl;
    }
};

// Derived class representing a Dog, inheriting from Animal
class Dog : public Animal {
public:
    // Constructor to initialize the Dog using the Animal constructor
    Dog(const std::string& name, int age)
        : Animal(name, age) {}

    // Override the makeSound function to provide a specific behaviour for dogs
    void makeSound() const override {
        std::cout << "Woof! Woof!" << std::endl;
    }
};

int main() {
    // Create an instance of Animal
    Animal genericAnimal("Generic", 5);

    // Create an instance of Dog
    Dog rover("Rover", 3);

    // Display information and sound for the generic animal
    std::cout << "Generic Animal:" << std::endl;
    genericAnimal.displayInfo();
    genericAnimal.makeSound();

    // Display information and sound for the dog
    std::cout << "\nDog:" << std::endl;
    rover.displayInfo();
    rover.makeSound();

    return 0;
}
Generic Animal:
Name: Generic, Age: 5
Some generic animal sound

Dog:
Name: Rover, Age: 3
Woof! Woof!

Explanation:

  • The Animal Class: This class defines the shared properties name and age, and a generic behaviour makeSound(). The displayInfo() method is also defined to print the animal’s details.
  • The Dog Class: This class inherits from Animal, reusing its name and age properties, as well as the displayInfo() method. However, it overrides the makeSound() method to provide a dog-specific implementation (“Woof! Woof!”).
  • In the main() Function:
    • An instance of Animal is created with generic details and calls its methods, producing a generic output.
    • An instance of Dog is created, which inherits the displayInfo() method but uses its own implementation of makeSound(), demonstrating polymorphism.

Why It Matters: This example showcases how inheritance promotes code reuse and allows for method overriding to enable specific behaviours in derived classes. It demonstrates the “is-a” relationship, where a Dog is an Animal, making the system extensible for new animal types in the future.

Types of Inheritance

C++ supports five types of inheritance, each serving different design purposes:

  • Single Inheritance: A derived class inherits from a single base class.
  • Multiple Inheritance: A derived class inherits from two or more base classes.
  • Hierarchical Inheritance: Multiple derived classes inherit from a single base class.
  • Multilevel Inheritance: A class is derived from another derived class.
  • Hybrid Inheritance: A combination of multiple inheritance and other types.

Let’s explore single and multiple inheritance with practical examples:

Different Types of Inheritance
#include <iostream>

// Base class providing a basic value and functionality
class Base {
protected:
    int value; // A protected integer that derived classes can access
public:
    // Constructor to initialize the value
    Base(int v) : value(v) {}

    // Method to display the current value
    void display() const {
        std::cout << "Value: " << value << std::endl;
    }
};

// Single Inheritance: Derived inherits from Base
class Derived : public Base {
public:
    // Constructor to initialize the base class
    Derived(int v) : Base(v) {}

    // Method to multiply the value by a given factor
    void multiply(int factor) {
        value *= factor;
    }
};

// Multiple Inheritance example

// Engine class represents a car's engine type
class Engine {
protected:
    std::string type; // Type of the engine (e.g., V8, Electric)
public:
    // Constructor to initialize the engine type
    Engine(const std::string& t) : type(t) {}

    // Method to simulate starting the engine
    void start() {
        std::cout << type << " engine starting..." << std::endl;
    }
};

// Vehicle class represents a general vehicle
class Vehicle {
protected:
    std::string brand; // Brand of the vehicle (e.g., Toyota)
public:
    // Constructor to initialize the vehicle's brand
    Vehicle(const std::string& b) : brand(b) {}

    // Method to display the vehicle's brand
    void displayBrand() {
        std::cout << "Brand: " << brand << std::endl;
    }
};

// Car class inherits from both Engine and Vehicle
class Car : public Engine, public Vehicle {
public:
    // Constructor to initialize both the engine and vehicle attributes
    Car(const std::string& type, const std::string& brand)
        : Engine(type), Vehicle(brand) {}

    // Method to display car info by combining Engine and Vehicle behaviour
    void info() {
        displayBrand();
        start();
    }
};

int main() {
    // Single inheritance example
    Derived d(5);      // Create an object of Derived with value 5
    d.display();       // Output the initial value
    d.multiply(3);     // Multiply value by 3
    d.display();       // Output the updated value

    std::cout << "\n";

    // Multiple inheritance example
    Car car("V8", "Toyota"); // Create a Car object with a V8 engine and Toyota brand
    car.info();              // Display car information (brand and engine type)

    return 0;
}
Value: 5
Value: 15

Brand: Toyota
V8 engine starting…

Explanation:

  • Single Inheritance:
    • The Derived class inherits from the Base class.
    • The Base class provides a property value and a method display().
    • The Derived class extends the behaviour of Base by adding the multiply() method, which modifies value.
  • Multiple Inheritance:
    • The Car class inherits from both the Engine and Vehicle classes.
    • The Engine class provides a method start(), while the Vehicle class provides displayBrand().
    • The Car class combines these behaviours in the info() method, which displays the brand and starts the engine.

Why It Matters: These examples illustrate how inheritance enables code reuse and modular design. Single inheritance is simpler and avoids potential conflicts, while multiple inheritance is powerful but requires careful management to resolve ambiguities when inheriting from multiple sources.

Other types, such as hierarchical, multilevel, and hybrid inheritance, are less commonly used. They often increase code complexity and are best applied in specific scenarios where they are absolutely necessary.

Virtual Functions and Polymorphism

Virtual functions are essential for enabling runtime polymorphism in C++, allowing derived classes to override base class methods. This means that the behaviour of a method is determined dynamically at runtime, rather than statically at compile time. Here’s a practical example to illustrate this concept:

Virtual Functions and Polymorphism Example
#include <iostream>
#include <vector>
#include <memory>

// Abstract base class representing a general shape
class Shape {
public:
    virtual ~Shape() = default; // Virtual destructor for proper cleanup
    virtual double area() const = 0;  // Pure virtual function to compute area
    virtual void draw() const = 0;   // Pure virtual function to draw the shape
};

// Derived class representing a Circle
class Circle : public Shape {
private:
    double radius; // Radius of the circle

public:
    // Constructor to initialize the radius
    Circle(double r) : radius(r) {}

    // Override the area function to compute the circle's area
    double area() const override {
        return 3.14159 * radius * radius;
    }

    // Override the draw function to display a circle-specific message
    void draw() const override {
        std::cout << "Drawing a circle" << std::endl;
    }
};

// Derived class representing a Rectangle
class Rectangle : public Shape {
private:
    double width;  // Width of the rectangle
    double height; // Height of the rectangle

public:
    // Constructor to initialize width and height
    Rectangle(double w, double h) : width(w), height(h) {}

    // Override the area function to compute the rectangle's area
    double area() const override {
        return width * height;
    }

    // Override the draw function to display a rectangle-specific message
    void draw() const override {
        std::cout << "Drawing a rectangle" << std::endl;
    }
};

int main() {
    // Vector to store unique pointers to Shape objects
    std::vector<std::unique_ptr<Shape>> shapes;

    // Add a Circle object to the vector
    shapes.push_back(std::make_unique<Circle>(5));

    // Add a Rectangle object to the vector
    shapes.push_back(std::make_unique<Rectangle>(4, 6));

    // Iterate through each shape in the vector
    for (const auto& shape : shapes) {
        shape->draw();  // Calls the appropriate draw() method based on the object type
        std::cout << "Area: " << shape->area() << std::endl; // Outputs the area
    }

    return 0;
}
Drawing a circle
Area: 78.5397
Drawing a rectangle
Area: 24

Explanation:

  • Base Class (Shape):
    • The Shape class declares two pure virtual functions: area() and draw(). This makes Shape an abstract class, meaning you cannot instantiate it directly.
    • The virtual destructor ensures proper cleanup of derived class objects, preventing resource leaks.
  • Derived Classes (Circle and Rectangle):
    • Both classes inherit from Shape and provide specific implementations for the area() and draw() methods.
    • The Circle class calculates the area using the formula for the area of a circle, while the Rectangle class uses the formula for a rectangle.
    • The draw() method outputs a message indicating the shape being drawn.
  • Polymorphism in Action:
    • The shapes vector holds std::unique_ptr objects pointing to Shape instances. Even though the vector is of type Shape, the overridden methods of the derived classes are called dynamically.
    • This demonstrates runtime polymorphism, where the correct draw() and area() methods are called based on the actual type of the object stored in the vector.

Why It Matters: Virtual functions allow for flexible and extensible designs by enabling runtime behaviour changes. This is particularly useful in systems with heterogeneous objects, such as graphical applications, where the behaviour of an object depends on its type but must be handled uniformly.

💡 Best Practice: Always make destructors virtual in base classes that are meant to be inherited from. This ensures proper cleanup of derived class objects through base class pointers, preventing memory leaks or incomplete destruction.

Best Practices and Common Pitfalls

When working with inheritance in C++, keep these best practices in mind:

  • Use virtual destructors in base classes when inheritance is intended
  • Prefer composition to inheritance when there isn’t a clear “is-a” relationship
  • Follow the Liskov Substitution Principle – derived classes should be substitutable for their base classes
  • Avoid deep inheritance hierarchies – they can make code harder to understand and maintain
  • Use override keyword for virtual function implementations in derived classes
Common Pitfalls Example
#include <iostream>

// Pitfall 1: Missing virtual destructor
class Base {
public:
    Base() { std::cout << "Base constructor\n"; }
    ~Base() { std::cout << "Base destructor\n"; }  // Should be virtual!
};

class Derived : public Base {
private:
    int* data;
public:
    Derived() : data(new int[100]) {
        std::cout << "Derived constructor\n";
    }
    ~Derived() {
        delete[] data;  // Proper cleanup of allocated memory
        std::cout << "Derived destructor\n";
    }
};

// Pitfall 2: Hiding base class methods
class Parent {
public:
    // A method that takes one argument
    void display(int x) {
        std::cout << "Parent: " << x << std::endl;
    }
};

class Child : public Parent {
public:
    // A method with the same name but different arguments
    // This hides the Parent::display(int) method
    void display(int x, int y) {
        std::cout << "Child: " << x << ", " << y << std::endl;
    }
};

int main() {
    // Pitfall 1: Memory leak due to missing virtual destructor
    Base* ptr = new Derived();  // Polymorphic behaviour
    delete ptr;  // Only calls the Base destructor, causing a memory leak

    std::cout << "\n";

    // Pitfall 2: Method hiding
    Child child;
    child.display(5, 10);  // Calls Child::display(int, int)
    // child.display(5);   // Error: Parent::display(int) is hidden

    return 0;
}
Base constructor
Derived constructor
Base destructor

Child: 5, 10

Explanation:

  • Pitfall 1: Missing virtual destructor
    • The destructor of the base class Base is not virtual, which causes undefined behaviour when a derived class object is deleted through a base class pointer.
    • In this example, deleting ptr (of type Base*) only calls the destructor of Base, leaving the dynamically allocated data in Derived undeleted, causing a memory leak.
    • To fix this, declare the destructor of Base as virtual.
  • Pitfall 2: Hiding base class methods
    • The Child class defines a method display(int, int), which hides the display(int) method in Parent.
    • As a result, calling child.display(5) results in a compilation error because the display(int) method is no longer visible.
    • To fix this, explicitly bring the base class method into scope in the derived class using using Parent::display;.
Fixed Pitfall #2
#include <iostream>


// Fixing Pitfall 2: Using `using` to avoid method hiding
class Parent {
public:
    void display(int x) {
        std::cout << "Parent: " << x << std::endl;
    }
};

class Child : public Parent {
public:
    using Parent::display;  // Bring Parent::display(int) into Child's scope

    void display(int x, int y) {
        std::cout << "Child: " << x << ", " << y << std::endl;
    }
};

int main() {
    // Fix for Pitfall 2: Access both methods in Child
    Child child;
    child.display(5, 10);  // Calls Child::display(int, int)
    child.display(5);      // Now correctly calls Parent::display(int)

    return 0;
}
Base constructor
Derived constructor
Base destructor

Child: 5, 10

Fixed Pitfall 2:

  • Used using Parent::display; to bring the Parent::display(int) method into the Child class’s scope.
  • This ensures that both Parent::display(int) and Child::display(int, int) are accessible without conflicts.

Practical Applications

Let’s look at a real-world example of using inheritance to build a flexible logging system:

Logging System Example
#include <iostream>
#include <fstream>
#include <string>
#include <memory>
#include <vector>

// Base Logger class
// Provides a common interface for different types of loggers
class Logger {
public:
    virtual ~Logger() = default; // Virtual destructor for proper cleanup
    virtual void log(const std::string& message) = 0; // Pure virtual function for logging
};

// Console Logger: Logs messages to the console
class ConsoleLogger : public Logger {
public:
    // Overrides the log method to print messages to the console
    void log(const std::string& message) override {
        std::cout << "[Console] " << message << std::endl;
    }
};

// File Logger: Logs messages to a file
class FileLogger : public Logger {
private:
    std::ofstream outFile; // File stream for writing logs

public:
    // Constructor to initialize and open the log file
    FileLogger(const std::string& fileName) {
        outFile.open(fileName, std::ios::app);
        if (!outFile.is_open()) {
            throw std::ios_base::failure("Failed to open log file");
        }
    }

    // Destructor ensures the file stream is properly closed
    ~FileLogger() {
        if (outFile.is_open()) {
            outFile.close();
        }
    }

    // Overrides the log method to write messages to the file
    void log(const std::string& message) override {
        outFile << "[File] " << message << std::endl;
    }
};

// Aggregated Logger: Combines multiple loggers
// Allows logging messages to multiple destinations simultaneously
class AggregatedLogger : public Logger {
private:
    std::vector<std::unique_ptr<Logger>> loggers; // Collection of loggers

public:
    // Adds a new logger to the collection
    void addLogger(std::unique_ptr<Logger> logger) {
        loggers.push_back(std::move(logger));
    }

    // Overrides the log method to forward messages to all loggers
    void log(const std::string& message) override {
        for (const auto& logger : loggers) {
            logger->log(message);
        }
    }
};

int main() {
    try {
        // Create a console logger
        auto consoleLogger = std::make_unique<ConsoleLogger>();

        // Create a file logger with "log.txt" as the log file
        auto fileLogger = std::make_unique<FileLogger>("log.txt");

        // Create an aggregated logger to combine console and file logging
        AggregatedLogger aggregatedLogger;

        // Add the console and file loggers to the aggregated logger
        aggregatedLogger.addLogger(std::move(consoleLogger));
        aggregatedLogger.addLogger(std::move(fileLogger));

        // Log messages using the aggregated logger
        aggregatedLogger.log("Application started");
        aggregatedLogger.log("Performing some tasks...");
        aggregatedLogger.log("Application ended");

        std::cout << "Logs written to console and file." << std::endl;
    } catch (const std::exception& e) {
        // Handle any exceptions during logging
        std::cerr << "Error: " << e.what() << std::endl;
    }

    return 0;
}
[Console] Application started
[Console] Performing some tasks…
[Console] Application ended
Logs written to console and file:

File “log.txt” will also contain:
[File] Application started
[File] Performing some tasks…
[File] Application ended

Explanation:

  • The Logger Base Class:
    • Defines the common interface for all types of loggers with the pure virtual method log(const std::string& message).
    • Allows different logging mechanisms (e.g., console, file) to be implemented without changing the client code.
  • The ConsoleLogger Class:
    • Implements the log method to print messages to the console using std::cout.
    • Logs messages prefixed with [Console].
  • The FileLogger Class:
    • Implements the log method to write messages to a file.
    • Handles file management with an std::ofstream object.
    • Throws an exception if the file cannot be opened, ensuring robust error handling.
  • The AggregatedLogger Class:
    • Combines multiple loggers into one by maintaining a collection of Logger objects using std::vector.
    • Calls the log method on each contained logger, allowing messages to be logged to multiple destinations (e.g., console and file).

Why It Matters: This logging system demonstrates the power of inheritance and polymorphism for creating modular, scalable designs. It allows the addition of new loggers (e.g., a network logger) without modifying existing code. Such a design is invaluable in real-world applications requiring flexibility, such as server-side logging or distributed systems.

💡 Real-world Application: This pattern is commonly used in software systems that require flexible and scalable logging mechanisms, such as server-side applications, CLI tools, and large-scale distributed systems.

Conclusion

Inheritance is a key feature of C++ that enables code reuse, reduces redundancy, and supports polymorphism. When used thoughtfully, it leads to maintainable and extensible designs. However, misuse can result in unnecessary complexity, so consider alternatives like composition when an “is-a” relationship isn’t clear.

By following best practices, such as using virtual destructors and avoiding deep hierarchies, inheritance can be a powerful tool for building flexible and adaptable systems.

Further Reading

  • Online C++ Compiler

    Dive deeper into the concepts from this blog post with our free online C++ compiler. Experiment with the examples discussed here, such as inheritance, virtual functions, and polymorphism. Modify and test the code snippets directly in your browser, exploring single and multiple inheritance, or creating and extending logging systems. This interactive practice will reinforce your understanding of C++ inheritance and its practical applications.

  • C++ Virtual Functions FAQ

    Comprehensive guide to virtual functions and polymorphism in C++.

  • Bjarne Stroustrup’s FAQ on Multiple Inheritance

    The creator of C++ explains multiple inheritance and its use cases.

  • Liskov Substitution Principle

    Deep dive into one of the fundamental principles of object-oriented design.

Attribution and Citation

If you found this guide and tools helpful, feel free to link back to this page or cite it in your work!

Profile Picture
Senior Advisor, Data Science | [email protected] | + posts

Suf is a senior advisor in data science with deep expertise in Natural Language Processing, Complex Networks, and Anomaly Detection. Formerly a postdoctoral research fellow, he applied advanced physics techniques to tackle real-world, data-heavy industry challenges. Before that, he was a particle physicist at the ATLAS Experiment of the Large Hadron Collider. Now, he’s focused on bringing more fun and curiosity to the world of science and research online.

Buy Me a Coffee ✨