C++ Class Hierarchy Design

Introduction

In C++ library design and development, sometimes it is unclear what kind of good class hierarchy to design in the first place so that later as the library becomes more mature it does not have to take the risk of changing interface significantly.

In this blog post, I would like to discuss the best practice for class hierarchy design.

C++ Class Hierarchy Design Guideline

The best practice for C++ class hierarchy design, according to Jon Kalb and Scott Meyers, is that “Classes should be used as bases or concrete (leaf) classes, not both.”

To make a class base, we make it an abstract class by providing it at least one pure virtual function (a derived class that does not override every pure virtual function is also an abstract class) and/or make its constructors protected.

To make a class leaf, we declare it as final so that it cannot be further derived and override each pure virtual function so that it can be instantiated.

In addition, it is very common to have base class pointers pointing to derived classes moving around. Trying to dereference a base class pointer and do copy or move assignment is extremely error-prone. We make the copy and move assignment operators protected for the base classes and public for the leaf classes.

Examples

Animals

We created the following dummy example for the guideline. In this example, we have four classes Animal, CatBase, Lion and Tiger. Animal is an abstract base class and it is not intended to be instantiated. CatBase, derived from Animal, is also supposed to be a base class. It is not an abstract class because we have override the pure virtual destructor. However, we still cannot instantiate a CatBase object because the constructors are protected. Lion and Tiger are supposed to be leaf classes and we declared them as final.

animal.cpp
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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
#include <utility>

// Base class.
class Animal
{
public:
// Pure virtual function.
// Enforce all the derived class to implement the destructor.
// A class is abstract if it has at least one pure virtual function.
virtual ~Animal() = 0;

protected:
// Prevent creating Animal base class instance.
Animal() = default;
Animal(Animal const&) = default;
Animal(Animal&&) = default;

// Prevent assigning Animal base class instance.
Animal& operator=(Animal const&) = default;
// Prevent moving Animal base class instance.
Animal& operator=(Animal&&) = default;
};

// There has to be an implementation for base class destructor.
inline Animal::~Animal() = default;

// Base class.
class CatBase : public Animal
{
public:
~CatBase() override = default;

protected:
CatBase() = default;
CatBase(CatBase const&) = default;
CatBase(CatBase&&) = default;

CatBase& operator=(CatBase const&) = default;
CatBase& operator=(CatBase&&) = default;
};

// Leaf class.
// Cannot be further derived.
class Lion final : public CatBase
{
public:
~Lion() override = default;

Lion() = default;
Lion(Lion const&) = default;
Lion(Lion&&) = default;

Lion& operator=(Lion const&) = default;
Lion& operator=(Lion&&) = default;
};

// Leaf class.
// Cannot be further derived.
class Tiger final : public CatBase
{
public:
~Tiger() override = default;

Tiger() = default;
Tiger(Tiger const&) = default;
Tiger(Tiger&&) = default;

Tiger& operator=(Tiger const&) = default;
Tiger& operator=(Tiger&&) = default;
};

int main()
{
// Animal and CatBase are only supposed to be derived.
// Creating them will result in compile-time error.
// Animal animal{};
// CatBase cat_base{};

Lion lion{};
Lion another_lion{};
Animal* p_lion{new Lion{}};
Animal* p_another_lion{new Lion{}};
Animal* p_tiger{new Tiger{}};
// Base class instance assignment is prohibited.
// *p_lion = *p_tiger;
// *p_lion = *p_another_lion;

lion = another_lion;
lion = std::move(another_lion);

delete p_lion;
delete p_another_lion;
delete p_tiger;
}

The application runs fine as expected.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ g++ animal.cpp -o animal -std=c++11
$ valgrind --leak-check=full ./animal
==498742== Memcheck, a memory error detector
==498742== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==498742== Using Valgrind-3.15.0 and LibVEX; rerun with -h for copyright info
==498742== Command: ./animal
==498742==
==498742==
==498742== HEAP SUMMARY:
==498742== in use at exit: 0 bytes in 0 blocks
==498742== total heap usage: 4 allocs, 4 frees, 72,728 bytes allocated
==498742==
==498742== All heap blocks were freed -- no leaks are possible
==498742==
==498742== For lists of detected and suppressed errors, rerun with: -s
==498742== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)

Animals with Clones

In some scenarios, some (proprietary) libraries will only provide the header for base abstracted classes. In terms of instantiating a concrete class object, the library will provide instantiation functions that returns a pointer to base abstracted classes. So everything is handled by base class pointers and it is impossible to know what kind of concrete class it belongs to.

Creating a concrete copy of a concrete class object is also impossible, unless the concrete class has a clone method.

animal.cpp
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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
#include <utility>

// Base class.
class Animal
{
public:
// Pure virtual function.
// Enforce all the derived class to implement the destructor.
// A class is abstract if it has at least one pure virtual function.
virtual ~Animal() = 0;
virtual Animal* clone() const = 0;

protected:
// Prevent creating Animal base class instance.
Animal() = default;
Animal(Animal const&) = default;
Animal(Animal&&) = default;

// Prevent assigning Animal base class instance.
Animal& operator=(Animal const&) = default;
// Prevent moving Animal base class instance.
Animal& operator=(Animal&&) = default;
};

// There has to be an implementation for base class destructor.
inline Animal::~Animal() = default;

// Base class.
class CatBase : public Animal
{
public:
~CatBase() override = default;
// No need to override `clone`.

protected:
CatBase() = default;
CatBase(CatBase const&) = default;
CatBase(CatBase&&) = default;

CatBase& operator=(CatBase const&) = default;
CatBase& operator=(CatBase&&) = default;
};

// Leaf class.
// Cannot be further derived.
class Lion final : public CatBase
{
public:
~Lion() override = default;
Lion* clone() const override { return new Lion(*this); }

Lion() = default;
Lion(Lion const&) = default;
Lion(Lion&&) = default;

Lion& operator=(Lion const&) = default;
Lion& operator=(Lion&&) = default;
};

// Leaf class.
// Cannot be further derived.
class Tiger final : public CatBase
{
public:
~Tiger() override = default;
Tiger* clone() const override { return new Tiger(*this); }

Tiger() = default;
Tiger(Tiger const&) = default;
Tiger(Tiger&&) = default;

Tiger& operator=(Tiger const&) = default;
Tiger& operator=(Tiger&&) = default;
};

int main()
{
Animal* p_tiger{new Tiger{}};
// Making copy is allowed.
Animal* p_cloned_tiger{p_tiger->clone()};

delete p_tiger;
delete p_cloned_tiger;
}

The application runs fine as expected.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ g++ animal.cpp -o animal -std=c++11
$ valgrind --leak-check=full ./animal
==499925== Memcheck, a memory error detector
==499925== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==499925== Using Valgrind-3.15.0 and LibVEX; rerun with -h for copyright info
==499925== Command: ./animal
==499925==
==499925==
==499925== HEAP SUMMARY:
==499925== in use at exit: 0 bytes in 0 blocks
==499925== total heap usage: 3 allocs, 3 frees, 72,720 bytes allocated
==499925==
==499925== All heap blocks were freed -- no leaks are possible
==499925==
==499925== For lists of detected and suppressed errors, rerun with: -s
==499925== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)

Conclusions

Classes should be used as bases or concrete (leaf) classes, not both.

References

Author

Lei Mao

Posted on

02-07-2022

Updated on

02-07-2022

Licensed under


Comments