7. Inheritance and composition

Video: inheritance and composition.

Imperial students can also watch this video on Panopto.

A key feature of abstractions is composability: the ability to make a complex object or operation out of several components. We can compose objects by simply making one object an attribute of another object. This combines objects in a has a relationship. For example the Polynomial class introduced in Chapter 3 has a tuple of coefficients. Object composition of this sort is a core part of encapsulation.

Another way of composing abstractions is to make a new class by modifying another class. Typically this is employed to make a more specialised class from a more general one. In fact, we have already seen this in the case of number classes. Recall that all numbers are instances of numbers.Number:

In [1]: from numbers import Number

In [2]: isinstance(1, Number)
Out[2]: True

Hang on, though. 1 is actually an instance of int:

In [3]: type(1)
Out[3]: int

In [4]: isinstance(1, int)
Out[4]: True

So 1, it turns out, has type int, and so is an instance of int, but is also somehow an instance of numbers.Number. Mathematically, the answer is obvious: integers form a subset of all of the numbers. Object inheritance works in much the same way: we say that int is a subclass of numbers.Number. Just as isinstance() provides a mechanism for determining whether an object is an instance of a class, issubclass() will tell us when one class is a subclass of another:

In [5]: issubclass(int, Number)
Out[5]: True

In fact, there is a whole hierarchy of numeric types in numbers:

In [6]: import numbers

In [7]: issubclass(int, numbers.Integral)
Out[7]: True

In [8]: issubclass(numbers.Integral, numbers.Rational)
Out[8]: True

In [9]: issubclass(numbers.Rational, numbers.Real)
Out[9]: True

In [10]: issubclass(numbers.Real, numbers.Complex)
Out[10]: True

It turns out that issubclass() is reflexive (classes are subclasses of themselves):

In [11]: issubclass(numbers.Real, numbers.Real)
Out[11]: True

This means that, in a manner analogous to subset inclusion, the subclass relationship forms a partial order on the set of all classes. This relationship defines another core mechanism for creating a new class from existing classes: inheritance. If one class is a subclass of another then we say that it inherits from that class. Where composition defines a has a relationship, inheritance defines an is a relationship.

7.1. An example from group theory

Video: an example from group theory.

Imperial students can also watch this video on Panopto.

In order to illustrate how composition and inheritance work, let’s suppose that we want to write a module that implements some basic groups. Recall that a group is a collection of elements, and a group operation which obeys certain axioms. A computer implementation of a group might therefore involve objects representing groups, and objects representing elements. We’ll lay out one possible configuration, which helpfully involves both inheritance and composition, as well as parametrisation of objects and delegation of methods.

7.1.1. Cyclic groups

Let’s start with the cyclic groups of order \(n\). These are isomorphic to the integers under addition modulo \(n\), a property which we can use to create our implementation. We’re going to eventually want to make different types of groups, so we’re going to need to carefully consider what changes from group to group, and what is the same. The first thing that we observe is that different cyclic groups differ only by their order, so we could quite easily have a single cyclic group class, and set the order when we instantiate it. This is pretty common: groups often come in families defined by some sort of size parameter. A group is defined by what values its elements can take, and the group operation. We might therefore be tempted to think that we need to define a cyclic group element type which can take the relevant values and which implements the group operation. This would be unfortunate for at least two reasons:

  1. Because each group needs several elements, we would need a different element type for each instance of a cyclic group. The number of classes needed would grow very fast!

  2. Adding a new family of groups would require us to add both a group class and a set of element classes. On the basis of parsimony, we would much prefer to only add one class in order to add a new family of groups.

Instead, we can make a single generic element type, and pass the group as an argument when instantiating the element. This is an example of composition: each element has a group. The group will then implement methods which check that element values are allowed for that group, and a method which implements the group operation. Element objects will then delegate validation and the group operation back to the group object.

Finally, we will want an infix operator representing the group operation. Group theorists often use a dot, but we need to choose one of the infix operators that Python supports. We’ll choose *, which is possibly the closest match among Python’s operators. One could easily envisage a more complete implementation of a group, with support for group properties such as generators and element features such as inverses. Our objective here is to develop an understanding of class relations, rather than of algebra, so this minimal characterisation of a group will suffice.

Listing 7.1 A simple implementation of a cyclic group class, and a generic group element.
 7class Element:
 8    """An element of the specified group.
 9
10    Parameters
11    ----------
12    group:
13        The group of which this is an element.
14    value:
15        The individual element value.
16    """
17    def __init__(self, group, value):
18        group._validate(value)
19        self.group = group
20        self.value = value
21
22    def __mul__(self, other):
23        """Use * to represent the group operation."""
24        return Element(self.group,
25                       self.group.operation(self.value, other.value))
26
27    def __str__(self):
28        """Return a string of the form value_group."""
29        return f"{self.value}_{self.group}"
30
31    def __repr__(self):
32        """Return the canonical string representation of the element."""
33        return f"{type(self).__name__}{self.group, self.value!r}"
34
35
36class CyclicGroup:
37    """A cyclic group represented by addition modulo group order."""
38    def __init__(self, order):
39        self.order = order
40
41    def _validate(self, value):
42        """Ensure that value is an allowed element value in this group."""
43        if not (isinstance(value, Integral) and 0 <= value < self.order):
44            raise ValueError("Element value must be an integer"
45                             f" in the range [0, {self.order})")
46
47    def operation(self, a, b):
48        """Perform the group operation on two values.
49
50        The group operation is addition modulo n.
51        """
52        return (a + b) % self.order
53
54    def __call__(self, value):
55        """Create an element of this group."""
56        return Element(self, value)
57
58    def __str__(self):
59        """Represent the group as Gd."""
60        return f"C{self.order}"
61
62    def __repr__(self):
63        """Return the canonical string representation of the group."""
64        return f"{type(self).__name__}({self.order!r})"

Listing 7.1 shows an implementation of our minimal conception of cyclic groups. Before considering it in any detail let’s try it out to observe the concrete effects of the classes:

In [1]: from example_code.groups_basic import CyclicGroup

In [2]: C = CyclicGroup(5)

In [3]: print(C(3) * C(4))
2_C5

We observe that we are able to create the cyclic group of order 5. Due to the definition of the __call__() special method at line 49, we are then able to create elements of the group by calling the group object. The group operation then has the expected effect:

(7.1)\[\begin{split}3_{C_5} \cdot 4_{C_5} &\equiv (3 + 4) \operatorname{mod} 5\\ &= 2\\ &\equiv 2_{C_5}\end{split}\]

Finally, if we attempt to make a group element with a value which is not an integer between 0 and 5, an exception is raised.

In [4]: C(1.5)
--------------------------------------------------------------------------
ValueError                               Traceback (most recent call last)
Cell In[4], line 1
----> 1 C(1.5)

File ~/docs/principles_of_programming/object-oriented-programming/example_code/groups_basic.py:56, in CyclicGroup.__call__(self, value)
     54 def __call__(self, value):
     55     """Create an element of this group."""
---> 56     return Element(self, value)

File ~/docs/principles_of_programming/object-oriented-programming/example_code/groups_basic.py:18, in Element.__init__(self, group, value)
     17 def __init__(self, group, value):
---> 18     group._validate(value)
     19     self.group = group
     20     self.value = value

File ~/docs/principles_of_programming/object-oriented-programming/example_code/groups_basic.py:44, in CyclicGroup._validate(self, value)
     42 """Ensure that value is an allowed element value in this group."""
     43 if not (isinstance(value, Integral) and 0 <= value < self.order):
---> 44     raise ValueError("Element value must be an integer"
     45                      f" in the range [0, {self.order})")

ValueError: Element value must be an integer in the range [0, 5)

Listing 7.1 illustrates composition: on line 19 Element is associated with a group object. This is a classic has a relationship: an element has a group. We might have attempted to construct this the other way around with groups having elements, however this would have immediately hit the issue that elements have exactly one group, while a group might have an unlimited number of elements. Object composition is typically most successful when the relationship is uniquely defined.

This code also demonstrates delegation. In order to avoid having to define different element classes for different groups, the element class does not in substance implement either value validation, or the group operation. Instead, at line 18, validation is delegated to the group by calling group._validate() and at line 25 the implementation of the group operation is delegated to the group by calling self.group.operation().

7.1.2. General linear groups

Video: inheritance.

Imperial students can also watch this video on Panopto.

We still haven’t encountered inheritance, though. Where does that come into the story? Well first we’ll need to introduce at least one more family of groups. For no other reason than convenience, let’s choose \(G_n\), the general linear group of degree \(n\). The elements of this group can be represented as \(n\times n\) invertible square matrices. At least to the extent that real numbers can be represented on a computer, we can implement this group as follows:

Listing 7.2 A basic implementation of the general linear group of a given degree.
 1class GeneralLinearGroup:
 2    """The general linear group represented by degree square matrices."""
 3    def __init__(self, degree):
 4        self.degree = degree
 5
 6    def _validate(self, value):
 7        """Ensure that value is an allowed element value in this group."""
 8        value = np.asarray(value)
 9        if not (value.shape == (self.n, self.n)):
10            raise ValueError("Element value must be a "
11                             f"{self.n} x {self.n}"
12                             "square array.")
13
14    def operation(self, a, b):
15        """Perform the group operation on two values.
16
17        The group operation is matrix multiplication.
18        """
19        return a @ b
20
21    def __call__(self, value):
22        """Create an element of this group."""
23        return Element(self, value)
24
25    def __str__(self):
26        """Represent the group as Gd."""
27        return f"G{self.degree}"
28
29    def __repr__(self):
30        """Return the canonical string representation of the group."""
31        return f"{type(self).__name__}({self.degree!r})"

We won’t illustrate the operation of this class, though the reader is welcome to import the example_code.groups_basic module and experiment. Instead, we simply note that this code is very, very similar to the implementation of CyclicGroup in Listing 7.1. The only functionally important differences are the definitions of the _validate() and operation() methods. self.order is also renamed as self.degree, and C is replaced by G in the string representation. It remains the case that there is a large amount of code repetition between classes. For the reasons we touched on in Section 4.5.4, this is a highly undesirable state of affairs.

7.2. Inheritance

Suppose, instead of copying much of the same code, we had a prototype Group class, and CyclicGroup and GeneralLinearGroup simply specified the ways in which they differ from the prototype. This would avoid the issues associated with repeating code, and would make it obvious how the different group implementations differ. This is exactly what inheritance does.

Listing 7.3 Implementation of a base class for a generic group, and subclasses for the cyclic groups and general linear groups. This code is available in the book repository in example_code/groups.py
 1class Group:
 2    """A base class containing methods common to many groups.
 3
 4    Each subclass represents a family of parametrised groups.
 5
 6    Parameters
 7    ----------
 8    n: int
 9        The primary group parameter, such as order or degree. The
10        precise meaning of n changes from subclass to subclass.
11    """
12    def __init__(self, n):
13        self.n = n
14
15    def __call__(self, value):
16        """Create an element of this group."""
17        return Element(self, value)
18
19    def __str__(self):
20        """Return a string in the form symbol then group parameter."""
21        return f"{self.symbol}{self.n}"
22
23    def __repr__(self):
24        """Return the canonical string representation of the element."""
25        return f"{type(self).__name__}({self.n!r})"
26
27
28class CyclicGroup(Group):
29    """A cyclic group represented by integer addition modulo n."""
30    symbol = "C"
31
32    def _validate(self, value):
33        """Ensure that value is an allowed element value in this group."""
34        if not (isinstance(value, Integral) and 0 <= value < self.n):
35            raise ValueError("Element value must be an integer"
36                            f" in the range [0, {self.n})")
37
38    def operation(self, a, b):
39        """Perform the group operation on two values.
40
41        The group operation is addition modulo n.
42        """
43        return (a + b) % self.n
44
45
46class GeneralLinearGroup(Group):
47    """The general linear group represented by n x n matrices."""
48
49    symbol = "G"
50
51    def _validate(self, value):
52        """Ensure that value is an allowed element value in this group."""
53        value = np.asarray(value)
54        if not (value.shape == (self.n, self.n)):
55            raise ValueError("Element value must be a "
56                            f"{self.n} x {self.n}"
57                            "square array.")
58
59    def operation(self, a, b):
60        """Perform the group operation on two values.
61
62        The group operation is matrix multiplication.
63        """
64        return a @ b

Listing 7.3 shows a new implementation of CyclicGroup and GeneralLinearGroup. These are functionally equivalent to those presented in Listing 7.1 and Listing 7.2 but have all the repeated code removed. The code common to both families of groups is instead placed in the Group class. In the following sections we will highlight the features of this code which make this work.

7.2.1. Inheritance syntax

Look again at the definition of CyclicGroup on line 28:

28class CyclicGroup(Group):

This differs from the previous class definitions we’ve seen in that the name of the class we’re defining, CyclicGroup is followed by another class name in brackets, Group. This syntax is how inheritance is defined. It means that CyclicGroup is a child class of Group. The effect of this is that any attribute defined on the parent class is also defined (is inherited) on the child class. In this case, CyclicGroup does not define __init__(), __call__(), __str__(), or __repr__(). If and when any of those methods are called, it is the methods from the parent class, Group which are used. This is the mechanism that enables methods to be shared by different classes. In this case, CyclicGroup and GeneralLinearGroup share these methods. A user could also define another class which inherited from Group, for example to implement another family of groups.

7.2.2. Class attributes

At line 30 of Listing 7.3, the name symbol is assigned to:

30symbol = "C"

This is also different from our previous experience: usually if we want to set a value on an object then we do so from inside a method, and we set a data attribute on the current instance, self, using the syntax:

self.symbol = "C"

This more familiar code sets an instance attribute. In other words, an attribute specific to each object of the class. Our new version of the code instead sets a single attribute that is common to all objects of this class. This is called a class attribute.

7.2.3. Attributes resolve at runtime

Consider the __str__() method of Group:

19def __str__(self):
20    """Return a string in the form symbol then group parameter."""
21    return f"{self.symbol}{self.n}"

This code uses self.symbol, but this attribute isn’t defined anywhere on Group. Why doesn’t this cause an AttributeError to be raised? One answer is that it indeed would if we were to instantiate Group itself:

In [1]: from example_code.groups import Group

In [2]: g = Group(1)

In [3]: print(g)
--------------------------------------------------------------------------
AttributeError                           Traceback (most recent call last)
Cell In[3], line 1
----> 1 print(g)

File ~/docs/principles_of_programming/object-oriented-programming/example_code/groups.py:62, in Group.__str__(self)
     60 def __str__(self):
     61     """Return a string in the form symbol then group parameter."""
---> 62     return f"{self.symbol}{self.n}"

AttributeError: 'Group' object has no attribute 'symbol'

In fact, Group is never supposed to be instantiated, it plays the role of an abstract base class. In other words, its role is to provide functionality to classes that inherit from it, rather than to be the type of objects itself. We will return to this in more detail in Section 10.2.

However, if we instead instantiate CyclicGroup then everything works:

In [1]: from example_code.groups import CyclicGroup

In [2]: g = CyclicGroup(1)

In [3]: print(g)
C1

The reason is that the code in methods is only executed when that method is called, and the object self is the actual concrete class instance, with all of the attributes that are defined for it. In this case, even though __str__() is defined on Group, self has type CyclicGroup, and therefore self.symbol is well-defined and has the value "C".

7.2.4. Parametrising over class

When we create a class, there is always the possibility that someone will come along later and create a subclass of it. It is therefore an important design principle to avoid doing anything which might cause a problem in a subclass. One important example of this is anywhere where it is assumed that the class of self is in fact the current class and not some subclass of it. For this reason, it is almost always a bad idea to explicitly use the name of the current class inside its definition. Instead, we should use the fact that type(self) returns the type (i.e. class) of the current object. It is for this reason that we typically use the formula type(self).__name__ in the __repr__() method of an object. A similar procedure applies if we need to create another object of the same class as the current object. For example, one might create the next larger Group than the current one with:

type(self)(self.n+1)

Observe that since type(self) is a class, we can instantiate it by calling it.

7.3. Calling parent class methods

Listing 7.4 The elementary rectangle class from example_code.shapes.
 1class Rectangle:
 2
 3    def __init__(self, length, width):
 4        self.length = length
 5        self.width = width
 6
 7    def area(self):
 8        return self.length * self.width
 9
10    def __repr__(self):
11        return f"{type(self).__name__}{self.length, self.width!r}"

Listing 7.4 shows a basic implementation of a class describing a rectangle. We might also want a class defining a square. Rather than redefining everything from scratch, we might choose to inherit from Rectangle by defining a square as a rectangle whose length and width are equal. The constructor for our new class will, naturally, just take a single length parameter. However the area() method that we will inherit expects both self.length and self.width to be defined. We could simply define both length and width in Square.__init__(), but this is exactly the sort of copy and paste code that inheritance is supposed to avoid. If the parameters to Rectangle.__init__() were to be changed at some future point, then having self.length and self.width defined in two separate places is likely to lead to very confusing bugs.

Instead, we would like to have Square.__init__() call Rectangle.__init__() and pass the same value for both length and width. It is perfectly possible to directly call Rectangle.__init__(), but this breaks the style rule that we should not repeat ourselves: if Square already inherits from Rectangle then it should not be necessary to restate that inheritance by explicitly naming the parent class. Fortunately, Python provides the functionality we need in the form of the super() function. Listing 7.5 demonstrates its application.

Listing 7.5 example_code.shapes.Square inherits from Rectangle and calls the latter’s constructor using super().
1class Square(Rectangle):
2
3    def __init__(self, length):
4        super().__init__(length, length)
5
6    def __repr__(self):
7        return f"{type(self).__name__}({self.length!r})"

The super() function returns a version of the current object in which none of the methods have been overridden by the current class. This has the effect that the superclasses of the current class are searched in increasing inheritance order until a matching method name is found, and this method is then called. This provides a safe mechanism for calling parent class methods in a way that responds appropriately if someone later comes back and rewrites the inheritance relationships of the classes involved.

7.4. Creating new exception classes

Python provides a wide range of exceptions, and usually the right thing to do when writing code that might need to raise an exception is to peruse the list of built-in exceptions and choose the one which best matches the circumstances. However, sometimes there is no good match, or it might be that the programmer wants user code to be able to catch exactly this exception without the risk that some other operation will raise the same exception and be caught by mistake. In this case, it is necessary to create a new type of exception.

A new exception will be a new class which inherits from another exception class. In most cases, the only argument that the exception constructor takes is an error message, and the base Exception class already takes this. This means that the subclass definition may only need to define the new class. Now, a class definition is a Python block and, as a matter of syntax, a block cannot be empty. Fortunately, the Python language caters for this situation with the pass statement, which simply does nothing. For example, suppose we need to be able to distinguish the ValueError which occurs in entity validation from other occurrences of ValueError. For example it might be advantageous to enable a user to catch exactly these errors. In this case, we’re still talking about some form of value error, so we’ll want our new error class to inherit from ValueError. We could achieve this as follows:

class GroupValidationError(ValueError):
    pass

7.5. Glossary

child class

A class which inherits directly from one or more parent classes. The child class automatically has all of the methods of the parent classes, unless it declares its own methods with the same names.

class attribute

An attribute which is declared directly on a class. All instances of a class see the same value of a class attribute.

composition

The process of making a more complex object from other objects by including the constituent objects as attributes of the more composite object. Composition can be characterised as a has a relationship, in contrast to inheritance, which embodies an is a relationship.

delegation

A design pattern in which an object avoids implementing a method by instead calling a method on another object.

inheritance

The process of making a new class by extending or modifying one or more existing classes.

parent class

A class from which another class, referred to as a child class, inherits. Inheritance can be characterised as an is a relationship, in contrast to composition, which embodies an has a relationship.

subclass

A class A is a subclass of the class B if A inherits from B either directly or indirectly. That is, if B is a parent, grandparent, great grandparent or further ancestor of A. Contrast superclass.

superclass

A class A is a superclass of the class B if B inherits from A either directly or indirectly. That is, if B is a subclass of A.

7.6. Exercises

Using the information on the book website obtain the skeleton code for these exercises.

Exercise

The symmetric group over n symbols is the group whose members are all the permutations of n symbols and whose group operation is the composition of those permutations: \(a \cdot b = a(b)\).

In the exercise repository, create package called groups containing a module called symmetric_groups. Define a new class SymmetricGroup which inherits from example_code.groups.Group and implements the symmetric group of order n. You will need to implement the group operation and the validation of group element values. Group elements can be represented by sequences containing permutations of the integers from 0 to n-1. You will find it advantageous to represent these permutations as numpy.ndarray because the indexing rules for that type mean that the group operation can simply be implemented by indexing the first permutation with the second: a[b].

You will also need to set the class attribute symbol. For this group, this should take the value S.

Hint

You will need to import example_code.groups.Group from the object_oriented_programming repository that you installed in Section 2.8. You should also git pull in that repository in order to get any changes that have happened in the intervening period.

Hint

In implementing element validation, the builtin function sorted() is likely to be useful.

Exercise

The objective of this exercise is to create subclasses of the built-in set class which are only valid for values which pass a certain test. For example, one might have a set which can only contain integers.

  1. In the exercise repository, create a package called sets containing a module verified_sets. Create a subclass of the inbuilt set, sets.verified_sets.VerifiedSet. VerifiedSet will itself be the parent of other classes which have particular verification rules.

  2. Give VerifiedSet a method _verify() which takes a single value. In the case of VerifiedSet, _verify() should unconditionally raise NotImplementedError. Subclasses of VerifiedSet will override this method to do something more useful.

  3. For each set method which adds items to the set, VerifiedSet will need to have its own version which calls _verify() on each item, before calling the appropriate superclass method in order to actually insert the value(s). The methods which add items to a set are add(), update(), and symmetric_difference_update().

  4. For those methods which create a new set, VerifiedSet will also need to instantiate a new object, so that the method returns a subclass of VerifiedSet instead of a plain set. The methods to which this applies are union(), intersection(), difference(), symmetric_difference(), and copy().

  5. Create a subclass of VerifiedSet called IntSet in which only integers (i.e. instances of numbers.Integral) are allowed. On encountering a non-integer IntSet._verify() should raise TypeError with an error message of the following form. For example if an attempt were made to add a string to the set, the message would be “IntSet expected an integer, got a str.”.

  6. Create a subclass of VerifiedSet called UniqueSet into which values can only be added if they are not already in the set. You should create a new exception UniquenessError, a subclass of KeyError. UniqueSet._verify should raise this if an operation would add a duplicate value to the UniqueSet.

Footnotes