Inheritance

A motivating example

A banking system

Suppose we are designing a banking system that transfers money between accounts, and auditing the transaction is very important.

class ConsoleLogger {
    void log(long from, long to, double amount) {
        fmt::print("Transferred {} from {} to {}\n", amount, from, to);
    }
};

class Bank {
    ConsoleLogger logger;
    void transfer(long from, long to, double amount) {
        // transfer operations are omitted
        logger.log(from, to, amount);
    }
};

int main() {
    Bank bank;
    bank.transfer(1000, 2000, 100); // Transferred 100 from 1000 to 2000
}

Has-a relationship (composition)

In the previous example, the Bank class has a ConsoleLogger object.

  • The Bank class uses the ConsoleLogger to log the transaction.

  • The Bank and ConsoleLogger classes have their own responsibilities:

    • Bank class deals with the details of the transaction.
    • ConsoleLogger deals with the details of logging.
  • If we change the logic of transaction or logging, we only need to change the corresponding class, without changing the other class.

The problem

Now suppose the boss wants to add more features:

  • Log the transaction to a file.
  • Log the transaction to a database.
  • Send an email to the customer when a transaction is made.

How would you modify the Bank class to support these new features?

The naive solution

We keep adding logger classes, and define an enum to switch between different loggers.

class FileLogger {
  public:
    void log(long from, long to, double amount) {
        std::ofstream file("transactions.log", std::ios::app);
        if (file.is_open()) {
            file << "Transferred " << amount << " from " << from << " to " << to
                 << std::endl;
            file.close();
            std::cout << "Transferred " << amount << " from " << from << " to "
                      << to << " to file" << std::endl;
        } else {
            std::cerr << "Unable to open file for logging" << std::endl;
        }
    }
};

class ConsoleLogger {
  public:
    void log(long from, long to, double amount) {
        fmt::print("Transferred {} from {} to {} to console\n", amount, from,
                   to);
    }
};

enum LoggerType { Console, File };

class Bank {
    LoggerType logger_type;
    ConsoleLogger console_logger;
    FileLogger file_logger;

  public:
    Bank(LoggerType logger_type)
        : logger_type(logger_type), console_logger(), file_logger() {}
    void set_logger_type(LoggerType logger_type) {
        this->logger_type = logger_type;
    }
    void transfer(long from, long to, double amount) {
        // transfer operations are omitted
        switch (logger_type) {
        case LoggerType::Console:
            console_logger.log(from, to, amount);
            break;
        case LoggerType::File:
            file_logger.log(from, to, amount);
            break;
        }
    }
};

The Bank class is now coupled with the ConsoleLogger and FileLogger classes.

  • If we want to add more loggers, we need to modify the Bank class.
  • This violates the Open/Closed Principle: A class should be open for extension but closed for modification.
    • As more loggers are added, the switch statement in the transfer method will become more and more complex.
    • More and more loggers must be added to the Bank class.

Inheritance

Derived class

  • A derived class is a class that inherits from a base class.
  • The derived class contains all the members of the base class.
  • The derived class can add more members to itself.
class Base {
    int x;
};

class Derived : public Base {
    int y;
};

Base b;
Derived d;

b.x = 3; // OK
b.y = 4; // Error
d.x = 1; // OK
d.y = 2; // OK

public, protected, private inheritance

We first introduce a new access specifier: protected.

  • A protected member is accessible to the derived class, but not to the outside world.

The following table shows how the access level of a member of the base class changes when the inheritance is public, protected, or private.

Base Class Member Public Inheritance Protected Inheritance Private Inheritance
Public Member Public in derived class Protected in derived class Private in derived class
Protected Member Protected in derived class Protected in derived class Private in derived class
Private Member Inaccessible Inaccessible Inaccessible
class Base {
  private:
    int c;

  protected:
    int n;
    //   private:
  public:
    void foo1(Base &b) {
        n++;   // Okay
        b.n++; // Okay
        c++;
        // return c;
    }
};

class Derived : public Base {
  public:
    void foo2(Base &b, Derived &d) {
        n++;       // Okay
        this->n++; // Okay
        // b.n++;      //Error. You cannot access a protected member through
        d.n++; // Okay
    }
};

Is-a relationship

Inheritance is a mechanism to express an is-a relationship.

  • A student is a person.
  • A cat is an animal.
  • A circle is a shape.
class Person {
    std::string name;
    int age;
};

class Student : public Person {
    int student_id;
};
  • A Student is a Person.
  • A Student has all the members of a Person, plus its own student_id.

Constructors

To initialize a derived class,

  • Allocate memory
  • The order of initialization:
    • Base class constructor is invoked
    • Derived class constructor is invoked
    • Member initializer list and constructor body are executed
class Base {
public:
    Base(int x) {
        std::cout << "Base constructor" << std::endl;
    }
};

class Derived : public Base {
public:
    int y;
    Derived(int x, int y) : Base(x), y(y) {
        std::cout << "Derived constructor" << std::endl;
    }
};

Derived d(1, 2);
  • The Derived constructor first invokes the Base constructor.
  • Then it initializes the y member.

Destructors

  • The order of destruction:
    • Derived class destructor is invoked
    • Base class destructor is invoked

Diagram

Inheritance
  • Derived class contains all the members of the base class.
  • Constructing the derived class object from the inner side to the outer side.
  • Destroying the derived class object from the outer side to the inner side.

Virtual functions

Why virtual functions?

Let’s look at a motivating example.

class Person {
    std::string name;
public:
    void print() {
        std::cout << "Person: " << name << std::endl;
    }
};

class Student : public Person {
    std::string id;
public:
    void print() {
        std::cout << "Name: " << name;
        std::cout << ". ID: " << id << std::endl;
    }
};

void printObjectInfo(Person &p) { p.print(); }

Student stu("Jerry", "2019");
printObjectInfo(stu);  // will student id be printed?

Because of the is-a relationship, a Student object can be treated as a Person object.

Student stu("Jerry", 20);
Person p = stu; // OK

Student stu("Jerry", "2019");
printObjectInfo(stu); // Ok, Student is a Person
  • A Person object can’t be treated as a Student object.
  • When a function exists in both the base class and the derived class, use virtual to avoid the name hiding problem.

In previous lectures, functions are statically bound to the object.

  • The function to be called is determined at compile time.
  • The function is called based on the type of the object (its static type).

In this example, the function print is called based on the static type of the object, which is Person.

Virtual functions

By adding the virtual keyword, we tell the compiler to perform dynamic binding on the function call.

  • The function to be called is determined at runtime.
  • The function is called based on the actual type of the object (its dynamic type).
    • A Student object is passed to printObjectInfo, but the print function of the Student class is called.

Keyword virtual makes the function virtual for the base and all the derived classes.

class Person {
    std::string name;
public:
    virtual void print() {
        std::cout << "Person: " << name << std::endl;
    }
};

class Student : public Person {
    std::string id;
public:
    void print() override {
        std::cout << "Name: " << name;
        std::cout << ". ID: " << id << std::endl;
    }
};

void printObjectInfo(Person &p) { p.print(); }

Student stu("Jerry", "2019");
printObjectInfo(stu);  // will student id be printed?
  • Always add override to the derived class function if it is intended to override a virtual function.

Pure virtual functions

  • A pure virtual function is a virtual function that has no definition, uses = 0 to specify.
  • A class that has a pure virtual function is an abstract class (interface).
    • An abstract class cannot be instantiated.
    • It’s used to define an interface and a set of derived classes implement the interface.
class Animal {
public:
    virtual void make_sound() = 0;
    virtual ~Animal() = default;
};

class Cat : public Animal {
public:
    void make_sound() override {
        std::cout << "Meow" << std::endl;
    }
};

Animal *a = new Cat(); // Error, Animal is an abstract class
Animal *b = new Cat(); // OK
Cat c; // OK
delete a;

Virtual destructor

  • If a class has virtual functions, it should have a virtual destructor.
  • A virtual destructor ensures that the destructor of the derived class is called when a derived class object is destroyed.
Person *p = new Student("Jerry", "2019");
p->print();
delete p; // if its destructor is not virtual, only the destructor of the base class is called

Polymorphism

Polymorphic code is code you write once and can reuse with different types.

  • Runtime polymorphism is a form of polymorphism that occurs during program execution, this is achieved by virtual functions.
  • Runtime polymorphism requires the use of pointers or references to the base class. 1
    • A derived class object can be treated as its base class object.
    • The actual type of the object is determined at runtime.

Solve the motivating example

Now we’re able to design a more flexible logging system. By using inheritance, we can design a new logger interface and implement it in the derived classes.

struct Logger {
    virtual void log(long from, long to, double amount) = 0;
    virtual ~Logger() = default;
};


struct ConsoleLogger : public Logger {
    void log(long from, long to, double amount) override {
        std::cout << "Transferred " << amount << " from " << from << " to " << to << std::endl;
    }
};

struct FileLogger : public Logger {
    void log(long from, long to, double amount) override {
        std::ofstream file("transactions.log", std::ios::app);
        file << "Transferred " << amount << " from " << from << " to " << to << std::endl;
    }
};

Constructor injection

class Bank {
private:
    Logger &logger;
public:
    Bank(Logger &logger) : logger(logger) {}
    void transfer(long from, long to, double amount) {
        // transfer operations are omitted
        logger.log(from, to, amount);
    }
};

// main.cpp
ConsoleLogger console_logger;
Bank bank(console_logger);
bank.transfer(1000, 2000, 100);
  • The reference can’t be reseated, therefore the object that logger points to doesn’t change during the lifetime of the Bank object.
  • To change the logger choice, we have to create a new Bank object.

Property injection

class Bank {
private:
    std::unique_ptr<Logger> logger;
public:
    Bank(std::unique_ptr<Logger> logger) : logger(std::move(logger)) {}
    void set_logger(std::unique_ptr<Logger> logger) {
        this->logger = std::move(logger);
    }
    void transfer(long from, long to, double amount) {
        if (logger) {
            logger->log(from, to, amount);
        }
    }
};

// main.cpp
ConsoleLogger console_logger;
Bank bank(std::make_unique<ConsoleLogger>());
bank.transfer(1000, 2000, 100);

If you want to change the underlying logger, you choose the property injection method.

  • Compared to constructor injection, property injection is more flexible.
    • You can change the underlying logger at runtime.
  • It also has drawbacks that the use of pointers makes the code more complex.
    • You need to manage the lifetime of the Logger object yourself.
    • You need to check if the Logger object is nullptr before using it.

Footnotes

  1. Recall the void printObjectInfo(Person &p) { p.print(); }, the Person object is passed by reference.↩︎