10. Further object-oriented features¶
In this chapter, we’ll tie up a few loose ends by examining in detail some programming concepts and Python features which we have encountered but not really studied in the preceding chapters.
10.1. Decorators¶
In Chapter 9 we encountered the
functools.singledispatch()
decorator, which turns a function into a
single dispatch function. More generally, a decorator is a function
which takes in a function and returns another function. In other words, the
following:
@dec
def func(...):
...
is equivalent to:
def func(...):
...
func = dec(func)
Decorators are therefore merely syntactic sugar, but can be very useful
in removing the need for boiler-plate code at the top of functions. For
example, your code for Exercise %s
probably contains a lot
of repeated code a similar to the following:
def __add__(self, other):
"""Return the Expr for the sum of this Expr and another."""
if isinstance(other, numbers.Number):
other = Number(other)
return Add(self, other)
We could define a decorator to clean up this code as follows:
1from functools import wraps
2
3def make_other_expr(meth):
4 """Cast the second argument of a method to Number when needed."""
5 @wraps(meth)
6 def fn(self, other):
7 if isinstance(other, numbers.Number):
8 other = Number(other)
9 return meth(self, other)
10 return fn
Now, each time we write one of the special methods of Expr
, we can
instead write something like the following:
@make_other_expr
def __add__(self, other):
"""Return the Expr for the sum of this Expr and another."""
return Add(self, other)
Let’s look closely at what the decorator in Listing 10.1 does. The
decorator takes in one function, meth()
an returns another one,
fn()
. Notice that we let fn()
take the same arguments as
meth()
. If you wanted to write a more generic decorator that worked on
functions with different signatures, then you could define function as
fn(*args, **kwargs)
and pass these through to meth()
.
The contents of fn()
are what will be executed every time meth()
is
called. We use this to check the type of other
and cast it to
Number
, and then call the original meth()
on the modified arguments.
We could also execute code that acts on the value that meth()
returns. To
do this we would assign the result of meth()
to a variable and then
include more code after line 9.
Finally, notice that we have wrapped fn
in another decorator,
functools.wraps()
. The purpose of this decorator is to copy the name and
docstring from meth()
to fn()
. The effect of this is that if the
user calls help()
on a decorated function then they will see the name and
docstring for the original function, and not that of the decorator.
10.1.1. Decorators which take arguments¶
Our make_other_expr
decorator doesn’t have brackets after its name, and doesn’t
take any arguments. However functools.wraps()
does have brackets, and takes a
function name as an argument. How does this work? The answer is yet another
wrapper function. A decorator is a function which takes a function and
returns a function. functools.wraps()
takes an argument (it happens to be
a function but other decorators take other types) and returns a decorator
function. That is, it is a function which takes in arguments and returns a
function which takes a function and returns a function. It’s functions all the
way down!
10.1.2. The property decorator¶
Back in Chapter 3, we gave the
Polynomial
class a
degree()
method:
def degree(self):
return len(self.coefficients) - 1
This enables the following code to work:
In [1]: from example_code.polynomial import Polynomial
In [2]: p = Polynomial((1, 2, 4))
In [3]: p.degree()
Out[3]: 2
However, the empty brackets at the end of degree()
are a bit clunky: why
should we have to provide empty brackets if there are no arguments to pass?
This represents a failure of encapsulation, because we
shouldn’t know or care from the outside whether
degree()
is a method or a
data attribute. Indeed, the developer of the
polynomial
module should be able to change that
implementation without changing the interface. This is where the
built-in property
decorator comes in. property
transforms
methods that take no arguments other than the object itself into attributes.
So, if we had instead defined:
@property
def degree(self):
return len(self.coefficients) - 1
Then degree
would be accessible as an attribute:
In [1]: from example_code.polynomial import Polynomial
In [2]: p = Polynomial((1, 2, 4))
In [3]: p.degree
Out[3]: 2
10.1.3. The functools
module¶
The functools
module is part of the Python Standard Library. It provides a collection of core higher order
functions, some of which we have already met earlier
in the course. Since decorators are an important class of higher order
function, it is unsurprising that functools
provides several very useful
ones. We will survey just a few here:
functools.cache
Some functions can be very expensive to compute, and may be called repeatedly. A cache stores the results of previous function calls. If the function is called again with a combination of argument values that have previously been used, the function result is returned from the cache instead of the function being called again. This is a trade-off of execution time against memory usage, so one has to be careful how much memory will be consumed by the cache.
functools.lru_cache
A least recently used cache is a limited size cache where the least recently accessed items will be discarded if the cache is full. This has the advantage that the memory usage is bounded, but the drawback that cache eviction may take time, and that more recomputation may occur than in an unbounded cache.
functools.singledispatch
We met this in Section 9.4.3. This decorator transforms a function into a single dispatch function.
10.2. Abstract base classes¶
We have now on several occasions encountered classes which are not designed to
be instantiated themselves, but merely serve as parent classes to concrete
classes which are intended to be instantiated. Examples of these classes
include numbers.Number
, example_code.groups.Group
, and the
Expr
, Operator
, and Terminal
classes from
Chapter 9. These classes that are only ever parents are
called abstract base classes. They are abstract
in the sense that they define (some of the) properties of their children, but
without providing full implementations of them. They are base classes in the
sense that they are intended to be inherited from.
Abstract base classes typically fulfil two related roles: they provide the definition of an interface that child classes can be expected to follow, and they provide a useful way of checking that an object of a concrete class has particular properties.
10.2.1. The abc
module¶
The concept of an abstract base class is itself an abstraction: an
abstract base class is simply a class which is designed not to be instantiated.
This requires no support from particular language features. Nonetheless, there
are features that a language can provide which makes the creation of useful
abstract base classes easy. In Python, these features are provided by the
abc
module in the Standard Library.
The abc
module provides the ABC
class. This is itself an
abstract base class: there is no need to ever make an object of type
ABC
. Instead, classes inherit from ABC
in order to
access features that it provides.
10.2.2. Abstract methods¶
Let’s look back at the groups example from Chapter 7. We
defined the base Group
class and specified that
child classes had to implement the _validate()
and operator()
methods as well as the symbol
class attribute. But how should
we actually know that these methods and attribute are required? This might be
documented, but that is somewhat hit and miss: it is often less than
completely obvious where to look for the documentation. Abstract methods
provide a much more satisfactory solution to this problem. The
example_code.groups_abc
module is an update of the
example_code.groups
module which uses the ABC
class.
1from abc import ABC, abstractmethod
2
3class Group(ABC):
4 """A base class containing methods common to many groups.
5
6 Each subclass represents a family of parametrised groups.
7
8 Parameters
9 ----------
10 n: int
11 The primary group parameter, such as order or degree. The
12 precise meaning of n changes from subclass to subclass.
13 """
14
15 def __init__(self, n):
16 self.n = n
17
18 @property
19 @abstractmethod
20 def symbol(self):
21 """Represent the group name as a character."""
22 pass
23
24 @abstractmethod
25 def _validate(self, value):
26 """Ensure that value is an allowed element value in this group."""
27 pass
28
29 @abstractmethod
30 def operation(self, a, b):
31 """Return a ∘ b using the group operation ∘."""
32 pass
33
34 def __call__(self, value):
35 """Create an element of this group."""
36 return Element(self, value)
37
38 def __str__(self):
39 """Return a string in the form symbol then group parameter."""
40 return f"{self.symbol}{self.n}"
41
42 def __repr__(self):
43 """Return the canonical string representation of the element."""
44 return f"{type(self).__name__}({repr(self.n)})"
There are a few features of Listing 10.2 which are noteworthy. First,
observe that Group
now inherits from
ABC
. This simply enables the features that we will use next.
The new Group
class has
_validate()
and
operator()
methods, but these don’t
actually do anything (their contents are merely pass
). They are,
however, decorated with abstractmethod
. The effect of this decorator can
be observed if we try to instantiate Group
:
In [1]: from example_code.groups_abc import Group
In [2]: Group(1)
--------------------------------------------------------------------------
TypeError Traceback (most recent call last)
Cell In [2], line 1
----> 1 Group(1)
TypeError: Can't instantiate abstract class Group with abstract methods _validate, operation, symbol
The combination of inheriting from ABC
and the
abstractmethod
decorator has the effect that instantiating this class is
an error, and we are told why. We have skipped over the symbol
attribute.
There is no abstractattribute
decorator, but the same effect can be achieved
by creating an abstractmethod
and converting it into a data attribute using
property
. In this case, the order of decorators is important:
abstractmethod
always needs to be the last, innermost, decorator.
The subclasses of Group
that we defined,
define all three of these attributes, so they can still be instantiated. For
example:
In [1]: from example_code.groups_abc import CyclicGroup
In [2]: C = CyclicGroup(3)
In [3]: print(C(1) * C(2))
0_C3
This illustrates the utility of this use of abstract base classes: the base class can specify what subclasses need to implement. If a subclass does not implement all the right attributes then a helpful error is generated, and subclasses that do implement the class fully work as expected.
10.2.3. Duck typing¶
Before we turn to the second use of abstract base classes, it is useful to divert our attention to what might be thought of as the type philosophy of Python. Many programming languages are strongly typed. This means that in situations such as passing arguments to functions, the type of each variable is specified, and it is an error to pass a variable of the wrong type. This is not the Python approach. Instead, Python functions typically do not check the types of their arguments beyond ensuring that they have the basic properties required of the operation in question. It doesn’t really matter what the type of an object is, so long as it has the operations, methods, and attributes required.
The term that is used for this approach to data types is duck typing: if a data type walks like a duck and quacks like a duck, then it might as well be a duck. This does, however, beg the question of how a program should know if an object has the right properties in a given circumstance. It is here that the second use of abstract base classes comes into play.
10.2.4. Virtual subclasses¶
We learned in Chapter 3 that we can determine if a type is a
number by checking if it is an instance of numbers.Number
. This is a
slightly different usage of abstract base classes. Rather than providing part
of the implementation of types such as float
, numbers.Number
provides
a categorisation of objects into numbers and non-numbers. This aids duck
typing, by enabling much more general type checking.
The Standard Library contains many abstract base classes
whose role is to support duck typing by identifying objects with particular
properties. For example, the collections.abc
module provides abstract
base classes for container objects with particular properties. The
collections.abc.Iterable
abstract base class groups all iterable
containers. For example:
In [1]: from collections.abc import Iterable
In [2]: from example_code.linked_list import Link
In [3]: issubclass(Link, Iterable)
Out[3]: True
Hang on, though, what magic is this? We didn’t declare
Link
as inheriting from
Iterable
.
What is going on here is a form of reverse inheritance process. Rather than
Link
declaring that it inherits from
Iterable
, Iterable
determines that Link
is its subclass. It’s a sort
of adoption mechanism for classes. Of course the authors of the Standard
Library don’t know that we will declare Link
, so
there is no code explicitly claiming Link
as a
subclass of Iterable
. Instead, any class which
implements the __iter__()
special method is a subclass of
Iterable
. How does this work? Well,
isinstance()
and issubclass()
are implemented with the help of, you
guessed it, yet another special method. This time the special method is
__subclasshook__()
.
1from abc import ABCMeta, abstractmethod
2
3...
4
5def _check_methods(C, *methods):
6 mro = C.__mro__
7 for method in methods:
8 for B in mro:
9 if method in B.__dict__:
10 if B.__dict__[method] is None:
11 return NotImplemented
12 break
13 else:
14 return NotImplemented
15 return True
16
17...
18
19class Iterable(metaclass=ABCMeta):
20
21 __slots__ = ()
22
23 @abstractmethod
24 def __iter__(self):
25 while False:
26 yield None
27
28 @classmethod
29 def __subclasshook__(cls, C):
30 if cls is Iterable:
31 return _check_methods(C, "__iter__")
32 return NotImplemented
33
34 __class_getitem__ = classmethod(GenericAlias)
Listing 10.3 shows the actual source code for
Iterable
[1]. Let’s walk through
this. The inheritance in line 19 is essentially equivalent to inheriting from
abc.ABC
. Similarly, lines 21 and 34 are unrelated technical code. At
line 24 we see the object.__iter__()
special method, decorated with
abstractmethod
. This ensures that classes that do explicitly inherit
from Iterable
have to implement
object.__iter__()
.
The part that currently concerns us, though, is the
declaration of __subclasshook__()
at line 29.
__subclasshook__()
is declared as class method. This
means that it will be passed the class itself as its first argument, in place
of the object. It is conventional to signal this difference by calling the
first parameter cls
instead of self
. The second parameter, C
is the class
to be tested.
In common with the special methods for arithmetic,
__subclasshook__()
returns NotImplemented
to
indicate cases that it cannot deal with. In this case, if the current class is
not Iterable
(this would happen if the method were
called on a subclass of Iterable
) then
NotImplemented
is returned. If we really are checking C
against
Iterable
then the _check_methods
helper function is
called. The fine details of how this works are a little technical, but in
essence the function loops over C
and its superclasses in order (C.__mro__
is the method resolution order) and checks if the relevant methods are
defined. If they are all found then True
is returned, otherwise the
result is NotImplemented
. An implementation of
__subclasshook__()
could also return False
to
indicate that C
is definitely not a subclass.
10.3. Glossary¶
- abstract base class¶
A class designed only to be the parent of other classes, and never to be instantiated itself. Abstract classes often define the interfaces of methods but leave their implementations to the concrete child classes.
- abstract method¶
A method whose presence is required by an abstract base class but for which concrete subclasses are required to provide the implementation.
- class method¶
A method which belongs to the class itself, rather than to the instances of the class. Class methods are declared using the
classmethod
decorator and take the class (cls
) as their first argument, instead of the instance (self
). See also: class attribute.- decorator¶
A syntax for applying higher order functions when defining functions. A decorator is applied by writing
@
followed by the decorator name immediately before the declaration of the function or method to which the decorator applies.- duck typing¶
The idea that the precise type of an object is not important, it is only important that the object has the correct methods or attributes for the current operation. If an object walks like a duck, and quacks like a duck then it can be taken to be a duck.
- higher order function¶
A function which acts on other functions, and which possibly returns another function as its result.
- method resolution order¶
- MRO¶
A sequence of the superclasses of the current class ordered by increasing ancestry. The superclasses of a class are searched in method resolution order to find implementations of methods and attributes.
- syntactic sugar¶
A feature of the programming language which adds no new functionality, but which enables a clearer or more concise syntax. Python special methods are a form of syntactic sugar as they enable, for example, the syntax
a + b
instead of something likea.add(b)
.- virtual subclass¶
A class which does not declare its descent from the superclass through its definition, but which is instead claimed as a subclass by the superclass.
10.4. Exercises¶
Using the information on the book website obtain the skeleton code for these exercises.
The objective of this exercise is to write a decorator which logs
whenever the decorated function is called. This sort of decorator could be
very useful in debugging code. Create the decorator in the
log_decorator.log_decorator
module and ensure it is importable as
log_decorator.log_call
. The decorator should be applicable to functions
taking any combination of arguments.
The logging itself should be accomplished using
the built-in logging
module by calling logging.info()
and passing
the log message.
The log message should comprise the string "Calling: "
followed by the
function name (accessible using the __name__
attribute), followed by
round brackets containing first the repr()
of the positional
arguments, followed by the key=value pairs the keyword arguments.
The groups.groups
module in the skeleton code is the new version
introduced above, using an abstract base class. The
log_decorator.log_call
decorator has been applied to the
Group._validate()
abstract method. However, even once you
have implemented this decorator, it never gets called. Your challenge is to
modify groups.groups
so that the decorator is called every time a
subclass _validate()
method is called, but without moving or
duplicating @log_call
.
Footnotes