The missing piece in Python tutorial - What is dispatch why you should care

python
fastai
Dispatch is an amazing useful features which is underused in Python. In this article, I will show you how you can use it to make Python more powerful.
Published

February 22, 2020

In python, we often think of it as a dynamic language, and type is barely noticed in Python as you can change the type of a variable whenever you want.

Since Python 3.4(PEP443)[https://www.python.org/dev/peps/pep-0443/], generic function is added to Python. This add a new feature that I found much of the exsiting tutorial does not cover it. Such feature is common in other language and is very useful to keep your code concise and clean.

In python, you cannot overload a normal function twice for different behavior base on the arguments. For example:

def foo(number:int ):
    print('it is a integer')
    
def foo(number: float):
    print('it is a float')
foo(1)
it is a float

The definition simply get replaced by the second definition. However, with singledispatch, you can define the function behavior base on the type of the argument.

from functools import singledispatch
@singledispatch
def foo(number ):
    print(f'{type(number)}, {number}')
foo(1)
<class 'int'>, 1

We can now register the function for different argument type.

@foo.register(int)
def _(data):
    print('It is a integer!')
    
@foo.register(float)
def _(data):
    print('It is a float!')

@foo.register(dict)
def _(data):
    print('It is a dict!')
foo(1.0)
foo(1)
foo({'1':1})
It is a float!
It is a integer!
It is a dict!

How is this possible? Basically there are multiple version of a generic function, singlepatch will pick the correct one base on the type of the first argument.

It will fallback to the most generic function if the type of argument is not registered.

foo([1,2,3])
<class 'list'>, [1, 2, 3]

I hope you can see how this is going to be useful. singledispatch limited the usage to the first argument of a function. But we can actually do more than that.

In next post I will cover the patch method from fastai will leverage singledispatch more to do multi-dispatch. In python, everything is just an object, even a function itself. So there is no reason why you can only dispatch to a function object. In fact, you could dispatch method to a class too.

Fastai @typedispatch

Single Dispatch is great, but what if we can do multi dispatch for more than 1 argument?

from fastcore.dispatch import  typedispatch, TypeDispatch

Let us first try if this work as expected

@typedispatch
def add(x:int, y:int):
    return x+y
@typedispatch
def add(x:int, y:str):
    return x + int(y)
print(add(1,2))
print(add(1,'2'))
print(add('a','a'))
3
3
a
add(1,2)
3
add(1,'2')
3

But what if we added something does not define?

add('2',1)
'2'

‘2’? where does it come from? Let’s have a look at the definition of typedispatch and understand how it works.

??typedispatch
class DispatchReg:
    "A global registry for `TypeDispatch` objects keyed by function name"
    def __init__(self): self.d = defaultdict(TypeDispatch)
    def __call__(self, f):
        nm = f'{f.__qualname__}'
        self.d[nm].add(f)
        return self.d[nm]

In fact, typedispatch is not even a function, it’s an instance! In python, everything is an object. With the __call__ method, we can use an instance just liek a function. And the typedispatch is just an instance of DispatchReg

type(typedispatch)
fastcore.dispatch.DispatchReg

typedispatch store a dictionary inside, when you first register your function, it actually store inside a dict. As shown previously, you cannot define the same function twice. But you actually can, because function is nothing but just an object! Let me show you.

def foo(): return 'foo'
a = foo
def foo(): return 'not foo'
b = foo
foo()
'not foo'

foo() is replaced by the latest definition indeed, but we store a copy of the original function as a variable.

a()
'foo'
b()
'not foo'
hex(id(a)), hex(id(b))
('0x2b9d28bb5e8', '0x2b9d2ebe048')

The two function is nothing other than two Python object. typedispatch make use of these, when you register a new function, you create an new object and stored inside typedispatch dictionary. It then checks your type annotation and find the corresponding type until it match the issubclass condition.

typedispatch.d
defaultdict(fastcore.dispatch.TypeDispatch,
            {'cast': (object,object) -> cast, 'add': (int,str) -> add
             (int,int) -> add})

So back to our question, why does add(‘a’,1) return ‘a’? The following explain the reasons. When you call your method, you are really calling the __call__ method inside TypeDispatch, and when the signature is not find, it will simply return the first argument.

def __call__(self, *args, **kwargs):
       ts = L(args).map(type)[:2]
       f = self[tuple(ts)]
       if not f: return args[0]
       if self.inst is not None: f = MethodType(f, self.inst)
       return f(*args, **kwargs)