01 - inheritance

You can create classes which contain objects of different types, possibly different classes. This is known as composition. You can also create classes which are an extension of existing classes. This is known as inheritance.

Example

Suppose a shop which sells car parts. I will showcase 2 ways to implement similar code, both will use this code fragment:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
struct dimentions
{
	int width_cm;
	int length_cm;
	int height_cm;
};

class product
{
public:
	product(int price, int warranty_months, dimentions dims)
	: price(price), warranty_months(warranty_months), dimentions(dims)
	{
		// some checks (e.g. negative values not accepted)
	}

	// some methods...

private:
	int price;
	int warranty_months;
	dimentions dims;
};

An example product written using composition:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class tyre
{
public:
	tyre(product prod, int diameter)
	: prod(prod), diameter(diameter) {}

	      product& get_product()       { return prod; }
	const product& get_product() const { return prod; }

private:
	product prod;
	int diameter;
	// some other tyre-specific data...
};

The same product written using inheritance:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class tyre: public product // public inheritance
{
public:
	tyre(int price, int warranty_months, dimentions dims, int diameter)
	: product(price, warranty_months, dims), diameter(diameter) {}
	// ^^^^^^ calling constructor of the base type
	// if it was skipped, product() would be assumed (default 0-argument constructor)

	tyre(product prod, int diameter)
	: product(prod), diameter(diameter) {}

private:
	int diameter;
	// some other tyre-specific data...
};

In the second example, the tyre class has not been defined as containing a product, but as an extensions of it.

It has the following effects:

  • tyre gets (inherits) all of product members. You can call product member functions on objects of type tyre.

  • Objects of type tyre are implicitly convertible to product.

  • When constructing tyre, in member init list, type name (product) is written instead of member name.

Code which uses tyre will be slightly different depending on the implementation:

1
2
3
4
5
6
7
8
9
10
11
int calculate_bargain_percent(const product& p);

void f(const tyre& t)
{
	// composition - obtain a member
	int bargain_percent = calculate_bargain_percent(t.get_product());

	// inheritance - tyre is implicitly convertible to product
	// this specific convertion is referred to as "upcast"
	int bargain_percent = calculate_bargain_percent(t);
}

Untill we move to more advanced features available through inheritance, you may have a feeling that both composition and inheritance can achieve the same thing, they just produce slightly different code... and you would be right.

Inheritance will start to make sense once you reach further topics described in this chapter, specifically virtual functions. If such features are not being used, more often than not writing inheritance-based code is a mistake. Practice has shown that composition results in simpler code that is easier to maintain and has less bugs, thus you should prefer composition when there is no need for inheritance-specific features.

Terminology

When speaking about inheritance:

  • the type that is used as a base is called base type

  • the type that inherits from base is called derived type

We can say that:

  • tyre is derived from product

  • product is a base of tyre

Other popular set of names is parent and child:

  • tyre is a child of product

  • product is a parent of tyre

A direct base is a class that is an immediate base type of the given class.

Another set of terms specifies how an object is seen:

  • A static type is a type of the object that is visible during compilation of the program.

  • A dynamic type is the real type of the object during runtime, which might be identical to the static type or be a type derived from it.

Inside the function calculate_bargain_percent which was called in the expression calculate_bargain_percent(t):

  • The static type of the argument is product - that's what the function sees.

  • The dynamic type of the argument is tyre - actual object given is of this type.

How it works? How can a function operate on an object which type is derived from the argument type? Strictly technically it's implementation-defined but to illustrate typical compiler implementation:

  • An object of base type occupies B bytes in memory.

  • An object of derived type occupies D bytes in memory (typically D > B but D == B if derived type has no extra non-static data members).

  • A function which references base type only operates on first B bytes of the object.

  • The remaining bytes (D - B bytes exactly) are not accessed by the function - it doesn't know if the object if of derived type. There might be actually multiple derived types (with sizes D1, D2, D3, ...) where after first B bytes, each has different data further in memory.

The implicit convertion mentioned earlier (upcast - the convertion from derived type to base type) doesn't actually do anything in the machine code (unless virtual inheritance is used) - it's purely a language abstraction. For any D1, D2, D3 bytes of derived types the fitst B bytes will be the same and represent the base type object.

Speaking about dynamic types only makes sense when objects are referred through pointers or references. Otherwise dynamic type can not be different from the static type.

Construction

Objects are initializated by calling constructors of base types first, then of members. Apart from initialization of members and delegating constructors, member initializer list can also call constructors of direct base classes (they have to be constructed somehow after all). If no initializers are given, default constructor is assumed.

This example doesn't use calls to base type constructors but the order of prints should let you understand in what order objects are being initialized:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#include <iostream>

class X
{
public:
	X() { std::cout << "X::X()\n"; }
};

class Y: public X
{
public:
	Y() { std::cout << "Y::Y()\n"; }
};

class Z: public Y
{
public:
	Z() { std::cout << "Z::Z()\n"; }
};

class A
{
public:
	A() { std::cout << "A::A()\n"; }

private:
	X x;
};

class B: public A
{
public:
	B() { std::cout << "B::B()\n"; }
};

class C: public B
{
public:
	C() { std::cout << "C::C()\n"; }

private:
	Z z;
};

int main()
{
	C c;
}

Output:

X::X()
A::A()
B::B()
X::X()
Y::Y()
Z::Z()
C::C()

protected

You already know that public sets no restrictions and private only gives the access to code within the class.

protected works like private but additionally grants access to derived classes.

Access when deriving

You very likely noticed that inheritance wasn't written as class tyre: product but as class tyre: public product. Actually, the first one is valid syntax too - it just uses default access specifiers. Just like for members with no explicitly specified access:

access in base

derived as public

derived as protected

derived as private

public

public

protected

private

protected

protected

protected

private

private

(no access)

(no access)

(no access)

Does it mean that inside tyre functions, there is no access to private members of product?

Yes. And that's a good thing. Derived types should not mess with their base type private data. A class is supposed to encapsulate specific behavior and member functions are supposed to preserve class invariants. Practice has shown that if derived types can modify base type data members there is a high chance of creating a bug by breaking invariants.

The open-closed principle states that classes should be open for extension, but closed for modification. Thus, the general recommendation is:

  • data members should be private

  • functions intended to be used everywhere should be public

  • functions intended to be used in current class and its derived classes should be protected

  • functions intended to be used only in other functions of the current class should be private

  • inheritance should use public access

If data has invariants (one of primary purposes of classes is to preserve invariants), private enforces that only the class that manages this data has access to it. If a derived class wants to modify data of its parents, it should do so through parent's public or protected functions.

It's worth mentioning that non-public inheritance is a C++-specific feature (at least I haven't seen another language that supported it). Other languages do not have the concept of changing access level in derived types, they do not have a syntax that supports access specifier in inheritance and always work as if the type was derived as public. Non-public inheritance is not typical OOP practice, it's a niche within C++ for some implementation tricks (many which are used in standard library implementations).

Classes which are derived in non-public way disallow convertions to their non-public bases for code outside the class. This effectively prevents any outside code in treating the type as if it was a base, making inheritance act as a hidden implementation detail, usually only for the purpose of code reuse inside the class. Any external code can only treat the class as if it had no such parent. Why composition is not used in such cases? It could be, but specific template patterns in C++ (e.g. policy-based design) are easier and more optimal (empty base optimization) to write this way.

Shapes example

A very popular inheritance example uses shapes, which looks like this:

  • triangle is a shape

  • quadrangle is a shape

  • rectangle is a quadrangle

  • square is a rectangle

Intuitively, shapes seem to be a great example of an is-a relationship. The problem is that this example suggests a class hierarchy which violates LSP (Liskov substitution principle). LSP states that if something can be done with one type, it should also be possible with its derived types. Derived types are supposed to be extensions, not limitations.

  • in mathematics, more derived types have stronger invariants (rectangle has 2 parallel sides of equal length, square has 4 sides of equal length)

  • in programming, more derived types are expected to:

    • not have stronger preconditions

    • not have weaker postconditions

Apart from violating LSP, there are representation problems too:

  • If rectangle is represented as 2 variables describing side lengths, a square would make no sense with 2 variables

  • If rectangle is represented as 4 points and functions which allow to modify them, a square would not work with these functions because it has stronger requirements.

So, should it be reverse? A rectangle inheriting square?

No. OOP is simply not feasible to be used for this problem. Another important detail is that the square-rectangle problem is very abstract - it encourages thinking in terms of mathematical logic instead of practical approaches like "which code is reused, which code is extended". In practice, code is used to implement specific behavior so first, you should look at the problem and find out any patterns/invariants within it before deciding on any particular abstraction. Inheritance makes no sense if LSP can not be satisfied. OOP is very popular, but it's not panacea and is not an appropriate solution to every abstraction problem.

  • If it's some tile-based game or simulation, there is no need to create different classes for different shapes - everything on the board/map can be treated as rectangle. Sometimes there may be squares, but if there is a need for any extra logic it will be implemented alongside rectangle-handling code, not as a derived class.

  • If it's some 2D/3D graphics rendering code, if there will be any hierarchy it will be that all concrete shapes inherit "base shape" class. Each shape will have its own specific function that implements particular rendering algorithm. The only extension would be that all shapes implement rendering.

The square-rectangle problem (AKA circle-ellipse) has also been described in on https://en.wikipedia.org/wiki/Circle-ellipse_problem.

Summary

This lesson is more philosophical than others in this chapter, similarly to the first lesson in the classes chapter. It doesn't present a lot of compilable code - it's more important that you understand the concept and the good practices that come with it. You can perfectly memorize all language rules and still write compiling, working code that will be just bad: hard to modify/maintain, unclear and bug-prone.

In the classes chapter, you learned about single responsibility principle - a class should represent exactly one thing (and take care to preserve its invariants). That's S in SOLID.

In this lesson, 2 additional principles were mentioned:

  • O - the open-closed principle

  • L - Liskov substitution principle (LSP)

The last 2 will be explained later. LSP will become even more significant once you read about virtual functions.

FAQ

Upcasts are allowed as implicit convertions. How about downcasts?

Downcasts must be explicit and there are semantic differences between static_cast and dynamic_cast. Explained in a later lesson in this chapter.

Can a class inherit from and contain a member of the same type?

Yes, but it doesn't make much sense - what would you do with 2 sets of the same functionality, each with slightly different syntax? You should do one of these or none if OO style is not appropriate for the given problem. Typically by looking at the definition of a class it can be said whether it's supposed to be contained (as a member) or inherited from - by default we can assume containment ("prefer composition over inheritance") and decide on inheritance by noticing inheritance-specific features such as virtual functions. Every class can be used as a member but only specific kinds of classes makes sense to inherit from.

Can a class inherit from another multiple times?

Yes, but not on the same inheritance level. It's not so rare but usually accidental, which creates the diamond problem. Explained in a later lesson in this chapter.