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.