Ovld – Efficient and featureful multiple dispatch for Python

2025-05-2919:4511346github.com

Advanced multiple dispatch for Python functions. Contribute to breuleux/ovld development by creating an account on GitHub.

Fast multiple dispatch in Python, with many extra features.

📋 Documentation

With ovld, you can write a version of the same function for every type signature using annotations instead of writing an awkward sequence of isinstance statements. Unlike Python's singledispatch, it works for multiple arguments.

  • ⚡️ Fast: ovld is the fastest multiple dispatch library around, by some margin.
  • 🚀 Variants, mixins and medleys of functions and methods.
  • 🦄 Dependent types: Overloaded functions can depend on more than argument types: they can depend on actual values.
  • 🔑 Extensive: Dispatch on functions, methods, positional arguments and even keyword arguments (with some restrictions).
  • ⚙️ Codegen: (Experimental) For advanced use cases, you can generate custom code for overloads.

Install with pip install ovld

Define one version of your function for each type signature you want to support. ovld supports all basic types, plus literals and value-dependent types such as Regexp.

from ovld import ovld
from ovld.dependent import Regexp
from typing import Literal @ovld
def f(x: str): return f"The string {x!r}" @ovld
def f(x: int): return f"The number {x}" @ovld
def f(x: int, y: int): return "Two numbers!" @ovld
def f(x: Literal[0]): return "zero" @ovld
def f(x: Regexp[r"^X"]): return "A string that starts with X" assert f("hello") == "The string 'hello'"
assert f(3) == "The number 3"
assert f(1, 2) == "Two numbers!"
assert f(0) == "zero"
assert f("XSECRET") == "A string that starts with X"

ovld shines particularly with recursive definitions, for example tree maps or serialization. Here we define a function that recursively adds lists of lists and integers:

from ovld import ovld, recurse @ovld
def add(x: list, y: list): return [recurse(a, b) for a, b in zip(x, y)] @ovld
def add(x: list, y: int): return [recurse(a, y) for a in x] @ovld
def add(x: int, y: list): return [recurse(x, a) for a in x] @ovld
def add(x: int, y: int): return x + y assert add([1, 2], [3, 4]) == [4, 6]
assert add([1, 2, [3]], 7) == [8, 9, [10]]

The recurse function is special: it will recursively call the current ovld object. You may ask: how is it different from simply calling add? The difference is that if you create a variant of add, recurse will automatically call the variant.

For example:

A variant of an ovld is a copy of the ovld, with some methods added or changed. For example, let's take the definition of add above and make a variant that multiplies numbers instead:

@add.variant
def mul(x: int, y: int): return x * y assert mul([1, 2], [3, 4]) == [3, 8]

Simple! This means you can define one ovld that recursively walks generic data structures, and then specialize it in various ways.

You can define a numeric priority for each method (the default priority is 0):

from ovld import call_next @ovld(priority=1000)
def f(x: int): return call_next(x + 1) @ovld
def f(x: int): return x * x assert f(10) == 121

Both definitions above have the same type signature, but since the first has higher priority, that is the one that will be called.

However, that does not mean there is no way to call the second one. Indeed, when the first function calls the special function call_next(x + 1), it will call the next function in line, in order of priority and specificity.

The pattern you see above is how you may wrap each call with some generic behavior. For instance, if you did something like this:

@f.variant(priority=1000)
def f2(x: object) print(f"f({x!r})") return call_next(x)

The above is effectively a clone of f that traces every call. Useful for debugging.

A dependent type is a type that depends on a value. ovld supports this, either through Literal[value] or Dependent[bound, check]. For example, this definition of factorial:

from typing import Literal
from ovld import ovld, recurse, Dependent @ovld
def fact(n: Literal[0]): return 1 @ovld
def fact(n: Dependent[int, lambda n: n > 0]): return n * recurse(n - 1) assert fact(5) == 120
fact(-1) # Error!

The first argument to Dependent must be a type bound. The bound must match before the logic is called, which also ensures we don't get a performance hit for unrelated types. For type checking purposes, Dependent[T, A] is equivalent to Annotated[T, A].

Define your own types with the @dependent_check decorator:

import torch
from ovld import ovld, dependent_check @dependent_check
def Shape(tensor: torch.Tensor, *shape): return ( len(tensor.shape) == len(shape) and all(s2 is Any or s1 == s2 for s1, s2 in zip(tensor.shape, shape)) ) @dependent_check
def Dtype(tensor: torch.Tensor, dtype): return tensor.dtype == dtype @ovld
def f(tensor: Shape[3, Any]): # Matches 3xN tensors ... @ovld
def f(tensor: Shape[2, 2] & Dtype[torch.float32]): # Only matches 2x2 tensors that also have the float32 dtype
    ...

The first parameter is the value to check. The type annotation (e.g. value: torch.Tensor above) is interpreted by ovld to be the bound for this type, so Shape will only be called on parameters of type torch.Tensor.

Either inherit from OvldBase or use the OvldMC metaclass to use multiple dispatch on methods.

from ovld import OvldBase, OvldMC # class Cat(OvldBase): <= Also an option
class Cat(metaclass=OvldMC): def interact(self, x: Mouse): return "catch" def interact(self, x: Food): return "devour" def interact(self, x: PricelessVase): return "destroy"

Subclasses inherit overloaded methods. They may define additional overloads for these methods which will only be valid for the subclass, but they need to use the @extend_super decorator (this is required for clarity):

from ovld import OvldMC, extend_super class One(metaclass=OvldMC): def f(self, x: int): return "an integer" class Two(One): @extend_super def f(self, x: str): return "a string" assert Two().f(1) == "an integer"
assert Two().f("s") == "a string"

Inheriting from ovld.Medley lets you combine functionality in a new way. Classes created that way are free-form medleys that you can (almost) arbitrarily combine together.

All medleys are dataclasses and you must define their data fields as you would for a normal dataclass (using dataclass.field if needed).

from ovld import Medley class Punctuator(Medley): punctuation: str = "." def __call__(self, x: str): return f"{x}{self.punctuation}" class Multiplier(Medley): factor: int = 3 def __call__(self, x: int): return x * self.factor # You can add the classes together to merge their methods and fields using ovld
PuMu = Punctuator + Multiplier
f = PuMu(punctuation="!", factor=3) # You can also combine existing instances!
f2 = Punctuator("!") + Multiplier(3) assert f("hello") == f2("hello") == "hello!"
assert f(10) == f2(10) == 30 # You can also meld medleys inplace, but only if all new fields have defaults
class Total(Medley): pass Total.extend(Punctuator, Multiplier)
f3 = Total(punctuation="!", factor=3)

(Experimental) For advanced use cases, you can generate custom code for type checkers or overloads. See here.

ovld is pretty fast: the overhead is comparable to isinstance or match, and only 2-3x slower when dispatching on Literal types. Compared to other multiple dispatch libraries, it has 1.5x to 100x less overhead.

Time relative to the fastest implementation (1.00) (lower is better).


Read the original article

Comments

  • By linschn 2025-06-015:484 reply

    One the one hand, this is pretty cool, the API is pythonic and makes quite a lot of sense.

    On the other hand, I can't stop myself from thinking about "Greenspun's tenth rule":

    > Any sufficiently complicated C or Fortran program contains an ad hoc, informally-specified, bug-ridden, slow implementation of half of Common Lisp.

    This doesn't apply directly here, as the features are intentional and it seems they are not bug ridden at all. But I get a nagging feeling of wanting to shout 'just use lisp!' when reading this.

    https://wiki.c2.com/?MultiMethods

    • By upghost 2025-06-0114:401 reply

      It's actually more like "just use Haskell".

      Having written one of these[1] a decade ago and inflicting it (with the best of intentions) upon production code in anger, I can tell you this often leads to completely unmaintainable code. It is impossible to predict the effect of changing a method, tracing a method, debugging a method (where do I put the breakpoint??).

      The code reads beautifully though. Pray you never have to change it.

      The reason I say "just use haskell" instead of lisp is bc lisp generics suffer from this same problem.

      Btw if anyone has a solution to this "generic/multidispatch maintainability in a dynamically typed language" problem I would love to hear it.

      [1]: https://github.com/jjtolton/naga/blob/master/naga/tools.py

      • By Fr0styMatt88 2025-06-0120:361 reply

        I’ve been doing the once-every-few-years deep dive into Smalltalk that I tend to do out of curiosity and this kind of question is one that always comes up for me. The answer there seems to be “the whole environment and philosophy is geared to work that way”; a dynamic language needs a lot of tooling support and interactivity.

        • By igouy 2025-06-0121:511 reply

          > this kind of question

          Which kind-of question? "where do I put the breakpoint??"

          • By Fr0styMatt88 2025-06-020:181 reply

            No specifically the question of how do you write maintainable code with generic multi-dispatch and highly dynamic languages.

            I like Python, but I like static typing too because there’s just less to think about and when I have to learn a new codebase there’s a lot of assumptions I can lean on about how things work; this saves time.

            I like the idea of Smalltalk and when you watch Alan Kay or Dan Ingalls talk about it, they make total sense and you have Pharo and Squeak to back it up as in “yes, you can build large systems with this idea”.

            But I don’t think you could program Smalltalk and have it be maintainable without everything else the environment brings. Being inside the environment with your objects. The total different approach of sending a message an object doesn’t understand, then having the debugger pop up and then you just implementing that message right there. That’s just an utterly different workflow.

            I like the idea in ‘late binding of all things’ and I think the approach of writing a DSL for your problem and then having to write far less code to solve your problem is great. But the objection is then always “okay but what about when someone else has to work with that code”.

            I guess what I’m trying to say is, the more dynamic your language is, the more support you need from your tooling to ease the cognitive load while you program, simply because the state-space of things you can do is bigger and not being restricted by types, etc.

            • By igouy 2025-06-0217:47

              Tangentially, you might find the abandoned Nice programming language interesting.

              https://gallium.inria.fr/~remy/poly/mot/10/nice/web/language...

                  ----
              
              > … but what about when someone else has to work with that code.

              Someone else has had to work with that code since before Smalltalk escaped Xerox PARC.

              1984 "Smalltalk-80 The Interactive Programming Environment" page 500

              "At the outset of a project involving two or more programmers: Do assign a member of the team to be the version manager. … The responsibilities of the version manager consist of collecting and cataloging code files submitted by all members of the team, periodically building a new system image incorporating all submitted code files, and releasing the image for use by the team. The version manager stores the current release and all code files for that release in a central place, allowing team members read access, and disallowing write access for anyone except the version manager."

              https://rmod-files.lille.inria.fr/FreeBooks/TheInteractivePr...

              Later "ENVY/Developer":

              https://archive.esug.org/HistoricalDocuments/TheSmalltalkRep...

    • By hasley 2025-06-017:181 reply

      I am thinking more about Julia here - which I would use if Python was not that common in several communities.

      • By pansa2 2025-06-019:152 reply

        Is it common in Julia to use multiple-dispatch on 3 or more arguments, or just double-dispatch?

        Julia definitely made the right choice to implement operators in terms of double-dispatch - it’s straightforward to know what happens when you write `a + b`. Whereas in Python, the addition is turned into a complex set of rules to determine whether to call `a.__add__(b)` or `b.__radd__(a)` - and it can still get it wrong in some fairly simple cases, e.g. when `type(a)` and `type(b)` are sibling classes.

        I wonder whether Python would have been better off implementing double-dispatch natively (especially for operators) - could it get most of the elegance of Julia without the complexity of full multiple-dispatch?

        • By ChrisRackauckas 2025-06-0110:23

          It's not uncommon to dispatch on 3 or more arguments. Linear algebra specializations are one case where I tend to do this a lot, for example specializing on structured matrix types (block banded matrices) against non-standard vectors (GPU arrays), you then need to specialize on the output vector to make it non-ambiguous in many cases.

        • By StefanKarpinski 2025-06-0212:57

          The paper "Julia: Dynamism and Performance Reconciled by Design" [1] (work largely by Jan Vitek's group at North Eastern, with collaboration from Julia co-creators, myself included), has a really interesting section on multiple dispatch, comparing how different languages with support for it make use of it in practice. The takeaway is that Julia has a much higher "dispatch ratio" and "degree of dispatch" than other systems—it really does lean into multiple dispatch harder than any other language. As to why this is the case: in Julia, multiple dispatch is not opt-in, it's always-on, and it has no runtime cost, so there's no reason not to use it. Anecdotally, once you get used to using multiple dispatch everywhere, when you go back to a language without it, it feels like programming in a straight jacket.

          Double dispatch feels like kind of a hack, tbh, but it is easier to implement and would certainly be an improvement over Python's awkward `__add__` and `__radd__` methods.

          [1] https://janvitek.org/pubs/oopsla18b.pdf

    • By ethagnawl 2025-06-017:531 reply

      I will forever think of this talk by Peter Siebel (author of Practical Common Lisp) whenever I hear about multiple dispatch: https://youtu.be/VeAdryYZ7ak?si=wY3RmcRnW96jxQQm

      • By cdrini 2025-06-0110:431 reply

        That's a very long video you've linked to :P could you talk about why multiple dispatch reminds you of that talk?

        • By ethagnawl 2025-06-0112:32

          Seibel spends a fair amount of time talking about how powerful and flexible CL's dynamic dispatch mechanism is and how/why it was still a novel feature at the time (~15 years ago).

  • By ziihrs 2025-06-016:181 reply

    I'm curious about what makes this implementation faster than the alternatives.

    Also, if you care about function call performance, I guess you'd use PyPy. Have you tried to run the benchmarks (with appropriate warmup) on PyPy to see if the results carry over?

    • By breuleux 2025-06-0115:111 reply

      Mainly codegen. Most other libraries do something like `return dispatch_dictionary[tuple(map(type, args))](*args)`. Whereas ovld generates a specialized function to the set of actual signatures of the overloads (still with a dictionary, but you remove a surprising amount of overhead just from removing varargs). The code is registered in the linecache, so you can step into it with pdb if you want to look at it. After that I became a bit obsessive about pushing it further and further (because it's fun). So... when dependent types are involved, it will actually generate custom dispatch code, e.g. if you define an @ovld with x:Literal[0], for example, it will generate an `if x == 0` in the int dispatch path (and you can define custom codegens for new types like Regexp).

      Regarding PyPy, I did run some benchmarks recently (I use pytest-benchmark). The advantage over other libraries remains and the magnitude is similar, but when I compare with custom if/isinstance code, that code is optimized a lot more aggressively and it gains a significant edge (let's say ~5x). Now, since I'm into codegen, part of me feels like meeting the challenge and figuring out a way to generate optimal code from a set of signatures, but I think I've spent enough time as it is haha.

      • By ziihrs 2025-06-0117:55

        Very interesting, thanks for the explanation!

  • By paddy_m 2025-06-0110:585 reply

    This looks really cool. I get multiple dispatch, multi methods, and CLOS method specialization confused in my head. I can understand what this does... But when do you use it.

    Can people list some times when they actually used multimethods to solve a real problem? How did it work out? Would you do it again?

    • By SatvikBeri 2025-06-0111:111 reply

      My company has used Julia for about 5 years now. 90% of the time in application code you only need single dispatch, same as OOP.

      One case where I actually rely on multiple dispatch is conversion to more or less structured data. Inspired by Clojure, I have a function `conform(OutputDataType, input_data::SomeType)` and specific implementations depend on both types involved.

      Multiple dispatch is also really helpful when two libraries don't quite do the same thing. In python pandas, numpy, and pytorch all have slightly different implementations of x.std() (standard deviation) with slightly different APIs. This means you can't write code that's generic across a numpy array or a pytorch tensor. In Python this could only easily be fixed if library authors coordinate, but with multiple dispatch you can just fix this in your own code.

      • By paddy_m 2025-06-0116:041 reply

        That makes sense for writing your own generic libraries. Do you frequently have to convert from pytorch to numpy? I would think that a for a given execution unit (pipeline step, single program, model running on a server) that the data will stay as the same type after some initial conversion.

        • By SatvikBeri 2025-06-0116:481 reply

          I'd say we have about 60% library code and 40% application code internally, so making it easier to write libraries is really nice. E.g. you don't want to have to write different statistical calculations for every type you use.

          In particular, the fact that these types would silently give you different answers if you called `.std()` was a big headache

          It was very common for us to want to be generic over pandas series and numpy arrays. A bit less so with pytorch tensors, but that was because we just aggressively converted them to numpy arrays. Fundamentally these three are all very similar types so it's frustrating to have to treat them differently.

          • By paddy_m 2025-06-0117:211 reply

            which implementation of `std()` did you go with?

            I was writing unit tests once against histograms. That code is super finnicky, and I couldn't get pandas and polars numbers to tie out. I wasn't super concerned with the exact output for my application, just that number of buckets was the same and they were roughly the same size. Just bumping to a new version of numpy would result in test breakages because of floating point rounding errors. I'm sure there are much more robust numerical testing things I could have done

            • By SatvikBeri 2025-06-0119:24

              Yeah this kind of stuff drove us crazy. Numpy uses things like SIMD everywhere with no option to turn it off, which is good for performance but makes life really hard when you want consistently reproducible results across different machines.

              Before switching to Julia we eventually standardized on numpy.std with ddof=1, and with some error tolerances in our tests.

    • By breuleux 2025-06-0115:45

      I use it quite a lot (having made it), most often when I need to recursively process complex heterogeneous data structures. Merging two data structures, treemap, processing a Python AST, etc. Also in any situation where you want to be able to register new behavior.

      One case where I'm finding it extremely useful is that I'm currently using ovld to implement a serialization library (https://github.com/breuleux/serieux, but I haven't documented anything yet). The arguments for deserialization are (declared_type, value, context). With multimethods, I can define

      * deserialize(type[int], str, Any) to deserialize a string into an int

      * deserialize(type[str], Regexp[r"^\$"], Environment) to deserialize $XYZ as an environment variable lookup but only if the context has the Environment type

      * deserialize(type[Any], Path, WorkingDirectory) to deserialize from a file

      That's one situation where ovld's performance and codegen capabilities come in handy: the overhead is low enough to remain in the same performance ballpark as Pydantic v2.

    • By nbadg 2025-06-0111:531 reply

      Also, dependent types, IE value-based dispatch. Which can be incredibly useful when dealing with enums. That alone is enough to make me curious to try it!

      • By paddy_m 2025-06-0112:511 reply

        Value based dispatch is a much better name for it then Dependent Types. I have seen the term dependent types and just glossed over it because I thought it was a more complex topic.

        • By breuleux 2025-06-0115:501 reply

          Yeah, I suppose. I wrote "dependent" because I think that's the term of art of it, but you're making me think I should probably change the header to something more intuitive to people unfamiliar with type theory.

          • By paddy_m 2025-06-0116:07

            I was just commenting that I had glossed over that in multiple readings about different typing systems. The parent of my original comment explained it nicely.

            I know that dependent type is the term of art, and you should probably keep it. You could say something along the lines of

            "ovld supports dependent types (an additionally specific name for a type that is based on its value, ie > 0)" the first time you use the term dependent types."

    • By perlgeek 2025-06-0114:41

      A use case is when a sub or method needs to do different things based on the data type of the argument.

      Example: turning a data structure into a JSON string, solved with multi dispatch: https://github.com/moritz/json/blob/master/lib/JSON/Tiny.pm#...

      Another common useful example is constructors. A Date class might have constructors that accept

         Date(Int, Int where 1..12, Int where 1..31) # Y, M, D
         Date(Str where /^\d4-\d2-\d2$/) # YYYY-MM-DD
         Date(DateTime) # extract the date of a DateTime object
      
      etc. to make it very intuitive to use.

    • By blarg1 2025-06-029:54

      Makes naming functions easier, eg multiply(mat3,vec3), multiply(mat3,mat3), etc

      If the language is dynamically typed, you can use it for polymorphism.

HackerNews