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
Bankclass uses theConsoleLoggerto log the transaction.The
BankandConsoleLoggerclasses have their own responsibilities:Bankclass deals with the details of the transaction.ConsoleLoggerdeals 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
Bankclass. - 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
transfermethod will become more and more complex. - More and more loggers must be added to the
Bankclass.
- As more loggers are added, the switch statement in the
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; // OKpublic, protected, private inheritance
We first introduce a new access specifier: protected.
- A
protectedmember 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
Studentis aPerson. - A
Studenthas all the members of aPerson, plus its ownstudent_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
Derivedconstructor first invokes theBaseconstructor. - Then it initializes the
ymember.
Destructors
- The order of destruction:
- Derived class destructor is invoked
- Base class destructor is invoked
Diagram
- 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
Personobject can’t be treated as aStudentobject. - When a function exists in both the base class and the derived class, use
virtualto 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
Studentobject is passed toprintObjectInfo, but theprintfunction of theStudentclass is called.
- A
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
overrideto 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
= 0to 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 calledPolymorphism
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
Bankobject. - To change the logger choice, we have to create a new
Bankobject.
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
Loggerobject yourself. - You need to check if the
Loggerobject isnullptrbefore using it.
- You need to manage the lifetime of the
Footnotes
Recall the
void printObjectInfo(Person &p) { p.print(); }, thePersonobject is passed by reference.↩︎