After using clojure for some time on my hobby projects, I find clojure's multimethods feature really exciting. In popular words, multimethods provides "polymorphism a la carte".
I thought it was funny to try implement that using python3. Amazingly, the result seems very expressive and maybe it is can be more flexible than singledispatch.
Implementation details
Let see how it is implemented.
from inspect import isclass
def isa(cls_child, cls_parent) -> bool:
if not isclass(cls_child):
return False
if not isclass(cls_parent):
return False
return issubclass(cls_child, cls_parent)
The isa
function is a helper function for safe check if one class is subclass of other, and
it is used in one of two matching search process.
Then, multimethod is implemented using a plain python class with callable interface. That additionally exposes methods for easy register implementations using python decorator syntax.
from threading import Lock
from functools import partial
class _multimethod:
def __init__(self, dispatch):
self.__name__ = dispatch.__name__
self.__doc__ = dispatch.__doc__
self._dispatch = dispatch
self._dispatch_entries = []
self._dispatch_cache = {}
self._dispatch_default = None
self._mutex = Lock()
self._notfound = object()
def register(self, value, func=None):
if func is None:
return partial(self.register, value)
with self._mutex:
_callable = _multimethod_callable(value, func)
self._dispatch_entries.append(_callable)
return self
def register_default(self, func):
self._dispatch_default = _multimethod_callable(None, func)
return self
def __call__(self, *args, **kwargs):
self._mutex.acquire()
# Calculate the dispatch value
dispatch_match = self._dispatch(*args, **kwargs)
# If a dispatch resolution is already
# exists in cache, use it as is. It
# just an optimization for avoid compute
# the resoultion in each call.
if dispatch_match in self._dispatch_cache:
dispatch_func = self._dispatch_cache[dispatch_match]
# Explicit release lock befor execute the method.
self._mutex.release()
return dispatch_func(*args, **kwargs)
# If no resolution found on cache, start the first
# search iteration using isa? method.
for dispatch_func in self._dispatch_entries:
dispatch_value = getattr(dispatch_func, "_dispatch_value", self._notfound)
if dispatch_value is self._notfound:
continue
if isa(dispatch_value, dispatch_match):
self._dispatch_cache[dispatch_match] = dispatch_func
self._mutex.release()
return dispatch_func(*args, **kwargs)
# If no resolution foun on first iteration, go to
# the second iteration using == operator.
for dispatch_func in self._dispatch_entries:
dispatch_value = getattr(dispatch_func, "_dispatch_value", self._notfound)
if dispatch_value is self._notfound:
continue
if dispatch_value == dispatch_match:
self._dispatch_cache[dispatch_match] = dispatch_func
self._mutex.release()
return dispatch_func(*args, **kwargs)
# If we are here, so no match is found.
self._mutex.release()
if not self._dispatch_default:
raise RuntimeError("No match found.")
return self._dispatch_default(*args, **kwargs)
The registred functions are wrapped with simple callable class: _multimethod_callable
.
This really can be done with adding a property to function instance directly, but I don't
like mutation.
_multimethod_callable
is defined like this:
class _multimethod_callable:
"""
Callable wrapper. The main purpose of this
callable container is not mutate the registered
function in a multimethod with dispatch value.
"""
def __init__(self, dispatch_value, func):
self._callable = func
self._dispatch_value = dispatch_value
def __call__(self, *args, **kwargs):
return self._callable(*args, **kwargs)
And finally, the api is exposed by simple decorator function:
def multimethod(func):
"""Decorator that creates multimethods."""
return _multimethod(func)
Obvioulsy, this implementation does not cover all use cases of clojure multimethods but I think is good result for an experiment.
Usage examples
As first example, we have a say_hello
multimethod that geets dispatching by
person language.
Here a multimethod definition:
@multimethod
def say_hello(person):
return person.get("lang", "es")
@say_hello.register("es")
def say_hello(person):
return "Hola {}".format(person["name"])
@say_hello.register("en")
def say_hello(person):
return "Hello {}".format(person["name"])
And having this sample data, this is a result:
person_es = {"name": "Foo", "lang": "es"}
person_en = {"name": "Bar", "lang": "en"}
print(say_hello(person_en))
print(say_hello(person_es))
# => "Hello Foo"
# => "Hola Bar"
Another example, uses multiple value dispatching and also has fallback implementation.
@multimethod
def do_stuff(data):
return (data.get("a"), data.get("b"))
@do_stuff.register((1,2))
def do_stuff(_):
return "foo"
@do_stuff.register((2,2))
def do_stuff(_):
return "bar"
@do_stuff.register_default
def do_stuff(_):
return "baz"
Let see how the result of using the do_stuff
multimethod:
print(do_stuff({"a": 1, "b": 2}))
print(do_stuff({"a": 2, "b": 2}))
print(do_stuff({"a": 3, "b": 2}))
# => "foo"
# => "bar"
# => "baz"
Links
You can found more about multimethods on clojure documentation: http://clojure.org/multimethods
The complete source of this post: https://gist.github.com/niwibe/27a91ae399e5de5dba10