Method and Function Overriding in Python
Using @functools.singledispatch and type annotations.
Method and Function overriding is an extremely useful technique. It allows you to define the same method multiple times in your code — but with each method taking parameters of a different type.
A Note on Terminology, for Those who Care!
Methods are defined on classes. Overloading is what happens when a child class redefines the method available in a parent class. Therefore we call this method overloading. I won’t be discussing method overloading in this article.
Functions are not defined on class objects (normally). When we define multiple versions of a function, it is called function overriding. This article is about function overriding in Python.
An Example without Function Overriding
Let’s use the iconic example of a circle class and a square class.
Nothing much to see here. The
square has an instance property: the length of its sides, and the circle defines a radius
__init__ method of each instance
takes an initialization value, and 0 is the default. This is object-oriented Python 101, nothing
special so far. We’ll need this code later.
A Function to Calculate the Area of the Object.
The area of a square is calculated
differently from the area of a circle. My function,
can take either a square or a circle object and calculate its area. It knows the different ways
to calculate the are of these two geometric shapes. In the future, I might want to add support
for other shapes.
This function uses an
if … elif construct to identify if the object is
either a circle or a square, and then use a different calculation. This function does not use
overriding. It accepts parameters of any type, and figures out how to deal with them.
This is the driver for the function:
And here is the output in my terminal:
You can find the example here as a Gist on Github.
An Example using Function Overriding
area function works just fine so far — there is
nothing much wrong with it. However, it will become bloated as I add support for more and more
geometric shapes. It will also be harder to test as it grows.
Let’s break down the function into more
specific variants, using overriding. Replace the previous
area function with the following code:
Here’s what’s going on:
- Line #1: Import
singledispatchfrom the functools module. This is where the ‘magic’ happens.
- Line #2: Use the decorator on a
- Line #3-#4: The base function, area, is not implemented and returns an error.
- Line #7: Register an override for the
areafunction which takes a
- Line #12: Register an override for the
areafunction which takes a
- Lines #8 and #9: The registered functions are simply named _ because they won’t be called directly.
As you can see, we have registered two
specific handlers to dispatch (or handle) variations in a single parameter to the function.
That’s why the decorator is called
singledispatch. This was introduced in Python
Why Raise a NotImplementedError on the Base Function?
This is a bit of convention, but is
very useful. If I’m writing a library for other people to use, then they may define shapes such
as a pentagram, which I don’t know about. Instead of silently returning 0 we choose to raise an
exception for the client to handle. We’ve explicitly defined that
area does not know how to return a calculated
value for the object passed in as a parameter.
Running this code I get exactly the same output as before! You can see the full example as Gist on Github.
Using Type Annotations Instead
Up until now, we’ve been registering specific variants of the base method using the following decorators, specifying the type explicitly:
There is a smarter way, using type annotations! Since version 3.7, Python is very happy to infer the correct type fora dispatch function using the type annotation on the function’s parameter.
Here is an example of the same code, updated to use type annotations:
As you can see in lines #7 and #12, we’re not specifying a type to register the function with. Instead, the correct type is inferred from the annotations on lines #8 and #13.
def _(any_object: Circle): ...
def _(any_object: Square): ...
Using annotations when registering a dispatch handler makes the code self-documenting, and therefore easier to maintain and understand.
Understanding Which Dispatcher will be Called
It’s sometimes useful to figure out which dispatcher will be called if different types are passed to the function.
Here is an example showing how different functions are registered as the dispatcher for a base function:
This is the result in my terminal — you’ll get different addresses.
<function _ints at 0x10b2a0dc0>
<function _lists at 0x10b2a0e50>
<function _floats at 0x10b2a0ee0>
<function fancy_print at 0x10b2a0af0>
Overriding Instance Methods using @singledispatchmethod
Instance methods always have an additional, first parameter, usually named self. The functools module provides a replacement for singledispatch called singledispatchmethod, which is used for instance methods. It can deal with the initial self parameter.
Here is a contrived example with a Dog class. It can bark, with different qualities. Sending an integer value will make the dog bark multiple times. Sending a string will also change the quality of the barking.
This is the output in my terminal:
BARK BARK BARK BARK BARK
As you can see from this example,
overriding of a class’ instance methods is possible by using the
Conclusion and Caveat
Within the functools module, Python since version 3.4 has offered two decorators: @singledispatchmethod for use on instance methods and @singledispatch for use on functions. Since version 3.7, these have been able to use type annotations.
This is a fantastic capability — but it has its limitations. These decorators can only be used for overriding the first argument in a method or function signature. If you need to be able to override multiple arguments, you’ll have to look elsewhere. Perhaps the PyPi module multipledispatch is what you need!
If you’d like to know more about object-oriented programming in Python, take a look at some of my other articles!