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.
// Base class. classAnimal { 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. classCatBase : public Animal { public: ~CatBase() override = default;
intmain() { // 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;
$ 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.
// Base class. classAnimal { 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. classCatBase : public Animal { public: ~CatBase() override = default; // No need to override `clone`.
intmain() { 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.