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) {
::print("Transferred {} from {} to {}\n", amount, from, to);
fmt}
};
class Bank {
;
ConsoleLogger loggervoid transfer(long from, long to, double amount) {
// transfer operations are omitted
.log(from, to, amount);
logger}
};
int main() {
;
Bank bank.transfer(1000, 2000, 100); // Transferred 100 from 1000 to 2000
bank}
Has-a relationship (composition)
In the previous example, the Bank
class has a ConsoleLogger
object.
The
Bank
class uses theConsoleLogger
to log the transaction.The
Bank
andConsoleLogger
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()) {
<< "Transferred " << amount << " from " << from << " to " << to
file << std::endl;
.close();
filestd::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) {
::print("Transferred {} from {} to {} to console\n", amount, from,
fmt);
to}
};
enum LoggerType { Console, File };
class Bank {
logger_type;
LoggerType ;
ConsoleLogger console_logger;
FileLogger file_logger
public:
(LoggerType logger_type)
Bank: 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:
.log(from, to, amount);
console_loggerbreak;
case LoggerType::File:
.log(from, to, amount);
file_loggerbreak;
}
}
};
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.
- 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
.x = 3; // OK
b.y = 4; // Error
b.x = 1; // OK
d.y = 2; // OK d
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) {
++; // Okay
n.n++; // Okay
b++;
c// return c;
}
};
class Derived : public Base {
public:
void foo2(Base &b, Derived &d) {
++; // Okay
nthis->n++; // Okay
// b.n++; //Error. You cannot access a protected member through
.n++; // Okay
d}
};
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 aPerson
. - A
Student
has 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:
(int x) {
Basestd::cout << "Base constructor" << std::endl;
}
};
class Derived : public Base {
public:
int y;
(int x, int y) : Base(x), y(y) {
Derivedstd::cout << "Derived constructor" << std::endl;
}
};
(1, 2); Derived d
- The
Derived
constructor first invokes theBase
constructor. - Then it initializes the
y
member.
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(); }
("Jerry", "2019");
Student stu(stu); // will student id be printed? printObjectInfo
Because of the is-a
relationship, a Student
object can be treated as a Person
object.
("Jerry", 20);
Student stu= stu; // OK
Person p
("Jerry", "2019");
Student stu(stu); // Ok, Student is a Person printObjectInfo
- A
Person
object can’t be treated as aStudent
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 toprintObjectInfo
, but theprint
function of theStudent
class 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(); }
("Jerry", "2019");
Student stu(stu); // will student id be printed? printObjectInfo
- 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;
}
};
*a = new Cat(); // Error, Animal is an abstract class
Animal *b = new Cat(); // OK
Animal ; // OK
Cat cdelete 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.
*p = new Student("Jerry", "2019");
Person ->print();
pdelete 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);
<< "Transferred " << amount << " from " << from << " to " << to << std::endl;
file }
};
Constructor injection
class Bank {
private:
&logger;
Logger public:
(Logger &logger) : logger(logger) {}
Bankvoid transfer(long from, long to, double amount) {
// transfer operations are omitted
.log(from, to, amount);
logger}
};
// main.cpp
;
ConsoleLogger console_logger(console_logger);
Bank bank.transfer(1000, 2000, 100); bank
- 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:
(std::unique_ptr<Logger> logger) : logger(std::move(logger)) {}
Bankvoid set_logger(std::unique_ptr<Logger> logger) {
this->logger = std::move(logger);
}
void transfer(long from, long to, double amount) {
if (logger) {
->log(from, to, amount);
logger}
}
};
// main.cpp
;
ConsoleLogger console_logger(std::make_unique<ConsoleLogger>());
Bank bank.transfer(1000, 2000, 100); bank
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 isnullptr
before using it.
- You need to manage the lifetime of the
Footnotes
Recall the
void printObjectInfo(Person &p) { p.print(); }
, thePerson
object is passed by reference.↩︎