Add methods retroactively in Python with singledispatch

Learn more about solving common Python problems in our series covering seven PyPI libraries.
Register or Login to like
Why and how to handle exceptions in Python Flask

Image from Unsplash.com, Creative Commons Zero 

Python is one of the most popular programming languages in use today—and for good reasons: it's open source, it has a wide range of uses (such as web programming, business applications, games, scientific programming, and much more), and it has a vibrant and dedicated community supporting it. This community is the reason we have such a large, diverse range of software packages available in the Python Package Index (PyPI) to extend and improve Python and solve the inevitable glitches that crop up.

In this series, we'll look at seven PyPI libraries that can help you solve common Python problems. Today, we'll examine singledispatch, a library that allows you to add methods to Python libraries retroactively.

singledispatch

Imagine you have a "shapes" library with a Circle class, a Square class, etc.

A Circle has a radius, a Square has a side, and a Rectangle has height and width. Our library already exists; we do not want to change it.

However, we do want to add an area calculation to our library. If we didn't share this library with anyone else, we could just add an area method so we could call shape.area() and not worry about what the shape is.

While it is possible to reach into a class and add a method, this is a bad idea: nobody expects their class to grow new methods, and things might break in weird ways.

Instead, the singledispatch function in functools can come to our rescue.

@singledispatch
def get_area(shape):
    raise NotImplementedError("cannot calculate area for unknown shape",
                              shape)

The "base" implementation for the get_area function fails. This makes sure that if we get a new shape, we will fail cleanly instead of returning a nonsense result.

@get_area.register(Square)
def _get_area_square(shape):
    return shape.side ** 2
@get_area.register(Circle)
def _get_area_circle(shape):
    return math.pi * (shape.radius ** 2)

One nice thing about doing things this way is that if someone writes a new shape that is intended to play well with our code, they can implement get_area themselves.

from area_calculator import get_area

@attr.s(auto_attribs=True, frozen=True)
class Ellipse:
    horizontal_axis: float
    vertical_axis: float

@get_area.register(Ellipse)
def _get_area_ellipse(shape):
    return math.pi * shape.horizontal_axis * shape.vertical_axis

Calling get_area is straightforward.

print(get_area(shape))

This means we can change a function that has a long if isintance()/elif isinstance() chain to work this way, without changing the interface. The next time you are tempted to check if isinstance, try using singledispatch!

In the next article in this series, we'll look at tox, a tool for automating tests on Python code.

Review the previous articles in this series:

What to read next
Tags
Moshe sitting down, head slightly to the side. His t-shirt has Guardians of the Galaxy silhoutes against a background of sound visualization bars.
Moshe has been involved in the Linux community since 1998, helping in Linux "installation parties". He has been programming Python since 1999, and has contributed to the core Python interpreter. Moshe has been a DevOps/SRE since before those terms existed, caring deeply about software reliability, build reproducibility and other such things.

3 Comments

Why wouldn't you just have a Shape class with all the methods common to all shapes and then simply just raise your NotImplementedError exception in get_area in the Shape class then have all of your shapes inherit from Shape and then override get_area as needed in the child classes? Isn't that what inheritance is for?

In your example get_area has no side effects so grow new methods isn't a concern. I fail to see how singledispatch accomplishes anything except make your code more complicated, harder to read and many would say non-pythonic.

I also tend to agree with you on this. I think the more Pythonic way would be yo use a dict to map the type to the squaring function/lambda. This is probably a wraper around this, but it is not explicit and is yet another library dependency to maintain.

In reply to by Jason L Gray (not verified)

Creative Commons LicenseThis work is licensed under a Creative Commons Attribution-Share Alike 4.0 International License.