Friday, 1 August 2025

Visitor vs Multiple Dispatch

The Visitor pattern has always felt a bit odd to me. The thing is that more than as a good practice, it was born to make up for a limitation, the lack of multiple dispatch (aka multimethods) in most programming languages. Being forced to have a visit() method in the classes that are going to be visited feels terribly intrusive to me, it violates the separation of concerns principle. So, when we have a language that supports multiple dispatch (either directly in the language of through some library) we should favor that and forget about Visitors.

That said, Python has no support for multiple dispatch at the language level, but has at least 2 interesting libraries that leverage Python's dynamism and introspection to provide us with the feature. I have only tried multimethod, and it's pretty amazing. I'll show some examples.


from multimethod import multimethod

# multimethod does not support keyword arguments

class Formatter:
    def __init__(self, wrapper: str):
        self.wrapper = wrapper

    @multimethod
    def format(self, item: str, starter: str):
        return f"{starter}{self.wrapper}{item}{self.wrapper}"
    
    @multimethod
    def format(self, item: int, starter: str):
        return f"{starter}{self.wrapper * 2}{item}{self.wrapper * 2}"
    
f1 = Formatter("|")
print(f1.format("aaa", "- "))

print(f1.format(25, "- "))

# I can even register new overloads to an existing one
@Formatter.format.register
def _(self, item: bool):
    return f"{self.wrapper * 3}{item}{self.wrapper * 3}"

print(f1.format(True))

# and I can even overwrite an existing overload!
@Formatter.format.register
def _(self, item: int, starter: str):
    return f"{starter}{self.wrapper * 5}{item}{self.wrapper * 5}"

print(f1.format(25, "- "))

# - |aaa|
# - ||25||
# |||True|||
# - |||||25|||||

# but there's one limitation, keyword arguments do not work
#print(f1.format(item="bbb", starter="- "))
#print(f1.format(starter="- ", item="bbb"))
# multimethod.DispatchError

Well, the code is self-explanatory. I can define different overloads of a same method using the multimethod decorator, and the method invocation will be redirected to the right method based on the parameters. There's one limitation though, this redirection only works when using positional parameters. If we use named parameters, it fails with a multimethod.DispatchError. To work with named parameters we have to use the multidispatch decorator.


from multimethod import multidispatch 

# multidispatch supports keyword arguments  
class Formatter:
    def __init__(self, wrapper: str):
        self.wrapper = wrapper

    @multidispatch
    def format(self, item: str, starter: str):
        return f"{starter}{self.wrapper}{item}{self.wrapper}"
    
    # @multidispatch
    # def format(self, item: int, starter: str):
    #     return f"{starter}{self.wrapper * 2}{item}{self.wrapper * 2}"

    @format.register
    def _(self, item: int, starter: str):
        return f"{starter}{self.wrapper * 2}{item}{self.wrapper * 2}"

    
f1 = Formatter("|")
print(f1.format("aaa", "- "))

print(f1.format(25, "- "))

print(f1.format(starter="- ", item="bbb"))
print(f1.format(item=25, starter="- "))

# I can register new overloads to an existing class!
@Formatter.format.register
def _(self, item: bool):
    return f"{self.wrapper * 3}{item}{self.wrapper * 3}"

print(f1.format(True))
 
# and I can even overwrite an existing overload!
@Formatter.format.register
def _(self, item: int, starter: str):
    return f"{starter}{self.wrapper * 5}{item}{self.wrapper * 5}"

print(f1.format(item=25, starter="- "))

#  - |aaa|
# - ||25||
# - |bbb|
# - ||25||
# |||True|||
# - |||||25|||||


This multidispatch decorator is implemented quite differently from the multimethod decorator, so apart from supporting named parameters, you use it differently, via the register method. Let's see how the whole thing works.

First of all, let's revisit how class creation works in Python. When the runtime comes across a class declaration it executes the statements inside that declaration (normally we mainly have function definitions, but we can have also assignments, conditionals...) and the different elements (methods and attributes) defined there are added to a namespace object (sort of dictionary). Then, the class object is created by invoking type(classname, superclasses, namespace) (or if the class is using a metaclass other than type: MyMeta(classname, superclasses, namespace, **kwargs)). You can further read about this in these 2 previous posts [1] and [2]

multidispatch (same as multimethod) is a class based decorator, so when you do: "@multidispatch def format()..." you are creating an instance of the multidispatch class and adding a "format" entry in the class namespace pointing to it. Then, you decorate the other "overloads" of the function with calls to the register() method of that multidispatch instance, so the instance will store the different overloads with their corresponding signatures. Notice that because of its different internal implementation, you can not name the overload functions with the same name of the first one (that will become the "public" name for this multiple dispatch method), we use a commodity name like "_". Finally the class object will be created by inovoking type with a namespace object that contains this instance of the multidispatch class. Obviously the multidispatch class is callable, so it has a __call__ method that when invoked searches through its list of registered overloads based on the signature.

Notice in both samples above that after having declared a class we still can add additional overloads to the class using the register method (we can even overwrite an existing overload). This is a particularly nice feature as your class remains open

No comments:

Post a Comment