Sounds like a job for functools.singledispatch
. I just learned about type classes literally 20 minutes ago, and went looking to see if python could support them. I found singledispatch (which is almost like typeclasses, almost) and this post.
https://docs.python.org/3/library/functools.html#functools.singledispatch
This decorator allows you to write your library-specific implementations, while allowing them to be extended:
from typing import Union
from functools import singledispatch
from maaniB import from_a_to_x, from_b_to_x, from_c_to_x
from package_a import a
from package_b import b
from package_C import c
@singledispatch
def to_x(data) -> x:
raise NotImplementedError(f"Unhandled data type: {type(data)}"
to_x.register(a, from_a_to_x)
to_x.register(b, from_b_to_x)
to_x.register(c, from_c_to_x)
The great part about this is that it allows me, a consumer, to implement my own types, without any input from you, the library maintainer:
from maaniB import to_x
@to_x.register
def int_to_x(i):
return whatever(i)
class MyOwnWidget:
def to_x(self):
return f"MyOwn({self})"
to_x.register(MyOwnWidget, MyOwnWidget.to_x)
If you can use python >=3.8, you can also use singledispatchmethod
.
Why is this better?
To understand the advantages of this approach, we first have to briefly consider what the alternatives are. The first is inheritance-based polymorphism. If you wanted, you could write the elif isinstance
tree in a OOPy way.
In python, the difference between Widget(foo).to_x()
(instance method with self
) and Widget.to_x(foo)
(the unbound method) is very small. So as long as you control type a
, you can just write a A.to_x()
method. If there is significant similarity, you can often accomplish this behavior with a mixin. This is "early binding". The one great thing about this approach is that it facilitates generic interfaces and static type checking.
You can provide a AbstractBaseClass
and let the user implement their own to_x
but this tends to lead to the banana-gorilla-jungle problem with more sophisticated interfaces.
However, it sounds like from your setup that package_a
through _c
are not actually libraries you control. Hence why you end up with the function with the manual type dispatch. As Doc Brown points out, this could be fine if the set of types you wish to handle is small - e.g. you just want to deal with numpy arrays and pandas dataframes. By using runtime dispatch, you don't have to modify the actual library code. This is "late binding". This works great when you are converging on a single concrete type (say you are returning str
), but it gets more tricky if the return type is parametric. Say your to_x
is tasked with converting all custom types to Union[str, int, float, dict, list]
so you can JSON serialize it later. But you don't know whether the root object is a Dict
or List
until you get your actual object. You need to essentially loosen the return type so that the type checks out. But now you have this abomination like -> Union[Dict[str, Jsonable], List[Dict[str, Jsonable]]
(where Jsonable is roughly Union[list, dict, int, float, str, type(None)]
. Also lets say a new type arrives and the sensible interface is to return a primitive, now you have -> Union[Jsonable, Dict[str, Jsonable], List[Dict[str, Jsonable]]
, it's a hot mess. It would be nice if each implementation could have its own return type, based on the input type. And as mentioned, late binding suffers from downsides relating to OCP.
singledispatch
mostly avoids issues with OCP; it's not quite the same type class behavior as more FP-heavy languages since you have to explicitly register functions. But I would contend that it gives you the best of both worlds - the stronger type guarantees of early binding, but the dynamicism of late binding.