Pythonic overload sets with singledispatch

by | May 24, 2021 | Python | 0 comments

Recently I’ve been refreshing some of my python skills and I have come across a neat little feature that I wasn’t aware of previously – the singledispatch decorator. It lets us implement cleanly what I hadn’t thought was possible as easily in python – overload sets. Let’s see what this thing is about.

When Duck typing isn’t enough

First of all, let’s consider why one would even need or want to implement an overload set in Python? Since python supports, and heavily relies on, duck typing – the idea that if something “walks like a duck and quacks like a duck then it is a duck” – or more practically – that we shouldn’t care about an object’s type as long as it supports the minimal interface we rely on.

Why an overload set then? Duck typing seems to have us covered. Well, with duck typing we’re performing the exact same algorithm for all types and just assuming that it’s going to do the correct thing, and maybe reporting errors if it doesn’t. In practice, however, it might be necessary to vary the implementation based on the type of the arguments. For example, let’s say that we’d like to serialize data to json, but also need to do some additional processing – e.g. we’d like integers to be stored in hexadecimal format with a ‘0x’ prefix, floats to have a fixed number of decimal digits of five and strings to be all uppercase, and for all other types, we’d like the repr() representation. There’s of course multiple ways of doing this. Let’s look at a few that might be an obvious first choice.

Type checking

The most naive way to do this might be to explicitly check the type of the argument:

def jsonify(obj):
    if type(obj) == int:
        return "0x{:x}".format(obj)
    elif type(obj) == float:
        return "{:.5}".format(obj)
    elif type(obj) == str:
        return obj.upper()
    else:
        return "{!r}".format(obj)
>>> jsonify(42)
'0x2a'
>>> jsonify(3.14)
'3.14000'
>>> jsonify('foobar')
'FOOBAR'
>>> 
>>> class Foo: pass
... 
>>> jsonify(Foo())
'<__main__.Foo object at 0x7fcd381d6280>'

This is about the least pythonic thing we could possibly do. It completely neglects the convenience of dynamic typing, doesn’t consider inheritence hierarchies and is entirely closed for extension. Oh, and it’s also just incorrect:

>>> jsonify([42, 3.14, 'foobar'])
"[42, 3.14, 'foobar']"

It would be very difficult, or impossible even, to handle all sequence types with this exact pattern.

isinstance and ABCs

An improvement would be to use the isinstance operator in combination with ABCs – abstract base classes:

import collections
import numbers

def jsonify(obj):
    if isinstance(obj, numbers.Integral):
        return "0x{:x}".format(obj)
    elif isinstance(obj, numbers.Real):
        return "{:.5f}".format(obj)
    elif isinstance(obj, str):
        return obj.upper()
    elif isinstance(obj, collections.Sequence):
        return "[{}]".format(', '.join(jsonify(elem) for elem in obj).strip())
    else:
        return "{!r}".format(obj)
>>> jsonify([42, 3.14, 'foobar'])
'[0x2a, 3.14000, FOOBAR]'

This is slightly better – at least it handles sequences correctly and user-defined types that meet the requirements of ABCs we used. The problem remains that this implementation is still closed for extension – introducing a new type that needs special handling would require us to modify this function. Additionally these long chains of if-isinstance checks are considered an antipattern that can get unwieldy quite quickly.

functools.singledispatch

The functools.singledispatch decorator comes to the rescue – allowing for similarly succinct implementation that doesn’t suffer from the same issues. Using the singledispatch decorator we can implement an overload set that can be extended for arbitrary types, without the need to ever modify the initial implementation:

import collections
import numbers
from functools import singledispatch

@singledispatch
def jsonify(obj):
    return "{!r}".format(obj)

@jsonify.register(numbers.Integral)
def _(num):
    return "0x{:x}".format(num)

@jsonify.register(numbers.Real)
def _(num):
    return "{:.5f}".format(num)

@jsonify.register(str)
def _(text):
    return text.upper()

@jsonify.register(collections.Sequence)
def _(seq):
    return "[{}]".format(', '.join(jsonify(elem) for elem in seq).strip())

First we declare and define the fallback function in the overload set, decorated with @singledispatch – this is what defines the overload set. It is now possible to register handlers for specific types or ABCs. Each subsequent overload is declared by decorating the implementation with @jsonify.register(…). Note that the name of the function doesn’t matter here – it will be wrapped and handled by the jsonify overload set anyway. The above can be used in exactly the same way as the isinstance-based implementation.

The major benefit here is that this implementation is open for extension – as recommended by the SOLID principles. We could encapsulate this into a module and if a user needed to extend the overload set they could do so without ever touching the original code. For example, we may want to handle tuples differently from all other sequences:

from jsonify import jsonify

@jsonify.register(tuple)
def _(tup):
    content = jsonify.dispatch(collections.Sequence)(tup).lstrip('[').rstrip(']')
    return '({})'.format(content)
>>> jsonify((42, 3.14, 'foo'))
'(0x2a, 3.14000, FOO)'

We reuse the generic implementation for sequences here, by explicitly delegating to the function registered for collections.Sequence ABC – this is done using the .dispatch() method – it returns an appropriate function in the overload set for the given type.

Note that the dispatching is done only based on the first argument – hence singledispatch. It is still possible to have more arguments, but only the first one is considered for overload resolution.

Summary

The singledispatch decorator was a quite surprising and neat discovery for me. It’s a great example of the kind of python code I like – terse and yet clean and following good practices. If that’s a good indicator of what python’s standard library has to offer, I’m looking forward to exploring it further.

 

References

0 Comments

Submit a Comment

Your email address will not be published. Required fields are marked *

Share This