5

Classes

Those types are not “abstract” they are as real as int and float.

– Doug McIlroy

5.1 Introduction

This chapter and the next three aim to give you an idea of C++’s support for abstraction and resource management without going into a lot of detail:

  • This chapter informally presents ways of defining and using new types (user-defined types). In particular, it presents the basic properties, implementation techniques, and language facilities used for concrete classes, abstract classes, and class hierarchies.

  • Chapter 6 presents the operations that have defined meaning in C++, such as constructors, destructors, and assignments. It outlines the rules for using those in combination to control the life cycle of objects and to support simple, efficient, and complete resource management.

  • Chapter 7 introduces templates as a mechanism for parameterizing types and algorithms with other types and algorithms. Computations on user-defined and built-in types are represented as functions, sometimes generalized to function templates and function objects.

  • Chapter 8 gives an overview of the concepts, techniques, and language features that underlie generic programming. The focus is on the definition and use of concepts for precisely specifying interfaces to templates and guide design. Variadic templates are introduced for specifying the most general and most flexible interfaces.

These are the language facilities supporting the programming styles known as object-oriented programming and generic programming. Chapters 918 follow up by presenting examples of standard-library facilities and their use.

5.1.1 Classes

The central language feature of C++ is the class. A class is a user-defined type provided to represent an entity in the code of a program. Whenever our design for a program has a useful idea, entity, collection of data, etc., we try to represent it as a class in the program so that the idea is there in the code, rather than just in our heads, in a design document, or in some comments. A program built out of a well-chosen set of classes is far easier to understand and get right than one that builds everything directly in terms of the built-in types. In particular, classes are often what libraries offer.

Essentially all language facilities beyond the fundamental types, operators, and statements exist to help define better classes or to use them more conveniently. By “better,” I mean more correct, easier to maintain, more efficient, more elegant, easier to use, easier to read, and easier to reason about. Most programming techniques rely on the design and implementation of specific kinds of classes. The needs and tastes of programmers vary immensely. Consequently, the support for classes is extensive. Here, we consider the basic support for three important kinds of classes:

  • Concrete classes (§5.2)

  • Abstract classes (§5.3)

  • Classes in class hierarchies (§5.5)

An astounding number of useful classes turn out to be of one of these three kinds. Even more classes can be seen as simple variants of these kinds or are implemented using combinations of the techniques used for these.

5.2 Concrete Types

The basic idea of concrete classes is that they behave “just like built-in types.” For example, a complex number type and an infinite-precision integer are much like a built-in int, except of course that they have their own semantics and sets of operations. Similarly, a vector and a string are much like built-in arrays, except that they are more flexible and better behaved (§10.2, §11.3, §12.2).

The defining characteristic of a concrete type is that its representation is part of its definition. In many important cases, such as a vector, that representation is only one or more pointers to data stored elsewhere, but that representation is present in each object of the concrete class. That allows implementations to be optimally efficient in time and space. In particular, it allows us to

  • Place objects of concrete types on the stack, in statically allocated memory, and in other objects (§1.5).

  • Refer to objects directly (and not just through pointers or references).

  • Initialize objects immediately and completely (e.g., using constructors; §2.3).

  • Copy and move objects (§6.2).

The representation can be private and accessible only through the member functions (as it is for Vector; §2.3), but it is present. Therefore, if the representation changes in any significant way, a user must recompile. This is the price to pay for having concrete types behave exactly like built-in types. For types that don’t change often, and where local variables provide much-needed clarity and efficiency, this is acceptable and often ideal. To increase flexibility, a concrete type can keep major parts of its representation on the free store (dynamic memory, heap) and access them through the part stored in the class object itself. That’s the way vector and string are implemented; they can be considered resource handles with carefully crafted interfaces.

5.2.1 An Arithmetic Type

The “classical user-defined arithmetic type” is complex:

class complex {
        double re, im;   // representation: two doubles
public:
        complex(double r, double i) :re{r}, im{i} {}       // construct complex from two scalars
        complex(double r) :re{r}, im{0} {}                     // construct complex from one scalar
        complex() :re{0}, im{0} {}                                   // default complex: {0,0}

        double real() const { return re; }
        void real(double d) { re=d; }
        double imag() const { return im; }
        void imag(double d) { im=d; }

        complex& operator+=(complex z)
        {
                re+=z.re;                  // add to re and im
                im+=z.im;
                return *this;             // return the result
        }

        complex& operator-=(complex z)
        {
                re-=z.re;
                im-=z.im;
                return *this;
        }

        complex& operator*=(complex);        // defined out-of-class somewhere
        complex& operator/=(complex);        // defined out-of-class somewhere
};

This is a simplified version of the standard-library complex17.4). The class definition itself contains only the operations requiring access to the representation. The representation is simple and conventional. For practical reasons, it has to be compatible with what Fortran provided 60 years ago, and we need a conventional set of operators. In addition to the logical demands, complex must be efficient or it will remain unused. This implies that simple operations must be inlined. That is, simple operations (such as constructors, +=, and imag()) must be implemented without function calls in the generated machine code. Functions defined in a class are inlined by default. It is possible to explicitly request inlining by preceding a function declaration with the keyword inline. An industrial-strength complex (like the standard-library one) is carefully implemented to do appropriate inlining. In addition, the standard-library complex has the functions shown here declared constexpr so that we can do complex arithmetic at compile time.

Copy assignment and copy initialization are implicitly defined (§6.2).

A constructor that can be invoked without an argument is called a default constructor. Thus, complex() is complex’s default constructor. By defining a default constructor you eliminate the possibility of uninitialized variables of that type.

The const specifiers on the functions returning the real and imaginary parts indicate that these functions do not modify the object for which they are called. A const member function can be invoked for both const and non-const objects, but a non-const member function can only be invoked for non-const objects. For example:

complex z = {1,0};
const complex cz {1,3};
z = cz;                              // OK: assigning to a non-const variable
cz = z;                              // error: assignment to a const
double x = z.real();         // OK: complex::real() is const

Many useful operations do not require direct access to the representation of complex, so they can be defined separately from the class definition:

complex operator+(complex a, complex b) { return a+=b; }
complex operator-(complex a, complex b) { return a-=b; }
complex operator-(complex a) { return {-a.real(), -a.imag()}; }        // unary minus
complex operator*(complex a, complex b) { return a*=b; }
complex operator/(complex a, complex b) { return a/=b; }

Here, I use the fact that an argument passed by value is copied so that I can modify an argument without affecting the caller’s copy and use the result as the return value.

The definitions of == and != are straightforward:

bool operator==(complex a, complex b) { return a.real()==b.real() && a.imag()==b.imag(); }     // equal
bool operator!=(complex a, complex b) { return !(a==b); }                                                             // not equal

Class complex can be used like this:

void f(complex z)
{
        complex a {2.3};              // construct {2.3,0.0} from 2.3
        complex b {1/a};
        complex c {a+z*complex{1,2.3}};
        if (c != b)
                c = -(b/a)+2*b;
}

The compiler converts operators involving complex numbers into appropriate function calls. For example, c!=b means operator!=(c,b) and 1/a means operator/(complex{1},a).

User-defined operators (“overloaded operators”) should be used cautiously and conventionally (§6.4). The syntax is fixed by the language, so you can’t define a unary /. Also, it is not possible to change the meaning of an operator for built-in types, so you can’t redefine + to subtract ints.

5.2.2 A Container

A container is an object holding a collection of elements. We call class Vector a container because objects of type Vector are containers. As defined in §2.3, Vector isn’t an unreasonable container of doubles: it is simple to understand, establishes a useful invariant (§4.3), provides range-checked access (§4.2), and provides size() to allow us to iterate over its elements. However, it does have a fatal flaw: it allocates elements using new but never deallocates them. That’s not a good idea because C++ does not offer a garbage collector to make unused memory available for new objects. In some environments you can’t use a collector, and often you prefer more precise control of destruction for logical or performance reasons. We need a mechanism to ensure that the memory allocated by the constructor is deallocated; that mechanism is a destructor:

class Vector {
public:
        Vector(int s) :elem{new double[s]}, sz{s}        // constructor: acquire resources
        {
                for (int i=0; i!=s; ++i)                // initialize elements
                        elem[i]=0;
        }

        ~Vector() { delete[] elem; }                              // destructor: release resources

        double& operator[](int i);
        int size() const;
private:
        double* elem;           // elem points to an array of sz doubles
        int sz;
};

The name of a destructor is the complement operator, ~, followed by the name of the class; it is the complement of a constructor.

Vector’s constructor allocates some memory on the free store (also called the heap or dynamic memory) using the new operator. The destructor cleans up by freeing that memory using the delete[] operator. Plain delete deletes an individual object; delete[] deletes an array.

This is all done without intervention by users of Vector. The users simply create and use Vectors much as they would variables of built-in types. For example:

Vector gv(10);                                  // global variable; gv is destroyed at the end of the program

Vector* gp = new Vector(100); // Vector on free store; never implicitly destroyed

void fct(int n)
{
        Vector v(n);
        // ... use v ...
        {
                Vector v2(2*n);
                // ... use v and v2 ...
        } // v2 is destroyed here
        // ... use v ..
} // v is destroyed here

Vector obeys the same rules for naming, scope, allocation, lifetime, etc. (§1.5), as does a built-in type, such as int and char. This Vector has been simplified by leaving out error handling; see §4.4.

The constructor/destructor combination is the basis of many elegant techniques. In particular, it is the basis for most C++ general resource management techniques (§6.3, §15.2.1). Consider a graphical illustration of a Vector:

Images

The constructor allocates the elements and initializes the Vector members appropriately. The destructor deallocates the elements. This handle-to-data model is very commonly used to manage data that can vary in size during the lifetime of an object. The technique of acquiring resources in a constructor and releasing them in a destructor, known as Resource Acquisition Is Initialization or RAII. It allows us to eliminate “naked new operations,” that is, to avoid allocations in general code and keep them buried inside the implementation of well-behaved abstractions. Similarly, “naked delete operations” should be avoided. Avoiding naked new and naked delete makes code far less error-prone and far easier to keep free of resource leaks (§15.2.1).

5.2.3 Initializing Containers

A container exists to hold elements, so obviously we need convenient ways of getting elements into a container. We can create a Vector with an appropriate number of elements and then assign to those later, but typically other ways are more elegant. Here, I just mention two favorites:

  • Initializer-list constructor: Initialize with a list of elements.

  • push_back(): Add a new element at the end of (at the back of) the sequence.

These can be declared like this:

class Vector {
public:
        Vector();                                                       // default initalize to "empty"; that is, to no elements
        Vector(std::initializer_list<double>);        // initialize with a list of doubles
        // ...
        void push_back(double);                           // add element at end, increasing the size by one
        // ...
};

The push_back() is useful for input of arbitrary numbers of elements. For example:

Vector read(istream& is)
{
        Vector v;
        for (double d; is>>d; )                // read floating-point values into d
                v.push_back(d);                 // add d to v
return v;
}

The input loop is terminated by an end-of-file or a formatting error. Until that happens, each number read is added to the Vector so that at the end, v’s size is the number of elements read. I used a for-statement rather than the more conventional while-statement to limit the scope of d to the loop.

Returning a potentially huge amount of data from read() could be expensive. The way to guarantee that returning a Vector is cheap is to provide it with a move constructor (§6.2.2):

Vector v = read(cin);        // no copy of Vector elements here

The way that std::vector is represented to make push_back() and other operations that change a vector’s size efficient is presented in §12.2.

The std::initializer_list used to define the initializer-list constructor is a standard-library type known to the compiler: when we use a {}-list, such as {1,2,3,4}, the compiler will create an object of type initializer_list to give to the program. So, we can write:

Vector v1 = {1, 2, 3, 4, 5};                // v1 has 5 elements
Vector v2 = {1.23, 3.45, 6.7, 8};        // v2 has 4 elements

Vector’s initializer-list constructor might be defined like this:

Vector::Vector(std::initializer_list<double> lst)        // initialize with a list
        :elem{new double[lst.size()]}, sz{static_cast<int>(lst.size())}
{
        copy(lst.begin(),lst.end(),elem);               // copy from lst into elem (§13.5)
}

Unfortunately, the standard-library uses unsigned integers for sizes and subscripts, so we need to use the ugly static_cast to explicitly convert the size of the initializer list to an int. This is pedantic because the chance that the number of elements in a handwritten list is larger than the largest integer (32,767 for 16-bit integers and 2,147,483,647 for 32-bit integers) is rather low. However, the type system has no common sense. It knows about the possible values of variables, rather than actual values, so it might complain where there is no actual violation. Such warnings can occasionally save the programmer from a bad error.

A static_cast does not check the value it is converting; the programmer is trusted to use it correctly. This is not always a good assumption, so if in doubt, check the value. Explicit type conversions (often called casts to remind you that they are used to prop up something broken) are best avoided. Try to use unchecked casts only for the lowest level of a system. They are error-prone.

Other casts are reinterpret_cast and bit_cast16.7) for treating an object as simply a sequence of bytes and const_cast for “casting away const.” Judicious use of the type system and well-designed libraries allow us to eliminate unchecked casts in higher-level software.

5.3 Abstract Types

Types such as complex and Vector are called concrete types because their representation is part of their definition. In that, they resemble built-in types. In contrast, an abstract type is a type that completely insulates a user from implementation details. To do that, we decouple the interface from the representation and give up genuine local variables. Since we don’t know anything about the representation of an abstract type (not even its size), we must allocate objects on the free store (§5.2.2) and access them through references or pointers (§1.7, §15.2.1).

First, we define the interface of a class Container, which we will design as a more abstract version of our Vector:

class Container {
public:
        virtual double& operator[](int) = 0;       // pure virtual function
        virtual int size() const = 0;                     // const member function (§5.2.1)
        virtual ~Container() {}                            // destructor (§5.2.2)
};

This class is a pure interface to specific containers defined later. The word virtual means “may be redefined later in a class derived from this one.” Unsurprisingly, a function declared virtual is called a virtual function. A class derived from Container provides an implementation for the Container interface. The curious =0 syntax says the function is pure virtual; that is, some class derived from Container must define the function. Thus, it is not possible to define an object that is just a Container. For example:

Container c;                                                         // error: there can be no objects of an abstract class
Container* p = new Vector_container(10);        // OK: Container is an interface for Vector_container

A Container can only serve as the interface to a class that implements its operator[]() and size() functions. A class with a pure virtual function is called an abstract class.

This Container can be used like this:

void use(Container& c)
{
        const int sz = c.size();

        for (int i=0; i!=sz; ++i)
                cout << c[i] << '
';
}

Note how use() uses the Container interface in complete ignorance of implementation details. It uses size() and [ ] without any idea of exactly which type provides their implementation. A class that provides the interface to a variety of other classes is often called a polymorphic type.

As is common for abstract classes, Container does not have a constructor. After all, it does not have any data to initialize. On the other hand, Container does have a destructor and that destructor is virtual, so that classes derived from Container can provide implementations. Again, that is common for abstract classes because they tend to be manipulated through references or pointers, and someone destroying a Container through a pointer has no idea what resources are owned by its implementation; see also §5.5.

The abstract class Container defines only an interface and no implementation. For Container to be useful, we have to implement a container that implements the functions required by its interface. For that, we could use the concrete class Vector:

class Vector_container : public Container {    // Vector_container implements Container
public:
        Vector_container(int s) : v(s) { }       // Vector of s elements
        ~Vector_container() {}

        double& operator[](int i) override { return v[i]; }
        int size() const override { return v.size(); }
private:
        Vector v;
};

The :public can be read as “is derived from” or “is a subtype of.” Class Vector_container is said to be derived from class Container, and class Container is said to be a base of class Vector_container. An alternative terminology calls Vector_container and Container subclass and superclass, respectively. The derived class is said to inherit members from its base class, so the use of base and derived classes is commonly referred to as inheritance.

The members operator[]() and size() are said to override the corresponding members in the base class Container. I used the explicit override to make clear what’s intended. The use of override is optional, but being explicit allows the compiler to catch mistakes, such as misspellings of function names or slight differences between the type of a virtual function and its intended overrider. The explicit use of override is particularly useful in larger class hierarchies where it can otherwise be hard to know what is supposed to override what.

The destructor (~Vector_container()) overrides the base class destructor (~Container()). Note that the member destructor (~Vector()) is implicitly invoked by its class’s destructor (~Vector_container()).

For a function like use(Container&) to use a Container in complete ignorance of implementation details, some other function will have to make an object on which it can operate. For example:

void g()
{
        Vector_container vc(10);       // Vector of ten elements
        // ... fill vc ...
        use(vc);
}

Since use() doesn’t know about Vector_containers but only knows the Container interface, it will work just as well for a different implementation of a Container. For example:

class List_container : public Container {         // List_container implements Container
public:
        List_container() { }          // empty List
        List_container(initializer_list<double> il) : ld{il} { }
        ~List_container() {}

        double& operator[](int i) override;
        int size() const override { return ld.size(); }

private:
        std::list<double> ld;         // (standard-library) list of doubles (§12.3)
};

double& List_container::operator[](int i)
{
        for (auto& x : ld) {
                if (i==0)
                        return x;
                --i;
        }
        throw out_of_range{"List container"};
}

Here, the representation is a standard-library list<double>. Usually, I would not implement a container with a subscript operation using a list, because performance of list subscripting is atrocious compared to vector subscripting. However, here I just wanted to show an implementation that is radically different from the usual one.

A function can create a List_container and have use() use it:

void h()
{
        List_container lc = {1, 2, 3, 4, 5, 6, 7, 8, 9};
        use(lc);
}

The point is that use(Container&) has no idea if its argument is a Vector_container, a List_container, or some other kind of container; it doesn’t need to know. It can use any kind of Container. It knows only the interface defined by Container. Consequently, use(Container&) needn’t be recompiled if the implementation of List_container changes or a brand-new class derived from Container is used.

The flip side of this flexibility is that objects must be manipulated through pointers or references (§6.2, §15.2.1).

5.4 Virtual Functions

Consider again the use of Container:

void use(Container& c)
{
        const int sz = c.size();

        for (int i=0; i!=sz; ++i)
                cout << c[i] << '
';
}

How is the call c[i] in use() resolved to the right operator[]()? When h() calls use(), List_container’s operator[]() must be called. When g() calls use(), Vector_container’s operator[]() must be called. To achieve this resolution, a Container object must contain information to allow it to select the right function to call at run time. The usual implementation technique is for the compiler to convert the name of a virtual function into an index into a table of pointers to functions. That table is usually called the virtual function table or simply the vtbl. Each class with virtual functions has its own vtbl identifying its virtual functions. This can be represented graphically like this:

Images

The functions in the vtbl allow the object to be used correctly even when the size of the object and the layout of its data are unknown to the caller. The implementation of the caller needs only to know the location of the pointer to the vtbl in a Container and the index used for each virtual function. This virtual call mechanism can be made almost as efficient as the “normal function call” mechanism (within 25% and far cheaper for repeated calls to the same object). Its space overhead is one pointer in each object of a class with virtual functions plus one vtbl for each such class.

5.5 Class Hierarchies

The Container example is a very simple example of a class hierarchy. A class hierarchy is a set of classes ordered in a lattice created by derivation (e.g., : public). We use class hierarchies to represent concepts that have hierarchical relationships, such as “A fire engine is a kind of a truck that is a kind of a vehicle” and “A smiley face is a kind of a circle that is a kind of a shape.” Huge hierarchies, with hundreds of classes, that are both deep and wide are common. As a semi-realistic classic example, let’s consider shapes on a screen:

Images

The arrows represent inheritance relationships. For example, class Circle is derived from class Shape. A class hierarchy is conventionally drawn growing down from the most basic class, the root, towards the (later defined) derived classes. To represent that simple diagram in code, we must first specify a class that defines the general properties of all shapes:

class Shape {
public:
        virtual Point center() const =0;          // pure virtual
        virtual void move(Point to) =0;

        virtual void draw() const = 0;            // draw on current "Canvas"
        virtual void rotate(int angle) = 0;

        virtual ~Shape() {}                              // destructor
        // ...
};

Naturally, this interface is an abstract class: as far as representation is concerned, nothing (except the location of the pointer to the vtbl) is common for every Shape. Given this definition, we can write general functions manipulating vectors of pointers to shapes:

void rotate_all(vector<Shape*>& v, int angle) // rotate v's elements by angle degrees
{
        for (auto p : v)
                p->rotate(angle);
}

To define a particular shape, we must say that it is a Shape and specify its particular properties (including its virtual functions):

class Circle : public Shape {
public:
        Circle(Point p, int rad) :x{p}. r{rad} {}               // constructor

        Point center() const override { return x; }
        void move(Point to) override { x = to; }
        void draw() const override;
        void rotate(int) override {}                // nice simple algorithm
private:
        Point x;     // center
        int r;          // radius
};

So far, the Shape and Circle example provides nothing new compared to the Container and Vector_container example, but we can build further:

class Smiley : public Circle {   // use the circle as the base for a face
public:
        Smiley(Point p, int rad) : Circle{p,rad}, mouth{nullptr} { }
        ~Smiley()
        {
                delete mouth;
                for (auto p : eyes)
                        delete p;
        }

        void move(Point to) override;

        void draw() const override;
        void rotate(int) override;

        void add_eye(Shape* s)
        {
                eyes.push_back(s);
        }

        void set_mouth(Shape* s);
        virtual void wink(int i);                // wink eye number i

        // ...

private:
        vector<Shape*> eyes;                 // usually two eyes
        Shape* mouth;
};

The push_back() member of vector copies its argument into the vector (here, eyes) as the last element, increasing that vector’s size by one.

We can now define Smiley::draw() using calls to Smiley’s base and member draw()s:

void Smiley::draw() const
{
        Circle::draw();
        for (auto p : eyes)
                p->draw();
        mouth->draw();
}

Note the way that Smiley keeps its eyes in a standard-library vector and deletes them in its destructor. Shape’s destructor is virtual and Smiley’s destructor overrides it. A virtual destructor is essential for an abstract class because an object of a derived class is usually manipulated through the interface provided by its abstract base class. In particular, it may be deleted through a pointer to a base class. Then, the virtual function call mechanism ensures that the proper destructor is called. That destructor then implicitly invokes the destructors of its bases and members.

In this simplified example, it is the programmer’s task to place the eyes and mouth appropriately within the circle representing the face.

We can add data members, operations, or both as we define a new class by derivation. This gives great flexibility with corresponding opportunities for confusion and poor design.

5.5.1 Benefits from Hierarchies

A class hierarchy offers two kinds of benefits:

  • Interface inheritance: An object of a derived class can be used wherever an object of a base class is required. That is, the base class acts as an interface for the derived class. The Container and Shape classes are examples. Such classes are often abstract classes.

  • Implementation inheritance: A base class provides functions or data that simplifies the implementation of derived classes. Smiley’s uses of Circle’s constructor and of Circle::draw() are examples. Such base classes often have data members and constructors.

Concrete classes – especially classes with small representations – are much like built-in types: we define them as local variables, access them using their names, copy them around, etc. Classes in class hierarchies are different: we tend to allocate them on the free store using new, and we access them through pointers or references. For example, consider a function that reads data describing shapes from an input stream and constructs the appropriate Shape objects:

enum class Kind { circle, triangle, smiley };

Shape* read_shape(istream& is)     // read shape descriptions from input stream is
{
        // ... read shape header from is and find its Kind k ...

        switch (k) {
        case Kind::circle:
                // ... read circle data {Point,int} into p and r ...
                return new Circle{p,r};
        case Kind::triangle:
                // ... read triangle data {Point,Point,Point} into p1, p2, and p3 ...
                return new Triangle{p1,p2,p3};
        case Kind::smiley:
                // ... read smiley data {Point,int,Shape,Shape,Shape} into p, r, e1, e2, and m ...
                Smiley* ps = new Smiley{p,r};
                ps->add_eye(e1);
                ps->add_eye(e2);
                ps->set_mouth(m);
                return ps;
        }
}

A program may use that shape reader like this:

void user()
{
        std::vector<Shape*> v;

        while (cin)
                v.push_back(read_shape(cin));

        draw_all(v);                       // call draw() for each element
        rotate_all(v,45);                 // call rotate(45) for each element

        for (auto p : v)                   // remember to delete elements
                delete p;
}

Obviously, the example is simplified – especially with respect to error handling – but it vividly illustrates that user() has absolutely no idea of which kinds of shapes it manipulates. The user() code can be compiled once and later used for new Shapes added to the program. Note that there are no pointers to the shapes outside user(), so user() is responsible for deallocating them. This is done with the delete operator and relies critically on Shape’s virtual destructor. Because that destructor is virtual, delete invokes the destructor for the most derived class. This is crucial because a derived class may have acquired all kinds of resources (such as file handles, locks, and output streams) that need to be released. In this case, a Smiley deletes its eyes and mouth objects. Once it has done that, it calls Circle’s destructor. Objects are constructed “bottom up” (base first) by constructors and destroyed “top down” (derived first) by destructors.

5.5.2 Hierarchy Navigation

The read_shape() function returns a Shape* so that we can treat all Shapes alike. However, what can we do if we want to use a member function that is only provided by a particular derived class, such as Smiley’s wink()? We can ask “is this Shape a kind of Smiley?” using the dynamic_cast operator:

Shape* ps {read_shape(cin)};

if (Smiley* p = dynamic_cast<Smiley*>(ps)) { // does ps point to a Smiley?
        // ... a Smiley; use it ...
}
else {
        // ... not a Smiley, try something else ...
}

If at run time the object pointed to by the argument of dynamic_cast (here, ps) is not of the expected type (here, Smiley) or a class derived from the expected type, dynamic_cast returns nullptr.

We use dynamic_cast to a pointer type when a pointer to an object of a different derived class is a valid argument. We then test whether the result is nullptr. This test can often conveniently be placed in the initialization of a variable in a condition.

When a different type is unacceptable, we can simply dynamic_cast to a reference type. If the object is not of the expected type, dynamic_cast throws a bad_cast exception:

Shape* ps {read_shape(cin)};
Smiley& r {dynamic_cast<Smiley&>(*ps)};      // somewhere, catch std::bad_cast

Code is cleaner when dynamic_cast is used with restraint. If we can avoid testing type information at run time, we can write simpler and more efficient code, but occasionally type information is lost and must be recovered. This typically happens when we pass an object to some system that accepts an interface specified by a base class. When that system later passes the object back to us, we might have to recover the original type. Operations similar to dynamic_cast are known as “is kind of” and “is instance of” operations.

5.5.3 Avoiding Resource Leaks

A leak is the conventional term for the what happens when we acquire a resource and fail to release it. Leaking resources must be avoided because a leak makes the leaked resource unavailable to the system. Thus, leaks can eventually lead to slowdown or even crashes as a system runs out of needed resources.

Experienced programmers will have noticed that I left open three opportunities for mistakes in the Smiley example:

  • The implementer of Smiley may fail to delete the pointer to mouth.

  • A user of read_shape() might fail to delete the pointer returned.

  • The owner of a container of Shape pointers might fail to delete the objects pointed to.

In that sense, pointers to objects allocated on the free store are dangerous: a “plain old pointer” should not be used to represent ownership. For example:

void user(int x)
{
        Shape* p = new Circle{Point{0,0},10};
        // ...
        if (x<0) throw Bad_x{};     // potential leak
        if (x==0) return;                 // potential leak
        // ...
        delete p;
}

This will leak unless x is positive. Assigning the result of new to a “naked pointer” is asking for trouble.

One simple solution to such problems is to use a standard-library unique_ptr15.2.1) rather than a “naked pointer” when deletion is required:

class Smiley : public Circle {
        // ...
private:
        vector<unique_ptr<Shape>> eyes;  // usually two eyes
        unique_ptr<Shape> mouth;
};

This is an example of a simple, general, and efficient technique for resource management (§6.3).

As a pleasant side effect of this change, we no longer need to define a destructor for Smiley. The compiler will implicitly generate one that does the required destruction of the unique_ptrs (§6.3) in the vector. The code using unique_ptr will be exactly as efficient as code using the raw pointers correctly.

Now consider users of read_shape():

unique_ptr<Shape> read_shape(istream& is) // read shape descriptions from input stream is
{
        // ... read shape header from is and find its Kind k ...

        switch (k) {
        case Kind::circle:
                // ... read circle data {Point,int} into p and r ...
                return unique_ptr<Shape>{new Circle{p,r}};          // §15.2.1
        // ...
}

void user()
{
        vector<unique_ptr<Shape>> v;

        while (cin)
                v.push_back(read_shape(cin));

        draw_all(v);                             // call draw() for each element
        rotate_all(v,45);                       // call rotate(45) for each element
} // all Shapes implicitly destroyed

Now each object is owned by a unique_ptr that will delete the object when it is no longer needed, that is, when its unique_ptr goes out of scope.

For the unique_ptr version of user() to work, we need versions of draw_all() and rotate_all() that accept vector<unique_ptr<Shape>>s. Writing many such _all() functions could become tedious, so §7.3.2 shows an alternative.

5.6 Advice

[1] Express ideas directly in code; §5.1; [CG: P.1].

[2] A concrete type is the simplest kind of class. Where applicable, prefer a concrete type over more complicated classes and over plain data structures; §5.2; [CG: C.10].

[3] Use concrete classes to represent simple concepts; §5.2.

[4] Prefer concrete classes over class hierarchies for performance-critical components; §5.2.

[5] Define constructors to handle initialization of objects; §5.2.1, §6.1.1; [CG: C.40] [CG: C.41].

[6] Make a function a member only if it needs direct access to the representation of a class; §5.2.1; [CG: C.4].

[7] Define operators primarily to mimic conventional usage; §5.2.1; [CG: C.160].

[8] Use nonmember functions for symmetric operators; §5.2.1; [CG: C.161].

[9] Declare a member function that does not modify the state of its object const; §5.2.1.

[10] If a constructor acquires a resource, its class needs a destructor to release the resource; §5.2.2; [CG: C.20].

[11] Avoid “naked” new and delete operations; §5.2.2; [CG: R.11].

[12] Use resource handles and RAII to manage resources; §5.2.2; [CG: R.1].

[13] If a class is a container, give it an initializer-list constructor; §5.2.3; [CG: C.103].

[14] Use abstract classes as interfaces when complete separation of interface and implementation is needed; §5.3; [CG: C.122].

[15] Access polymorphic objects through pointers and references; §5.3.

[16] An abstract class typically doesn’t need a constructor; §5.3; [CG: C.126].

[17] Use class hierarchies to represent concepts with inherent hierarchical structure; §5.5.

[18] A class with a virtual function should have a virtual destructor; §5.5; [CG: C.127].

[19] Use override to make overriding explicit in large class hierarchies; §5.3; [CG: C.128].

[20] When designing a class hierarchy, distinguish between implementation inheritance and interface inheritance; §5.5.1; [CG: C.129].

[21] Use dynamic_cast where class hierarchy navigation is unavoidable; §5.5.2; [CG: C.146].

[22] Use dynamic_cast to a reference type when failure to find the required class is considered a failure; §5.5.2; [CG: C.147].

[23] Use dynamic_cast to a pointer type when failure to find the required class is considered a valid alternative; §5.5.2; [CG: C.148].

[24] Use unique_ptr or shared_ptr to avoid forgetting to delete objects created using new; §5.5.3; [CG: C.149].

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.144.206.162