ICS 45C Fall 2021
Notes and Examples: Inheritance and Polymorphism

The moniker for this code example is Inheritance


Background

While C++ is not solely an object-oriented programming language, C++ is a language that decidedly supports it. In C++, the set of object-oriented features is a tool that is available to you if you feel you need it to express your design more clearly, but are not compulsory. And, importantly, as with most features in C++, the object-oriented features cost you nothing if you choose not to use them — and, in fact, they don't cost you very much if you use them carefully. So they are a good choice when they give you a way to improve your design, though not all designs need them.

It's often said that object-oriented programming is characterized by three main features:

All three of these features are available in C++. We've seen the first of these in some detail already. This code example explores the other two.


An overview of inheritance in C++

We can introduce an inheritance relationship between two classes X and Y, in which Y is said to be a derived class of X and, in turn, X is said to be a base class of Y. The way you introduce that relationship is to describe it in the declaration of the derived class, like this:

class X { ... };

class Y : public X { ... };

The : operator in this context can be read as derives from or inherits from. The word public in this context means that the inheritance relationship is known throughout the program (i.e., code anywhere in the program can make use of this fact); while there are other ways to express inheritance relationships, ours will be public much more often than not, so we'll reserve a conversation about other ways for later.

Conceptually, inheritance indicates what we often call an is a relationship (i.e., a Y object is an X). This concept makes more sense given a specific example, like a Student class that inherits from Person; in that case, we would be saying logically that "a Student is a Person," which makes good conceptual sense. Almost everything that's true of a person is also true of a student, but students have additional qualities and are in certain ways different from other people.

Once this relationship is introduced in C++, it has a few effects:

In short, the class Y can be written primarily in terms of what makes it different from X, rather than having to duplicate everything within X manually. And, what's more, a change in the class X will have a corresponding, automatic effect on the class Y.


An overview of polymorphism in C++

Given the relationship between the classes X and Y above, certain things become legal and certain others become illegal. In general, the fact that "a Y is an X" is respected by the compiler, and manifests itself in the ways you might expect, given that basic philosophy.

First of all, we'll be able to declare and define objects of each of these classes, X and Y.

X x;
Y y;

These are both legal, since X and Y are both classes. (We'll assume that these classes both have constructors that take no arguments, which would be required in order for those statements to be legal.)

We'll also be able to dynamically allocate objects of both classes and point like-typed pointers to them, as you would with any other class.

X* px = new X;
Y* py = new Y;

And, similarly, we'll be able to use like-typed references to refer to these objects. (I'm assuming all of the code in this section is part of the same function.)

X& rx = x;
Y& ry = y;

So far, there haven't been any surprises. The more interesting effects come when we mix types, in cases like these:

Slice assignment

Our first attempt at mixing types is to assign (or copy construct) a value of one type into a variable of a related type. Consider these possibilities, again assuming that the code earlier in this section has already executed.

Y y2 = x;
X x2 = y;

Which of these do we expect will be legal? Let's consider them in turn:

Using pointers and references to mix types

Pointers and references make for some more interesting behavior. Which of these do we expect will be legal?

X xx;
Y yy;
X& rxx = yy;
Y& ryy = xx;
X* px = new X;
Y* py = new Y;
X* px2 = py;
Y* py2 = px;

The answer lies in the basic philosophical assumption that "a Y is an X." We expect, in general, that X pointers and X references can legally point to objects that are X's. But, because of the inheritance relationship between the two classes, Y's are X's. So we can reasonably expect X pointers and X references to be able to point to Y objects. However, we don't expect the reverse to be true. An X is not necessarily a Y, so we don't expect to be able to point Y pointers or Y references to X objects.

If we have a pointer of one type pointing to an object of a different type, we have another interesting question to consider. Suppose that the following member function is declared in both X and Y (i.e., Y has a version that overrides the version from X).

class X
{
public:
    void foo();
};

class Y : public X
{
public:
    void foo();
};

Given the declarations above, what do we expect will happen in these cases?

rxx.foo();
px2->foo();

There are two possibilities.

Which of these possibilities holds true depends on how the foo() function is declared. The latter behavior — determining the version based on the type of the object — is an example of what's called polymorphism, where the same pointer might be used to call different versions of foo() at run time, depending on the kind of object the pointer points to.

But polymorphism has a run-time cost (i.e., the cost of looking up the appropriate version of the function at run time, based on the type of the object), so this behavior is not the default in C++. You have to opt in, on a member-function-by-member-function basis, by declaring particular member functions to be virtual. We declare a member function to be virtual by putting the keyword virtual at the beginning of its signature in the class declaration.

class X
{
public:
    virtual void foo();
};

class Y : public X
{
public:
    void foo();
};

Note that declaring foo() to be a virtual function in the class X automatically makes it virtual in all of the classes derived from X, so we don't need to mark foo() as virtual in the class Y. It's wise to be clear about the fact that you intend to be overriding a member function from a base class, though; there's more to be said about that in the next section.

If foo() is virtual, then our calls to foo() using rxx and px2 will both call Y's version of foo(). If foo() is not virtual, then they would call X's version (i.e., the version corresponding to the pointer's or reference's type) instead.

The override specifier

Member functions that are intended to override a member function from a base class are best marked with an override specifier, which means that the word override should be added to the end of their signature:

class Y : public X
{
public:
    void foo() override;
};

While this is not strictly necessary in C++ — technically, what makes Y::foo() override X::foo() is the fact that their signatures match (and what makes it useful is the fact that the member function is virtual) — it is a good habit to get into, for at least a couple of reasons:

  1. Your code reads more clearly, because the word override in its signature alerts the reader to the relationship between the member function and a corresponding one from a base class.
  2. More importantly, if you specify that this is your intent, the compiler will now check your intent, meaning that you'll be able to get back a compile-time error in any case where that intent isn't met, such as these:
    • You mark a member function with override in a class whose base class doesn't have a member function with that name.
    • You mark a member function with override in a class whose base class does have a member function with that name, but whose signature doesn't match.
    • You mark a member function with override, but that member function was not declared virtual.
    In any of these cases, what you would get without the word override is a program that compiles and exhibits behavior other than what you were expecting. In other words, in none of these cases would your member function actually override a member function from the base class, but if you expected that it had, you would be misunderstanding your own program. Eliminating possible mistakes, by turning them into compile-time errors, is always a good thing.

Note, too, that this argument is especially true of programs that evolve over time. If you were to change the signature of a member function in a class, any class derived from it would have to change. With override on the derived class' member functions, these issues would manifest themselves as compile-time errors and be quickly fixed. Without it, they would be issues that wouldn't surface until run-time, which might very well mean that they would be problems that you wouldn't notice right away, perhaps not until users of the program were affected.


The code

The official moniker for this code example is Inheritance, so your best bet is to do this:

Alternatively, you can click the link to the tarball below: