A Modern C++ class designer’s toolkit

C++ has promoted class design throughout its history, and Modern C++ continues this tradition. Some new syntax has been added, while other syntax is used less often, or even deprecated. This article aims to summarize best practice using the latest versions of the language in one place, preferring to outline the modern variants (only) without reference to historical style.

Defaulting members and constructors

Variables of the built-in types need to be initialized in order to prevent them from having random contents, and member variables are no exception to this rule. Consider where we have a struct with three fields: an int, a double and a std::string. The third of these is automatically initialized to the empty string so there is no need to provide a default value explicitly, the other two can use a pair of empty braces in order to specify default (zeroized) initialization:

struct S1 {
    int i{};
    double d{};
    std::string s;
};

Providing other initial values is possible:

struct S2 {
    int i{ 1 };
    double d{ 2.3 };
    std::string s{ "Hello" };
};

Default constructors are those which do not take any parameters, and are only generated for a class which does not declare any other constructor. The following class definition demonstrates a class with both a default and a user-defined constructor (declared in the public: block so that they are accessible); the default constructor initializes the member variables as for S2:

class C1 {
    int i;
    double d;
    std::string s;
public:
    C1() : i{ 1 }, d{ 2.3 }, s{ "Hello" }
    {}
    C1(int i, double d, const std::string& s) : i{ i }, d{ d }, s{ s }
    {}
};

Note that each constructor’s member initializers appear after a : and before the empty function body {}. Also note that the members are initialized in the same order they appear in the class definition, and that the same variable names can be used in the constructor’s parameter list.

The same could be equally well accomplished with:

class C2 {
    int i{ 1 };
    double d{ 2.3 };
    std::string s{ "Hello" };
public:
    C2() = default;  // explicit generation of default constructor
    C2(int i, double d, const std::string& s) : i{ i }, d{ d }, s{ s }
    {}
};

Or even:

class C3 {
    int i;
    double d;
    std::string s;
public:
    C3(int i = 1, double d = 2.3, const std::string& s = "Hello") : i{ i }, d{ d }, s{ s }
    {}
};

Personally I prefer C3‘s style with just a single constructor, meaning that you only have to look in one place to determine the default initial state for the class. Note that this does allow setting only i, or only i and d, so if this is not appropriate then one of the other styles should be used (both of which allow all three values, or none, to be provided).

Use explicit constructors and uniform initialization

Implicit type conversions are a dubious C++ feature inherited from C and maintained for compatibility. Universal initialization syntax forbids narrowing conversions which does go some way towards alleviating the dangers associated with implicit conversions for parameters passed to constructors:

class C4 {
    long long n;
public:
    C4(long long n = 0) : n{ n } {}
};

C4 a, b(-100), c(-1ULL << 63), d(0.5);  // bad: c and d are narrowing conversions
C4 e{ -1ULL << 63 }, f{ 0.5 };  // better: e and f are disallowed

For a function which accepts a C4 as a parameter and is passed a long long (from which a C4 can be constructed) the compiler creates a C4 without complaint. This type of conversion (or possibly, synthesis) can be prevented by using the explicit keyword in front of the constructor definition:

class C5 {
    long long n;
public:
    explicit C5(long long n = 0) : n{ n } {}  // note: explicit constructor
};

void f(C5 z) {
    //...
}

void g() {
    f(100);  // bad, instead use: f(C5{ 100 });
}

Allowing access to members

Member functions cannot have the same names as member variables. In practice this is not a problem as “getters” and “setters” can be given other suitable names by convention. Setters should validate their input as being allowable in order to maintain the class’s invariants (otherwise they provide no useful encapsulation). Setters can return a reference to *this in order to be able to be “chained”, while getters should return a const copy of a member (for built-in/small types) or a const reference to the member variable (for other types) and should themselves be declared const.

The following Pixel class (adapted from Chapter 6 of the Tutorial) demonstrates these concepts:

class Pixel {
    unsigned x, y;
    inline static unsigned screen_x = 640, screen_y = 480; // only one instance of these variables
public:
    Pixel(unsigned x, unsigned y) {
        setX(x);
        setY(y);
    }
    Pixel& setX(unsigned new_x) {
        x = (new_x < screen_x) ? new_x : screen_x - 1;
        return *this;
    }
    Pixel& setY(unsigned new_y) {
        y = (new_y < screen_y) ? new_y : screen_y - 1;
        return *this;
    }
    const std::pair<unsigned,unsigned> getXY() const {
        return { x, y };
    }
    static void setScreen(unsigned sx, unsigned sy) {
        screen_x = sx;
        screen_y = sy;
        // assume screen cleared and all Pixels deleted
    }
};

This allows creation and modification of Pixels as follows:

int main() {
    Pixel::setScreen(800, 600);
    Pixel p(20, 50);
    p.setX(100).setY(200);
    auto [ px, py ] = p.getXY();
}

Using public, protected, private

In C++, classes are the same as structs except that the default access specifier is private: (instead of public:). Both can have any number of different access specifiers in their definitions. Member variables and functions within a private: block cannot be accessed from outside the class (or struct). This restriction can be lifted by declaring a function, member function, or whole class as a friend, and members of other objects of the same type can always be accessed (this is often useful when overloading operators).

The third access specifier protected: is the same as private: except that members of derived classes can access these members. Therefore protected: is only used in classes intended to be base classes.

These three keywords are also used with inheritance, where private inheritance (the default for classes) renders all members of the base class private according to users of the derived class. Protected inheritance makes public members of the base class protected, while public inheritance (the default for structs) leaves the access levels untouched. Note that a using declaration in the derived class can make otherwise private/protected members visible:

class B1 {
    int n{ 42 };
public:
    const int get() const { return n; }
};

class D1 : private B1 {
public:
    using B1::get;
};

int main() {
    D1 derived;
    auto n = derived.get();
}

Virtual functions

Classes declared with one or more virtual functions have per-class (not per-object) metadata allocated which allows run-time selection of which function (from within a class hierarchy) should be called. This space (and time) overhead is the reason why C++ member functions are not declared virtual by default (the “only pay for what you use” philosophy).

The recommendation that all base classes should define a virtual destructor is based on the assumption that a derived class object could be deleted from a pointer to its base class:

class B2 {
    //...
};

class D2 : B2 {
    //...
};

void f() {
    B2 *derived  = new D2;
    delete derived;
}

In this code, when the compiler encounters tries to delete derived it has no way of knowing that it is really a D2, not a B2, and this uncertainty can cause a memory leak. The solution is simple:

class B2 {
    //...
public:
    virtual ~B2() {}
};

Note that this is an empty function definition (not just a declaration) which should be declared public:. With this change, when the compiler deletes a pointer to a B2 it can call ~D2() first if the object is in fact a D2.

Functions declared virtual can be optionally qualified with = 0, override and final. The syntax virtual void f() = 0; is a declaration of a pure virtual function, that is one that must be redefined (overridden) in a subsequent derived class. A class declaring one or more pure virtual functions is called an abstract base class (ABC) and such a class cannot be instantiated directly. The keywords override and final are new to Modern C++ and are intended to catch errors when writing derived classes. Using override means that an identical function signature must have previously appeared in the class hierarchy, while final means that no (further) overriding of this function is possible.

The following program demonstrates the concept of polymorphism using virtual functions throughout:

#include <iostream>

class B3 {
public:
    virtual void f() = 0;
    virtual void g() { std::cout << "B3::g()\n"; }
};

class D3 : public B3 {
public:
    virtual void f() override { std::cout << "D3::f()\n"; }
};

class E3 : public D3 {
public:
    virtual void f() override { std::cout << "E3::f()\n"; }
    virtual void h() { std::cout << "E3::h()\n"; }
};

class F3 : public B3 {
public:
    virtual void f() { B3::f(); }
};
    
void B3::f() {  // definition of a pure virtual function
    std::cout << "B3::f()\n";
}

int main() {
    D3 a;
    a.f();
    a.g();
    E3 b;
    b.f();
    b.g();
    b.h();
    F3 c;
    c.f();
}

Note that a definition of a pure virtual function B3::f is possible, this is used by F3. Also, overridden functions can be redefined again, as in E3 inheriting from D3, not B3. The output is:

D3::f()
B3::g()
E3::f()
B3::g()
E3::h()
B3::f()

Know the use case for virtual inheritance

Some popular compiled languages, such as Java and C#, do not allow classes to inherit from more than one base class; they only support single inheritance unlike C++, which has full support for multiple inheritance. Single inheritance by definition avoids the possibility of diamond-lattice inheritance trees, where two (or more) classes inherit from the same base class and are (both) inherited by the same further derived class.

As an example of this, consider four classes: FileBase, InputFile, OutputFile and IOFile. InputFile and OutputFile both inherit from FileBase, and are both inherited by IOFile. The correct C++ skeleton class definition would look like this:

class FileBase {
    // Hierarchy base class
};

class InputFile : virtual public FileBase {
   // Functionalilty for reading files
};

class OutputFile : virtual public FileBase {
    // Functionality for writing files
};

class IOFile : public InputFile, public OutputFile {
    // All functionality of both InputFile and OutputFile
    // with one copy of FileBase's members
};

As a rule, in C++ you should try to avoid the “dreaded diamond”, but in the rare cases where it is necessary, separate inheritance of a common base class should be virtual (as in virtual public FileBase above). This allows run-time resolution of the correct base members, but does add time-and-space costs, which is why virtual inheritance must always be explicitly enabled as needed. The Standard Library utilizes a diamond-lattice inheritance hierarchy for streams, where the four classes are called std::basic_ios, std::basic_istream, std::basic_ostream and std::basic_iostream.

Explicitly define, default or delete all special member functions

The historically so-called the “Rule of Three” was amended with C++11 to cover five functions, which can be automatically generated for classes by the compiler, they are: destructor, copy constructor, copy assignment operator, move constructor and move assignment operator. The “Rule of Three” instructs the compiler not to generate a default copy constructor or default copy assignment operator if the other one of these is user-defined in the class, or if there is a user-defined destructor. This rule is borne out of experience which shows that if user-defined behaviour is required from any of these, it is probably required by all three.

Adding C++11’s move constructor and move assignment operator to the mix results in neither of these being generated if the other is user-defined, or if the copy operations are user-defined. Either move operation being user-defined prevents the copy operations being generated, too.

Before you start to panic about remembering all of that, consider that the fact that most of the classes you write will not require special treatment of members (because they themselves have all the necessary operations defined) means that you can rely on the compiler to generate all special member functions correctly as and when required. However I would still suggest (as in Chapter 9 of the Tutorial) taking direct control of this by using = delete or = default for all five special member functions, unless defining them (all) yourself, and also for the default constructor. It is a comparatively small amount of boilerplate that will save you untold future grief with any “atypical” classes you may write.

Use operator overloading appropriately

Suppose you have a class modeling a gearbox in a vehicle. You may include get() and set() methods for changing gear, but maybe a variant of this vehicle has paddles on the steering wheel for this purpose. Defining the (pre-)increment and (pre-)decrement operators ++ and -- would seem appropriate, together with checks that the modified value would be within range. Most users of your class would use these more naturally than member functions called nextGear()/prevGear() or similar.

For types that mirror numerical types, such as the C++ Standard Library’s std::complex template, you should provide all of the basic operations such as +, *, etc. that may be needed by your class’s clients. As outlined in Chapter 6 of the Tutorial, the operator-assignments (+=, *=, etc.) should be public members of your class, returning *this by reference, while the operators should be global functions which call these op-assigns, making them friend functions if absolutely necessary. This scheme minimizes code duplication and allows mixed mode operations such as 1 + n where n is an instance of your type (not using explicit constructors may be advisable in order to facilitate this).

In a future article we’ll look at the use of generics within (template) class definitions, but for now that wraps up the discussion about C++ class design and implementation. If you have any other tips or tricks to share, please leave a comment.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s