.. details:: Video: inheritance and composition. .. vimeo:: 516216973 .. only:: html Imperial students can also `watch this video on Panopto <https://imperial.cloud.panopto.eu/Panopto/Pages/Viewer.aspx?id=b40b76c6-ba53-4e0f-84e9-ae1c00db5322>`__.
A key feature of abstractions is :term:`composability <composition>`: the ability to make a complex object or operation out of several components. We can compose objects by simply making one object an :term:`attribute` of another object. This combines objects in a has a relationship. For example the :class:`~example_code.polynomial.Polynomial` class introduced in :numref:`Chapter %s <objects>` has a :class:`tuple` of coefficients. Object composition of this sort is a core part of :term:`encapsulation`.
Another way of composing abstractions is to make a new :term:`class` by modifying another class. Typically this is employed to make a more specialised :term:`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 :class:`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 :class:`int`:
In [3]: type(1)
Out[3]: int
In [4]: isinstance(1, int)
Out[4]: True
So 1, it turns out, has :term:`type` :class:`int`, and so is an instance of :class:`int`, but is also somehow an instance of :class:`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 :class:`int` is a :term:`subclass` of :class:`numbers.Number`. Just as :func:`isinstance` provides a mechanism for determining whether an object is an :term:`instance` of a :term:`class`, :func:`issubclass` will tell us when one :term:`class` is a :term:`subclass` of another:
In [5]: issubclass(int, Number)
Out[5]: True
.. only:: book .. raw:: latex \clearpage
In fact, there is a whole hierarchy of numeric types in :mod:`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 :func:`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 :term:`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: :term:`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.
.. details:: Video: an example from group theory. .. vimeo:: 516277973 .. only:: html Imperial students can also `watch this video on Panopto <https://imperial.cloud.panopto.eu/Panopto/Pages/Viewer.aspx?id=1110c00a-dba2-4d7b-895a-ae1c00db630f>`__.
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.
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 :term:`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 :term:`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 :term:`argument` when instantiating the element. This is an example of :term:`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 :term:`delegate <delegation>` validation and the group operation back to the group object.
Finally, we will want an :term:`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.
class Element:
"""An element of the specified group.
Parameters
----------
group:
The group of which this is an element.
value:
The individual element value.
"""
def __init__(self, group, value):
group._validate(value)
self.group = group
self.value = value
def __mul__(self, other):
"""Use * to represent the group operation."""
return Element(self.group,
self.group.operation(self.value, other.value))
def __str__(self):
"""Return a string of the form value_group."""
return f"{self.value}_{self.group}"
def __repr__(self):
"""Return the canonical string representation of the element."""
return f"{type(self).__name__}{self.group, self.value!r}"
class CyclicGroup:
"""A cyclic group represented by addition modulo group order."""
def __init__(self, order):
self.order = order
def _validate(self, value):
"""Ensure that value is an allowed element value in this group."""
if not (isinstance(value, Integral) and 0 <= value < self.order):
raise ValueError("Element value must be an integer"
f" in the range [0, {self.order})")
def operation(self, a, b):
"""Perform the group operation on two values.
The group operation is addition modulo n.
"""
return (a + b) % self.order
def __call__(self, value):
"""Create an element of this group."""
return Element(self, value)
def __str__(self):
"""Represent the group as Gd."""
return f"C{self.order}"
def __repr__(self):
"""Return the canonical string representation of the group."""
return f"{type(self).__name__}({self.order!r})"
:numref:`cyclic_group` 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 :meth:`~object.__call__` :term:`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:
3_{C_5} \cdot 4_{C_5} &\equiv (3 + 4) \operatorname{mod} 5\\ &= 2\\ &\equiv 2_{C_5}
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)
:numref:`cyclic_group` illustrates :term:`composition`: on line 19 :class:`~example_code.groups_basic.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 :term:`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 :meth:`group._validate` and at line 25 the implementation of the group operation is delegated to the group by calling :meth:`self.group.operation`.
.. details:: Video: inheritance. .. vimeo:: 516698411 .. only:: html Imperial students can also `watch this video on Panopto <https://imperial.cloud.panopto.eu/Panopto/Pages/Viewer.aspx?id=14c473ff-f126-4348-b6d5-ae1c00db6c07>`__.
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:
class GeneralLinearGroup:
"""The general linear group represented by degree square matrices."""
def __init__(self, degree):
self.degree = degree
def _validate(self, value):
"""Ensure that value is an allowed element value in this group."""
value = np.asarray(value)
if not (value.shape == (self.n, self.n)):
raise ValueError("Element value must be a "
f"{self.n} x {self.n}"
"square array.")
def operation(self, a, b):
"""Perform the group operation on two values.
The group operation is matrix multiplication.
"""
return a @ b
def __call__(self, value):
"""Create an element of this group."""
return Element(self, value)
def __str__(self):
"""Represent the group as Gd."""
return f"G{self.degree}"
def __repr__(self):
"""Return the canonical string representation of the group."""
return f"{type(self).__name__}({self.degree!r})"
We won't illustrate the operation of this class, though the reader is welcome to :keyword:`import` the :mod:`example_code.groups_basic` module and experiment. Instead, we simply note that this code is very, very similar to the implementation of :class:`~example_code.groups_basic.CyclicGroup` in :numref:`cyclic_group`. The only functionally important differences are the definitions of the :meth:`_validate` and :meth:`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 :numref:`repetition`, this is a highly undesirable state of affairs.
Suppose, instead of copying much of the same code, we had a prototype :class:`Group` class, and :class:`CyclicGroup` and :class:`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.
class Group:
"""A base class containing methods common to many groups.
Each subclass represents a family of parametrised groups.
Parameters
----------
n: int
The primary group parameter, such as order or degree. The
precise meaning of n changes from subclass to subclass.
"""
def __init__(self, n):
self.n = n
def __call__(self, value):
"""Create an element of this group."""
return Element(self, value)
def __str__(self):
"""Return a string in the form symbol then group parameter."""
return f"{self.symbol}{self.n}"
def __repr__(self):
"""Return the canonical string representation of the element."""
return f"{type(self).__name__}({self.n!r})"
class CyclicGroup(Group):
"""A cyclic group represented by integer addition modulo n."""
symbol = "C"
def _validate(self, value):
"""Ensure that value is an allowed element value in this group."""
if not (isinstance(value, Integral) and 0 <= value < self.n):
raise ValueError("Element value must be an integer"
f" in the range [0, {self.n})")
def operation(self, a, b):
"""Perform the group operation on two values.
The group operation is addition modulo n.
"""
return (a + b) % self.n
class GeneralLinearGroup(Group):
"""The general linear group represented by n x n matrices."""
symbol = "G"
def _validate(self, value):
"""Ensure that value is an allowed element value in this group."""
value = np.asarray(value)
if not (value.shape == (self.n, self.n)):
raise ValueError("Element value must be a "
f"{self.n} x {self.n}"
"square array.")
def operation(self, a, b):
"""Perform the group operation on two values.
The group operation is matrix multiplication.
"""
return a @ b
:numref:`groups_inheritance` shows a new implementation of :class:`~example_code.groups.CyclicGroup` and :class:`~example_code.groups.GeneralLinearGroup`. These are functionally equivalent to those presented in :numref:`cyclic_group` and :numref:`general_linear_group` but have all the repeated code removed. The code common to both families of groups is instead placed in the :class:`~example_code.groups.Group` class. In the following sections we will highlight the features of this code which make this work.
Look again at the definition of :class:`~example_code.groups.CyclicGroup` on line 28:
class CyclicGroup(Group):
This differs from the previous class definitions we've seen in that the name of the class we're defining, :class:`CyclicGroup` is followed by another class name in brackets, :class:`Group`. This :term:`syntax` is how inheritance is defined. It means that :class:`CyclicGroup` is a :term:`child class` of :class:`Group`. The effect of this is that any :term:`attribute` defined on the :term:`parent class` is also defined (is inherited) on the child class. In this case, :class:`CyclicGroup` does not define :meth:`__init__`, :meth:`__call__`, :meth:`__str__`, or :meth:`__repr__`. If and when any of those :term:`methods <method>` are called, it is the methods from the parent class, :class:`Group` which are used. This is the mechanism that enables methods to be shared by different classes. In this case, :class:`~example_code.groups.CyclicGroup` and :class:`~example_code.groups.GeneralLinearGroup` share these methods. A user could also define another class which inherited from :class:`~example_code.groups.Group`, for example to implement another family of groups.
At line 30 of :numref:`groups_inheritance`, the name :attr:`symbol` is assigned to:
symbol = "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 :term:`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 :term:`class attribute`.
Consider the :meth:`__str__` method of :class:`~example_code.groups.Group`:
def __str__(self):
"""Return a string in the form symbol then group parameter."""
return f"{self.symbol}{self.n}"
This code uses self.symbol, but this attribute isn't defined anywhere on :class:`~example_code.groups.Group`. Why doesn't this cause an :class:`AttributeError` to be raised? One answer is that it indeed would if we were to instantiate :class:`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, :class:`Group` is never supposed to be instantiated, it plays the role of an :term:`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 :numref:`abstract_base_classes`.
However, if we instead instantiate :class:`~example_code.groups.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 :meth:`__str__` is defined on :class:`Group`, self has type :class:`CyclicGroup`, and therefore self.symbol is well-defined and has the value "C".
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 :data:`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 :meth:`~object.__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 :class:`~example_code.groups.Group` than the current one with:
type(self)(self.n+1)
Observe that since type(self) is a :term:`class`, we can :term:`instantiate` it by calling it.
class Rectangle:
def __init__(self, length, width):
self.length = length
self.width = width
def area(self):
return self.length * self.width
def __repr__(self):
return f"{type(self).__name__}{self.length, self.width!r}"
:numref:`rectangle_class` 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 :term:`inherit <inheritance>` from :class:`~example_code.shapes.Rectangle` by defining a square as a rectangle whose length and width are equal. The :term:`constructor` for our new class will, naturally, just take a single length parameter. However the :meth:`~example_code.shapes.Rectangle.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 :meth:`Square.__init__`, but this is exactly the sort of copy and paste code that inheritance is supposed to avoid. If the parameters to :meth:`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 :meth:`Square.__init__` call :meth:`Rectangle.__init__` and pass the same value for both length and width. It is perfectly possible to directly call :meth:`Rectangle.__init__`, but this breaks the style rule that we should not repeat ourselves: if :class:`Square` already inherits from :class:`~example_code.shapes.Rectangle` then it should not be necessary to restate that inheritance by explicitly naming the :term:`parent class`. Fortunately, Python provides the functionality we need in the form of the :func:`super` function. :numref:`square_class` demonstrates its application.
class Square(Rectangle):
def __init__(self, length):
super().__init__(length, length)
def __repr__(self):
return f"{type(self).__name__}({self.length!r})"
The :func:`super` function returns a version of the current object in which none of the :term:`methods <method>` have been overridden by the current :term:`class`. This has the effect that the :term:`superclasses <superclass>` 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.
Python provides a wide range of :term:`exceptions <exception>`, and usually the right thing to do when writing code that might need to raise an exception is to peruse the :external:doc:`list of built-in exceptions <library/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 :term:`class` which inherits from another exception class. In most cases, the only argument that the exception :term:`constructor` takes is an error message, and the base :class:`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 :term:`syntax`, a block cannot be empty. Fortunately, the Python language caters for this situation with the :keyword:`pass` statement, which simply does nothing. For example, suppose we need to be able to distinguish the :class:`ValueError` which occurs in entity validation from other occurrences of :class:`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 :class:`ValueError`. We could achieve this as follows:
class GroupValidationError(ValueError):
pass
.. glossary:: :sorted: child class A class which :term:`inherits <inheritance>` directly from one or more :term:`parent classes <parent class>`. The child class automatically has all of the :term:`methods <method>` of the parent classes, unless it declares its own methods with the same names. class attribute An :term:`attribute` which is declared directly on a :term:`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 :term:`inheritance`, which embodies an *is a* relationship. delegation A design pattern in which an object avoids implementing a :term:`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 :term:`child class`, inherits. Inheritance can be characterised as an *is a* relationship, in contrast to :term:`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 :term:`parent <parent class>`, grandparent, great grandparent or further ancestor of `A`. Contrast :term:`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 :term:`subclass` of `A`.
.. only:: not book Using the information on the `book website <https://object-oriented-python.github.io/edition3/exercises.html>`__ obtain the skeleton code for these exercises.
.. only:: book Using the information on the book website [#exercise_page]_ obtain the skeleton code for these exercises.
.. proof: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: :math:`a \cdot b = a(b)`. In the exercise repository, create package called :mod:`groups` containing a module called :mod:`symmetric_groups`. Define a new class :class:`SymmetricGroup` which inherits from :class:`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 :class:`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 :term:`class attribute` :attr:`symbol`. For this group, this should take the value `S`. .. hint:: You will need to :keyword:`import` :class:`example_code.groups.Group` from the `object_oriented_programming` repository that you installed in :numref:`programs_in_files_exercises`. 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 :func:`sorted` is likely to be useful.
.. proof:exercise:: The objective of this exercise is to create subclasses of the built-in :class:`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 :mod:`sets` containing a module `verified_sets`. Create a subclass of the inbuilt :class:`set`, :class:`sets.verified_sets.VerifiedSet`. :class:`VerifiedSet` will itself be the parent of other classes which have particular verification rules. 2. Give :class:`VerifiedSet` a method :meth:`_verify` which takes a single value. In the case of :class:`VerifiedSet`, :meth:`_verify` should unconditionally raise :class:`NotImplementedError`. Subclasses of :class:`VerifiedSet` will override this method to do something more useful. 3. For each :class:`set` method which adds items to the set, :class:`VerifiedSet` will need to have its own version which calls :meth:`_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 :meth:`~frozenset.add`, :meth:`~frozenset.update`, and :meth:`~frozenset.symmetric_difference_update`. 4. For those methods which create a new set, :class:`VerifiedSet` will also need to :term:`instantiate` a new object, so that the method returns a subclass of :class:`VerifiedSet` instead of a plain :class:`set`. The methods to which this applies are :meth:`~frozenset.union`, :meth:`~frozenset.intersection`, :meth:`~frozenset.difference`, :meth:`~frozenset.symmetric_difference`, and :meth:`~frozenset.copy`. 5. Create a subclass of :class:`VerifiedSet` called :class:`IntSet` in which only integers (i.e. instances of :class:`numbers.Integral`) are allowed. On encountering a non-integer :meth:`IntSet._verify` should raise :class:`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 :class:`VerifiedSet` called :class:`UniqueSet` into which values can only be added if they are not already in the set. You should create a new exception :class:`UniquenessError`, a subclass of :class:`KeyError`. :class:`UniqueSet._verify` should raise this if an operation would add a duplicate value to the :class:`UniqueSet`.
Footnotes
[1] | https://object-oriented-python.github.io/edition3/exercises.html |