CS100 Lecture 22
Inheritance and Polymorphism II
Contents
- Abstract base class
- More on the "is-a" relationship (Effective C++ Item 32)
- Inheritance of interface vs inheritance of implementation (Effective C++ Item 34)
Abstract base class
Shapes
Define different shapes: Rectangle, Triangle, Circle, ...
Suppose we want to draw things like this:
void drawThings(ScreenHandle &screen,
const std::vector<std::shared_ptr<Shape>> &shapes) {
for (const auto &shape : shapes)
shape->draw(screen);
}
and print information:
void printShapeInfo(const Shape &shape) {
std::cout << "Area: " << shape.area()
<< "Perimeter: " << shape.perimeter() << std::endl;
}
Shapes
Define a base class Shape
and let other shapes inherit it.
class Shape {
public:
Shape() = default;
virtual void draw(ScreenHandle &screen) const;
virtual double area() const;
virtual double perimeter() const;
virtual ~Shape() = default;
};
Different shapes should define their own draw
, area
and perimeter
, so these functions should be virtual
.
Shapes
class Rectangle : public Shape {
Point2d mTopLeft, mBottomRight;
public:
Rectangle(const Point2d &tl, const Point2d &br)
: mTopLeft(tl), mBottomRight(br) {} // Base is default-initialized
void draw(ScreenHandle &screen) const override { /* ... */ }
double area() const override {
return (mBottomRight.x - mTopLeft.x) * (mBottomRight.y - mTopLeft.y);
}
double perimeter() const override {
return 2 * (mBottomRight.x - mTopLeft.x + mBottomRight.y - mTopLeft.y);
}
};
Shapes
Pure virtual
functions
How should we define Shape::draw
, Shape::area
and Shape::perimeter
?
- For the general concept "Shape", there is no way to determine the behaviors of these functions.
Pure virtual
functions
How should we define Shape::draw
, Shape::area
and Shape::perimeter
?
- For the general concept "Shape", there is no way to determine the behaviors of these functions.
- Direct call to
Shape::draw
,Shape::area
andShape::perimeter
should be forbidden. - We shouldn't even allow an object of type
Shape
to be instantiated! The classShape
is only used to define the concept "Shape" and required interfaces.
Pure virtual
functions
If a virtual
function does not have a reasonable definition in the base class, it should be declared as pure virtual
by writing =0
.
class Shape {
public:
virtual void draw(ScreenHandle &) const = 0;
virtual double area() const = 0;
virtual double perimeter() const = 0;
virtual ~Shape() = default;
};
Any class that has a pure virtual
function is an abstract class. Pure virtual
functions (usually) cannot be called \({}^{\textcolor{red}{1}}\), and abstract classes cannot be instantiated.
Pure virtual
functions and abstract classes
Any class that has a pure virtual
function is an abstract class. Pure virtual
functions (usually) cannot be called \({}^{\textcolor{red}{1}}\), and abstract classes cannot be instantiated.
Shape shape; // Error.
Shape *p = new Shape; // Error.
auto sp = std::make_shared<Shape>(); // Error.
std::shared_ptr<Shape> sp2 = std::make_shared<Rectangle>(p1, p2); // OK.
We can define pointer or reference to an abstract class, but never an object of that type!
Pure virtual
functions and abstract classes
An impure virtual
function must be defined. Otherwise, the compiler will fail to generate necessary runtime information (the virtual table), which leads to an error.
class X {
virtual void foo(); // Declaration, without a definition
// Even if `foo` is not used, this will lead to an error.
};
Linkage error:
/usr/bin/ld: /tmp/ccV9TNfM.o: in function `main':
a.cpp:(.text+0x1e): undefined reference to `vtable for X'
Make the interface robust, not error-prone.
Is this good?
class Shape {
public:
virtual double area() const {
return 0;
}
};
What about this?
class Shape {
public:
virtual double area() const {
throw std::logic_error{"area() called on Shape!"};
}
};
Make the interface robust, not error-prone.
class Shape {
public:
virtual double area() const {
return 0;
}
};
If Shape::area
is called accidentally, the error will happen silently!
Make the interface robust, not error-prone.
class Shape {
public:
virtual double area() const {
throw std::logic_error{"area() called on Shape!"};
}
};
If Shape::area
is called accidentally, an exception will be raised.
However, a good design should make errors fail to compile.
[Best practice] If an error can be caught in compile-time, don't leave it until run-time.
Polymorphism (多态)
Polymorphism: The provision of a single interface to entities of different types, or the use of a single symbol to represent multiple different types.
- Run-time polymorphism: Achieved via dynamic binding.
- Compile-time polymorphism: Achieved via function overloading, templates, concepts (since C++20), etc.
struct Shape {
virtual void draw() const = 0;
};
void drawStuff(const Shape &s) {
s.draw();
}
template <typename T>
concept Shape = requires(const T x) {
x.draw();
};
void drawStuff(Shape const auto &s) {
s.draw();
}
More on the "is-a" relationship
Effective C++ Item 32
Public inheritance: The "is-a" relationship
By writing that class D
publicly inherits from class B
, you are telling the compiler (as well as human readers of your code) that
- Every object of type
D
is also an object of typeB
, but not vice versa. B
represents a more general concept thanD
, and thatD
represents a more specialized concept thanB
.
More specifically, you are asserting that anywhere an object of type B
can be used, an object of type D
can be used just as well.
- On the other hand, if you need an object of type D
, an object of type B
won't do.
Example: Every student is a person.
class Person { /* ... */ };
class Student : public Person { /* ... */ };
- Every student is a person, but not every person is a student.
-
Anything that is true of a person is also true of a student:
-
A person has a date of birth, so does a student.
-
Something is true of a student, but not true of people in general.
-
A student is entrolled in a particular school, but a person may not.
The notion of a person is more general than is that of a student; a student is a specialized type of person.
Example: Every student is a person.
The is-a relationship: Anywhere an object of type Person
can be used, an object of type Student
can be used just as well, but not vice versa.
void eat(const Person &p); // Anyone can eat.
void study(const Student &s); // Only students study.
Person p;
Student s;
eat(p); // Fine. `p` is a person.
eat(s); // Fine. `s` is a student, and a student is a person.
study(s); // Fine.
study(p); // Error! `p` isn't a student.
Your intuition can mislead you.
- A penguin is a bird.
- A bird can fly.
If we naively try to express this in C++, our effort yields:
class Bird {
public:
virtual void fly(); // Birds can fly.
// ...
};
class Penguin : public Bird { // A penguin is a bird.
// ...
};
Penguin p;
p.fly(); // Oh no!! Penguins cannot fly, but this code compiles!
No. Not every bird can fly.
In general, birds have the ability to fly.
- Strictly speaking, there are several types of non-flying birds.
Maybe the following hierarchy models the reality much better?
class Bird { /* ... */ };
class FlyingBird : public Bird {
virtual void fly();
};
class Penguin : public Bird { // Not FlyingBird
// ...
};
No. Not every bird can fly.
Maybe the following hierarchy models the reality much better?
class Bird { /* ... */ };
class FlyingBird : public Bird {
virtual void fly();
};
class Penguin : public Bird { // Not FlyingBird
// ...
};
- Not necessarily. If your application has much to do with beaks and wings, and nothing to do with flying, the original two-class hierarchy might be satisfactory.
- There is no one ideal design for every software. The best design depends on what the system is expected to do.
What about report a runtime error?
void report_error(const std::string &msg); // defined elsewhere
class Penguin : public Bird {
public:
virtual void fly() {
report_error("Attempt to make a penguin fly!");
}
};
What about report a runtime error?
void report_error(const std::string &msg); // defined elsewhere
class Penguin : public Bird {
public:
virtual void fly() { report_error("Attempt to make a penguin fly!"); }
};
No. This does not say "Penguins can't fly." This says "Penguins can fly, but it is an error for them to actually try to do it."
To actually express the constraint "Penguins can't fly", you should prevent the attempt from compiling.
Penguin p;
p.fly(); // This should not compile.
[Best practice] Good interfaces prevent invalid code from compiling.
Example: A square is a rectangle.
Should class Square
publicly inherit from class Rectangle
?
Example: A square is a rectangle.
Consider this code.
class Rectangle {
public:
virtual void setHeight(int newHeight);
virtual void setWidth(int newWidth);
virtual int getHeight() const;
virtual int getWidth() const;
// ...
};
void makeBigger(Rectangle &r) {
r.setWidth(r.getWidth() + 10);
}
class Square : public Rectangle {
// A square is a rectangle,
// where height == width.
// ...
};
Square s(10); // A 10x10 square.
makeBigger(s); // Oh no!
Is this really an "is-a" relationship?
We said before that the "is-a" relationship means that anywhere an object of type B
can be used, an object of type D
can be used just as well.
However, something applicable to a rectangle is not applicable to a square!
Conclusion: Public inheritance means "is-a". Everything that applies to base classes must also apply to derived classes, because every derived class object is a base class object.
Inheritance of interface vs inheritance of implementation
Effective C++ Item 34
Example: Airplanes for XYZ Airlines.
Suppose XYZ has only two kinds of planes: the Model A and the Model B, and both are flown in exactly the same way.
class Airplane {
public:
virtual void fly(const Airport &destination) {
// Default code for flying an airplane to the given destination.
}
};
class ModelA : public Airplane { /* ... */ };
class ModelB : public Airplane { /* ... */ };
Airplane::fly
is declaredvirtual
because in principle, different airplanes should be flown in different ways.Airplane::fly
is defined, to avoid copy-and-pasting code in theModelA
andModelB
classes.
Example: Airplanes for XYZ Airlines.
Now suppose that XYZ decides to acquire a new type of airplane, the Model C, which is flown differently from the Model A and the Model B.
XYZ's programmers add the class ModelC
to the hierarchy, but forget to redefine the fly
function!
class ModelC : public Airplane {
// `fly` is not overridden.
// ...
};
This surely leads to a disaster:
auto pc = std::make_unique<ModelC>();
pc->fly(PVG); // No! Attempts to fly Model C in the Model A/B way!
Impure virtual function: Interface + default implementation
The problem here is not that Airplane::fly
has default behavior, but that ModelC
was allowed to inherit that behavior without explicitly saying that it wanted to.
* By defining an impure virtual function, we have the derived class inherit a function interface as well as a default implementation.
- Interface: Every class inheriting from
Airplane
canfly
. - Default implementation: If
ModelC
does not overrideAirplane::fly
, it will have the inherited implementation automatically.
Separate default implementation from interface
To sever the connection between the interface of the virtual function and its default implementation:
class Airplane {
public:
virtual void fly(const Airport &destination) = 0; // pure virtual
// ...
protected:
void defaultFly(const Airport &destination) {
// Default code for flying an airplane to the given destination.
}
};
- The pure virtual function
fly
provides the interface: Every derived class canfly
. - The default implementation is written in
defaultFly
.
Separate default implementation from interface
If ModelA
and ModelB
want to adopt the default way of flying, they simply make a call to defaultFly
.
class ModelA : public Airplane {
public:
virtual void fly(const Airport &destination) {
defaultFly(destination);
}
// ...
};
class ModelB : public Airplane {
public:
virtual void fly(const Airport &destination) {
defaultFly(destination);
}
// ...
};
Separate default implementation from interface
For ModelC
:
- Since
Airplane::fly
is pure virtual,ModelC
must define its own version offly
. - If it does want to use the default implementation, it must say it explicitly by making a call to
defaultFly
.
class ModelC : public Airplane {
public:
virtual void fly(const Airport &destination) {
// The "Model C way" of flying.
// Without the definition of this function, `ModelC` remains abstract,
// which does not compile if we create an object of such type.
}
};
Still not satisfactory?
Some people object to the idea of having separate functions for providing the interface and the default implementation, such as fly
and defaultFly
above.
-
For one thing, it pollutes the class namespace with closely related function names.
-
This really matters, especially in complicated projects. Extra mental effort might be required to distinguish the meaning of overly similar names.
Read the rest part of Effective C++ Item 34 for another solution to this problem.
Inheritance of interface vs inheritance of implementation
We have come to the conclusion that
- Pure virtual functions specify inheritance of interface only.
- Simple (impure) virtual functions specify inheritance of interface + a default implementation.
- The default implementation can be overridden.
Moreover, non-virtual functions specify inheritance of interface + a mandatory implementation.
Note: In public inheritance, interfaces are always inherited.
Summary
Pure virtual function and abstract class
- A pure virtual function is a virtual function declared
= 0
. - Call to a pure virtual function is not allowed. \({}^{\textcolor{red}{1}}\)
- Pure virtual functions define the interfaces and force the derived classes to override it.
- A class that has a pure virtual function is an abstract class.
- We cannot create an object of an abstract class type.
- Abstract classes are often used to represent abstract, general concepts.
Summary
Public inheritance models the "is-a" relationship.
- Everything that applies to base classes must also apply to derived classes.
- The "Birds can fly, and a penguin is a bird" example.
- The "A square is a rectangle" example.
Summary
Inheritance of interface vs inheritance of implementation
- In public inheritance, interfaces are always inherited.
- Pure virtual functions: inheritance of interface only.
- Simple (impure) virtual functions: inheritance of interface + a default implementation.
- The default implementation can be overridden.
- non-virtual functions: inheritance of interface + a mandatory implementation.
Notes
\({}^{\textcolor{red}{1}}\) A pure virtual function can have a definition. In that case, it can be called via the syntax ClassName::functionName(args)
, not via a virtual function call (dynamic binding).
In some cases, we want a class to be made abstract, but it does not have any pure virtual function. A possible workaround is to declare the destructor to be pure virtual, and then provide a definition for it:
struct Foo {
virtual ~Foo() = 0;
};
Foo::~Foo() = default; // Provide a definition outside the class.
The "another solution" mentioned in page 36 is also related to this.