ICS 45C Spring 2022
Code Example: Abstract Base Classes

Includes a code example with the moniker AbstractBaseClasses


Background

When object-oriented principles are taught, it's not uncommon to see the example of different kinds of shapes — circles, rectangles, right triangles, etc. — to be used to illustrate the concept. Different kinds of shapes might have similar abilities, such as the ability to be drawn or the ability to tell you their area, but would perform these similar operations in different ways.

Let's consider that same example, but keep it simple: Shapes, in our example, will only have the ability to tell you their area. Of course, different kinds of shapes would calculate their area differently; for example, circles would square their radius and multiply it by π, while rectangles would multiply their width and height together.


Starting with Circle and Rectangle classes

We could begin with relatively straightforward implementations of Circle and Rectangle classes (and whatever other shapes we'd like to implement; feel free to practice by adding additional shapes to this example).

class Circle
{
public:
    double area() const;
};

class Rectangle
{
public:
    double area() const;
};

There is obvious value in giving each of the area() members functions the same signature: We'd like, at some point, to be able to have a pointer that points to some kind of shape, call area() on it, and have the "right" function be called. One requirement if we want that kind of polymorphism is that all of the functions share the same signature.

But this example stops short of being complete in C++. If we wrote this same example in Python, we might well be done, from a design perspective. We might write the two classes this way.

class Circle:
    def __init__(self, radius):
        self._radius = radius

    def area(self):
        return 3.1415927 * self._radius * self._radius


class Rectangle:
    def __init__(self, width, height):
        self._width = width
        self._height = height

    def area(self):
        return self._width * self._height

In Python, what relates these classes together is that they share the same "interface" (i.e., they both have area methods that have the same signature). And that's all you need to achieve polymorphism, because the notion of "interface" in Python is an implicit one: If two classes have a set of methods with the same signatures, they share an interface. Because of that, you would now be able to write something like this...

def print_area(some_shape):
    print(f'The area of the shape is {some_shape.area()}')

...then pass either a Circle or Rectangle object to this function. Since method calls are resolved at run time in Python — Python asks "What kind of object is some_shape? Does that kind of object have an area() method? If so, call it! If not, raise an exception!" — it is actually legal to pass any kind of object as an argument to this function, though only objects that have an area() method will do anything other than fail (i.e., raise an exception).

However, C++ has a different point of view on the issue of type checking. In particular, C++ requires every variable to be declared with a particular type, and then that variable will only be able to accept a value of a type that's compatible, or else the program will neither compile nor run. So how could we write a function in C++ that's equivalent to our print_area function in Python?

First of all, we'd need a type for the function's parameter. It would need to be a type that's compatible with any kind of shape (i.e., we should be able to pass the function a Circle object or a Rectangle object). So far, there's no way to say that in C++; since the Circle and Rectangle classes are unrelated in any explicit way, C++ considers them separate, incompatible types.

To achieve our goal, then, we'll need to add a piece to the puzzle.


Adding an abstract base class

One way to relate C++ classes that share the same interface (i.e., that have one or more member functions with identical signatures and identical meanings) is to make that relationship explicit using inheritance. We do that by introducing a base class that's general — not specific to any particular kind of shape — and then derive our Circle and Rectangle classes from it. A good name for that class might be Shape.

class Shape
{
public:
    virtual double area() const;
};

class Circle : public Shape
{
public:
    double area() const override;
};

class Rectangle : public Shape
{
public:
    double area() const override;
};

The trouble, though, is that there is no reasonable implementation for Shape's area() member function. What does it mean to ask a shape for its area? It depends on what kind of shape it is. The Shape class represents the abstract notion of a shape without being any particular kind of shape. And if we don't know what kind of shape we have, we don't know how to calculate its area.

This issue arises in designs like this quite often, so C++ offers a way to establish the important fact about our design — namely, that all shapes can calculate an area the same way — while leaving the implementation details to be filled in by derived classes. We do this by declaring area() in the Shape class to be a pure virtual function, by using the rather bizarre-looking = 0 notation at the end of its signature. (You can read that as the word "pure" if it helps it to make more sense.)

class Shape
{
public:
    virtual double area() const = 0;
};

Doing this has two consequences:

Using abstract base classes

Now that we have the Shape class, there's a natural way to express the idea that we want a variable that can potentially store any kind of shape. Of course, we can't use the type Shape for such a variable, but we can use a pointer or a reference instead. So, for example, we could implement a function in C++ that's analogous to our print_area Python function this way:

void printArea(const Shape& s)
{
    std::cout << "The area of the shape is " << s.area() << std::endl;
}

The parameter s can be referred to any type of object that inherits from Shape. Meanwhile, because area() is a virtual function in Shape, the appropriate version of area() will be called based on the type of shape we have.

We can also store collections of shapes, though we would now need to use pointers rather than references, since collections can change over their lifetime. For example, we could have a vector of Shape pointers, each able to point to any type of object that inherits from Shape.

double calculateSumOfAreas(const std::vector<Shape*>& shapes)
{
    double total = 0.0;

    for (const Shape* s : shapes)
    {
        total += s->area();
    }

    return total;
}

The code

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

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