My first comment was exactly about how mixing (Pythonic) OOP and multiple dispatch causes issues, so perhaps you donât understand what I mean. In fact, I was downplaying how problematic it gets. Maybe itâs easier to see in an imaginary language with Juliaâs multimethods in Pythonâs classes, letâs call it Pulia (Jython is taken). Multiple dispatch is intended to work just like in Julia, but the first argument (self
) is specially tied to the class. Sounds reasonable, right? Well letâs try a simple case: 2 classes/types, and the multimethod dispatches on 1 or 2 arguments with those types:
# code # intended usage
class A:
f(self) = "A" # A().f()
f(self, ::B) = "AB" # A().f( B() )
class B:
f(self) = "B" # B().f()
f(self, ::A) = "BA" # B().f( A() )
Looks good, so whatâs the problem? Well, the f(self, ::B)
line throws a UndefVarError: B not defined
. Well okay, we can just move class B
to the front then, right? But then f(self, ::A)
throws the error. This circular dependency does not happen in Python because there are no formal type annotations.
So what do languages with both classes and type signatures do? In C++, you use forward declarations, in this case you could add a class B: pass
line at the start. However, when it hasnât been defined yet, you can only use pointers to B. Neither Python nor Julia have pointers as a core language feature, in fact they actively avoided it.
Well okay, we can still make this work. The problem is that the method was defined too early. We can just add it to the class after the fact:
# A must be defined before B
class A:
f(self) = "A"
class B:
f(self) = "B"
f(self, ::A) = "BA"
A.f(self, ::B) = "AB"
Well now that methods donât have to be contained by the class definition, why not define all the methods outside after the classes? Then we donât have to define our classes in a particular order:
# A and B can be defined in any order before the methods
class B: pass
class A: pass
A.f(self) = "A"
A.f(self, ::B) = "AB"
B.f(self) = "B"
B.f(self, ::A) = "BA"
And now we have effectively separated data and methods. It really is just cleaner for multimethods, you donât have to fight type definition order, you donât have to introduce pointers. If the classes A
and B
are defined in separate modules/files, you donât have to watch out for circular imports and are not forced to split f
across those modules/files.
This also shows a limitation of classes encapsulating multimethods. If you want to look for methods that take in B
in the Pulia example above, you probably want to find A.f(self, ::B)
, but you would have to search the class A
instead. Thatâs not very feasible to figure out in more complex code.