7. Inheritance and composition¶
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¶
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:
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!
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.
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:
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¶
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:
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.
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¶
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.
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 classB
ifA
inherits fromB
either directly or indirectly. That is, ifB
is a parent, grandparent, great grandparent or further ancestor ofA
. Contrast superclass.- superclass¶
A class
A
is a superclass of the classB
ifB
inherits fromA
either directly or indirectly. That is, ifB
is a subclass ofA
.
7.6. Exercises¶
Using the information on the book website obtain the skeleton code for these exercises.
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.
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.
In the exercise repository, create a package called
sets
containing a moduleverified_sets
. Create a subclass of the inbuiltset
,sets.verified_sets.VerifiedSet
.VerifiedSet
will itself be the parent of other classes which have particular verification rules.Give
VerifiedSet
a method_verify()
which takes a single value. In the case ofVerifiedSet
,_verify()
should unconditionally raiseNotImplementedError
. Subclasses ofVerifiedSet
will override this method to do something more useful.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 areadd()
,update()
, andsymmetric_difference_update()
.For those methods which create a new set,
VerifiedSet
will also need to instantiate a new object, so that the method returns a subclass ofVerifiedSet
instead of a plainset
. The methods to which this applies areunion()
,intersection()
,difference()
,symmetric_difference()
, andcopy()
.Create a subclass of
VerifiedSet
calledIntSet
in which only integers (i.e. instances ofnumbers.Integral
) are allowed. On encountering a non-integerIntSet._verify()
should raiseTypeError
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.”.Create a subclass of
VerifiedSet
calledUniqueSet
into which values can only be added if they are not already in the set. You should create a new exceptionUniquenessError
, a subclass ofKeyError
.UniqueSet._verify
should raise this if an operation would add a duplicate value to theUniqueSet
.
Footnotes