Skip to content

Overloading, dispatch & binding

Senior

Why Python has no method overloading, singledispatch, typing.overload, operator dunders and reflected ops, alternative constructors, multiple dispatch, virtual calls, and bound methods.

Created 12 September 2026 · Updated 23 October 2026

27 concepts · detail hidden

  1. 01Why Python has no method overloading — and what a duplicate method name in a class body does

    Traditional method overloading (defining multiple methods with the same name but different parameter signatures, with the compiler picking the right one based on the call site) relies on the compiler statically knowing argument types ahead of time — which is fundamentally at odds with Python’s dynamically typed nature, where a method is really just a name bound to a function object inside a namespace (a dict, effectively). If you define two methods with the same name inside one class body, the second def simply overwrites the first in that namespace — there’s no error, no dispatch logic, and no trace of the first definition remaining at all; only the last one defined actually exists on the class. A class defining both def greet(self, name): and later def greet(self): ends up with only the parameterless version — calling greet("Alice") afterward would simply raise a TypeError for an unexpected argument, since the first definition is entirely gone, not “overloaded” in any sense.

    #c-why-python-has-no-method-overloading-and-what-a-duplicate-me
  2. 02Idiomatic ways to vary behaviour by argument without signature overloading

    A good spread of answers: default/optional arguments (a single function signature handling multiple call shapes via optional parameters, simplest when the variation is minor); *args/**kwargs combined with manual argument-count/type inspection inside the function body (more flexible, but pushes complexity into runtime checks you have to write yourself); functools.singledispatch for cleanly dispatching different implementations based on the type of the first argument, without a pile of manual isinstance checks; and, specifically for constructors, alternative constructors via @classmethod rather than trying to cram multiple construction styles into one overloaded-feeling __init__. Python solves with several idiomatic tools a problem other languages address with a single built-in feature.

    #c-idiomatic-ways-to-vary-behaviour-by-argument-without-signatu
  3. 03`functools.singledispatch` — dispatch keyed on the first argument's type, with MRO fallback

    functools.singledispatch turns an ordinary function into a generic function: you define a default implementation, then @my_func.register(SomeType) additional implementations for specific types, and calling my_func(x) automatically picks the implementation matching x’s actual runtime type. The key, easy-to-get-wrong detail: dispatch is based exclusively on the type of the first argument — not any combination of arguments (that would be genuine multiple dispatch, which Python doesn’t natively support), and not the second or later arguments even if they’d seem more semantically relevant in a given case. If you call it with a value of a subclass that has no implementation explicitly registered for it, singledispatch falls back by walking the MRO — using the nearest registered ancestor’s implementation, or the original default implementation if nothing along the MRO matches.

    #c-functoolssingledispatch-dispatch-keyed-on-the-first-argument
  4. 04`singledispatch` vs `singledispatchmethod` — why methods need the second argument's type

    Since singledispatch dispatches strictly on the first argument’s type, naively applying it directly to an ordinary instance method would dispatch on the type of self — which is always just “an instance of this class” for every call, making the whole dispatch mechanism useless in that context. functools.singledispatchmethod (added in Python 3.8) exists specifically to handle this correctly: it’s aware it’s decorating a method, and dispatches instead on the type of the second argument (the first genuine parameter after self) — giving you the same type-based dispatch behavior, correctly adjusted for the fact that a method always has an implicit first argument that isn’t the thing you actually want to dispatch on.

    #c-singledispatch-vs-singledispatchmethod-why-methods-need-the-
  5. 05Registering a `singledispatch` implementation against an ABC — virtual subclassing

    You can register an implementation against an abstract base class like collections.abc.Iterable (@dispatcher.register(collections.abc.Iterable)), and it will correctly match any object recognized as an Iterable — not just objects that literally, nominally inherit from Iterable directly, but also ones satisfying it structurally (defining __iter__), because many of the standard-library ABCs implement a __subclasshook__ that makes isinstance/issubclass checks succeed based on the presence of the right methods, not strict formal inheritance. This means singledispatch isn’t limited to exact-type matching the way it might first appear — it piggybacks on this same underlying ABC-registry/virtual-subclass machinery, so registering against a suitably-defined ABC or Protocol gives you dispatch based on structural/behavioral compatibility, not just nominal inheritance.

    #c-registering-a-singledispatch-implementation-against-an-abc-v
  6. 06`singledispatch` performance — cached MRO resolution, and when a hot path wants `isinstance`

    singledispatch builds and internally caches an MRO-based dispatch resolution rather than performing a naive linear scan through every registered type on every single call — so repeated calls with the same concrete type resolve quickly via that cache rather than re-walking the whole registration set each time. That said, it’s worth reasoning about whether the dynamic dispatch overhead (still meaningfully more than a direct function call) actually matters for a specific hot path, rather than assuming it never does — it’s reasonable to choose a simple, explicit if isinstance(...) chain instead in a genuinely performance-critical inner loop, purely for the reduced indirection, even though singledispatch would work correctly there too.

    #c-singledispatch-performance-cached-mro-resolution-and-when-a-
  7. 07`@typing.overload` at runtime — the stubs never execute, only the real implementation does

    The intuitive-but-wrong assumption (“it picks the matching overload, like Java”) is common. In reality, @typing.overload stub definitions exist purely for the benefit of static type checkers (mypy, pyright) — at runtime, those stub bodies (typically just ...) are never actually executed at all; only the final, non-@overload-decorated implementation is the real, live function that ever runs, regardless of which “overload” a static type checker might have matched the call signature against for type-checking purposes. Calling the function invokes that single real implementation every time, with no runtime dispatch to any of the @overload-decorated stubs whatsoever.

    #c-typingoverload-at-runtime-the-stubs-never-execute-only-the-r
  8. 08Why an `@overload`-annotated function still needs `isinstance` checks in its real body

    The @overload stubs let a type checker correctly narrow the inferred return type at each call site based on the argument type used — genuinely useful for downstream type-checking accuracy in code that calls this function. But since none of that logic exists at runtime, the actual implementation function still has to contain real, explicit isinstance (or equivalent) checks to determine, at runtime, which branch of behavior to actually execute for a given call — the overload declarations describe the type-level contract for static analysis, while the real implementation still has to independently, manually enforce that same behavior for real, executing code.

    #c-why-an-overload-annotated-function-still-needs-isinstance-ch
  9. 09`__add__`, `NotImplemented`, and `__radd__` — the reflected-operation fallback chain

    When you write vector + 5, Python first tries vector.__add__(5). If that method genuinely doesn’t know how to handle a plain integer operand, the correct, idiomatic response is to return the special singleton value NotImplemented — critically, this is not the same as raising an exception; it’s a specific signal telling Python “I personally don’t know how to do this, try something else.” Upon receiving NotImplemented, Python then tries the reflected operation on the other operand: (5).__radd__(vector). Only if that also returns NotImplemented (or doesn’t exist) does Python finally raise a genuine TypeError. This NotImplemented-returning convention (rather than raising directly from __add__) is precisely what makes this whole reflected-method fallback chain work at all — raising directly from __add__ would short-circuit the process and prevent __radd__ from ever getting its chance.

    #c-__add__-notimplemented-and-__radd__-the-reflected-operation-
  10. 10`__add__` vs `__radd__` vs `__iadd__` — and why `+=` is safe on immutable types

    __add__ handles a + b when a is the left operand; __radd__ is the fallback handling a + b from b’s side, when a’s own __add__ didn’t know how to handle it. __iadd__ is specifically for in-place addition (a += b) — genuinely mutating a directly rather than creating and returning a new object, which only makes sense for mutable types (like list, where a += [x] really does mutate the existing list object in place). For an immutable type, defining __iadd__ doesn’t make sense in that mutating way — and if a class defines no __iadd__ at all, Python’s a += b simply falls back to the ordinary a = a.__add__(b) (or the equivalent reflected fallback) — meaning it works correctly, just by rebinding the name a to a brand-new object rather than mutating anything in place. This surprises people coming from languages where += always implies mutation — on an immutable custom type, += is perfectly safe and functional, it just quietly never mutates anything, always producing a new object instead.

    #c-__add__-vs-__radd__-vs-__iadd__-and-why-is-safe-on-immutable
  11. 11`__eq__` and `__hash__` — why defining `__eq__` alone makes instances unhashable

    Python enforces an important invariant: any two objects that compare equal (==) must also produce the same hash value, since dicts and sets rely on this to correctly find/deduplicate entries. If you define a custom __eq__ (say, comparing objects by their field values rather than identity) but leave __hash__ untouched, you’d risk violating that invariant — so Python’s actual, deliberate safety behavior is that defining __eq__ alone automatically sets __hash__ to None on your class, making instances unhashable by default, raising a real TypeError: unhashable type the moment you try to use one as a dict key or put it in a set. This is a genuinely common, real runtime bug that surprises people focused purely on equality semantics. If you do define both consistently, and later accidentally violate the “equal objects must have equal hashes” invariant anyway (e.g., through a coding mistake), the practical failure mode is subtle and quiet: objects can become effectively “lost” inside a dict/set — a lookup for a genuinely-equal object can fail to find an entry that logically should match, because the hash-based bucket it’s searching doesn’t correspond to where the “equal” object was actually stored.

    #c-__eq__-and-__hash__-why-defining-__eq__-alone-makes-instance
  12. 12`functools.total_ordering` — deriving all comparisons from `__eq__` plus one ordering method

    functools.total_ordering is a class decorator that fills in the remaining rich-comparison dunder methods for you, given just a minimal starting set — specifically, it needs __eq__ plus exactly one of __lt__, __le__, __gt__, or __ge__ defined explicitly, and it derives the rest from that pair algorithmically. It genuinely can’t derive everything starting from __eq__ alone, since equality alone tells you nothing about relative ordering (two unequal objects could be “less than” or “greater than” each other, and __eq__ provides no information distinguishing which) — an actual ordering comparison has to be the seed the rest is logically built from.

    #c-functoolstotal_ordering-deriving-all-comparisons-from-__eq__
  13. 13Alternative constructors as `@classmethod`s instead of an `isinstance`-branching `__init__`

    Rather than one __init__ internally branching on argument types/counts to figure out “which construction style is this,” the more idiomatic Python pattern is defining separate, clearly-named alternative constructors as @classmethods — Point.from_string("3,4"), Point.from_point(other_point) — with the plain Point(x, y) form remaining the simple, primary __init__. This reads far more clearly at every call site (the method name itself documents the construction style being used), and is much easier to correctly type-annotate (each classmethod has one clean, unambiguous signature) than a single polymorphic __init__ trying to accept several structurally different argument shapes and branch internally.

    Why it must be @classmethod, not @staticmethod

    @staticmethod takes no automatic first argument at all (no self, no cls) — it’s just a plain function namespaced inside the class. @classmethod automatically receives the class itself as its first argument (cls), whether it’s called via the class or via an instance. That cls is what makes a classmethod genuinely required, not merely convenient, for alternative constructors: it constructs an instance of whichever class it was actually called on, so subclasses inherit a working constructor for free. A staticmethod has to hardcode the class name and silently returns the wrong type for subclasses.

    class Point:
        def __init__(self, x, y):
            self.x, self.y = x, y
    
        @classmethod
        def from_string(cls, s):
            x, y = map(float, s.split(","))
            return cls(x, y)                 # whichever class was called
    
        @staticmethod
        def from_string_static(s):
            x, y = map(float, s.split(","))
            return Point(x, y)               # hardcoded base class
    
    class Point3D(Point):
        pass
    
    type(Point3D.from_string("3,4"))         # -> Point3D
    type(Point3D.from_string_static("3,4"))  # -> Point   (subclass lost)
    

    Use @staticmethod only for helpers that need neither the instance nor the class (a validation function that logically belongs to the class’s namespace).

    #c-alternative-constructors-as-classmethods-instead-of-an-isins
  14. 14`__class_getitem__` — why `list[int]` works, and subscript overloading at the class level

    __class_getitem__ (introduced via PEP 585) is a special method a class can define to control what SomeClass[X] means when applied to the class itself (rather than an instance) — this is precisely the mechanism that lets built-in types like list support the modern list[int] generic-annotation syntax directly, without list needing to be a fundamentally different, specially-built “generic” class the way older typing.List[int] required a separate parallel type from typing. In effect, it’s “overloading” the subscript ([]) operator specifically at the class level (as opposed to __getitem__, which overloads subscripting on instances) — a nice example of how far Python’s operator-overloading philosophy extends beyond just arithmetic operators, into syntax most people don’t initially think of as “overloadable” at all.

    #c-__class_getitem__-why-listint-works-and-subscript-overloadin
  15. 15Hand-rolling multiple dispatch — a `(type, type)` registry, and resolving inheritance ambiguity

    The natural approach: a registry (a dict) keyed by a (type(a), type(b)) tuple, mapping each specific type-pair combination to its own implementation function, with the dispatching function looking up (type(a), type(b)) in that registry to find and call the right implementation. The genuinely hard part — and the real signal of whether someone’s actually built something like this — is ambiguity resolution: what happens when, due to inheritance, more than one registered signature could technically apply to a given call (e.g., you registered (Shape, Shape) as a generic fallback, and also (Circle, Rectangle) specifically, and someone calls with two Circle subclass instances)? A naive implementation might only support exact type matches (simple, but brittle against subclassing); a more sophisticated one walks both arguments’ MROs looking for the most specific applicable match — which itself opens the door to genuinely ambiguous cases where no single “most specific” match clearly exists, requiring a deliberate policy decision (raise an error on ambiguity, rather than picking arbitrarily).

    #c-hand-rolling-multiple-dispatch-a-type-type-registry-and-reso
  16. 16Why Python has no first-class multiple dispatch — the flexibility vs predictability tradeoff

    This is a genuine language-design tradeoff, not an oversight: as a class hierarchy grows in a system supporting real multiple dispatch, determining which of several potentially-applicable implementations actually wins for a given call can become genuinely surprising and hard to reason about purely by reading the call site — the resolution depends on the entire type hierarchy’s shape, not something locally visible. Python’s much more limited default (single dispatch via ordinary method calls, or explicit isinstance branching when more is needed) keeps dispatch resolution more locally, predictably reasoned-about, at the cost of the elegance and conciseness multiple dispatch gives languages (like Julia) genuinely built around it as a core paradigm from the start. This is a real, deliberate tradeoff, not simply “Python just wasn’t designed that way.”

    #c-why-python-has-no-first-class-multiple-dispatch-the-flexibil
  17. 17The Visitor pattern — double dispatch from two chained single-dispatch calls

    The Visitor pattern’s elegant trick: it achieves the effect of double dispatch using nothing but two chained ordinary (single-dispatch) virtual method calls. The first dispatch happens completely normally — calling shape.accept(visitor) dispatches, as usual, based purely on shape’s actual runtime type (which accept method runs). Inside that method, the second dispatch happens because it calls back a type-specific method on the visitor object itself — e.g., visitor.visit_circle(self) — meaning the second dispatch is effectively “baked into” the first method’s own code, choosing which visitor method to call based on which concrete shape’s accept is executing. Two ordinary single-dispatch calls, chained together, jointly achieve a result equivalent to genuine double dispatch on both types — a genuinely elegant realization once you see it, worth understanding mechanically rather than just as a pattern name.

    #c-the-visitor-pattern-double-dispatch-from-two-chained-single-
  18. 18Multiple-dispatch libraries vs hand-rolled `isinstance` or the Visitor pattern — the tradeoff

    Real multiple-dispatch libraries genuinely exist (multipledispatch, plum-dispatch, and others) and give cleaner call sites — you register implementations by type signature and the library handles resolution automatically — centralizing dispatch logic that might otherwise be scattered across a manually-written chain of isinstance checks. The real tradeoff: this introduces genuine “magic” that makes control flow harder to trace purely by reading the code top-to-bottom (you can’t tell which implementation runs for a given call just by reading linearly; you have to consult the registrations), and can produce genuinely confusing ambiguous-match errors surfacing at runtime, at the actual call site, rather than being something a reader could have anticipated just from reading the code — a real, honest cost against the convenience gained.

    #c-multiple-dispatch-libraries-vs-hand-rolled-isinstance-or-the
  19. 19Why operator overloading is a limited built-in double dispatch that doesn't generalize

    It genuinely qualifies as double dispatch: Python first tries the left operand’s __add__, and — specifically only if that returns NotImplemented — falls back to trying the right operand’s __radd__ instead; the ultimate outcome of a + b therefore genuinely depends on the combination of both operands’ actual types, which is exactly what double dispatch means. But this behavior is hardcoded, at the language level, into a fixed, small, specific set of dunder-method pairs reserved for operators (__add__/__radd__, __mul__/__rmul__, and so on) — you don’t get this same fallback-based double-dispatch behavior “for free” for an arbitrary method name of your own choosing the way a genuine, general-purpose multi-dispatch system would provide.

    #c-why-operator-overloading-is-a-limited-built-in-double-dispat
  20. 20Why every Python method call is effectively "virtual" — there are no non-virtual methods

    The honest, correct answer is that this distinction doesn’t meaningfully exist in Python at all: every single Python method call is effectively “virtual” in the sense other languages use that term — since Python has no static, compile-time type declarations to resolve a call against in the first place, dispatch is always determined by the object’s actual, current runtime type, resolved via ordinary attribute lookup. There’s no final/non-virtual keyword, no opt-out mechanism, no way to request the C++/Java-style “resolve this call statically, at compile time, based purely on the declared type” behavior at all. “Virtual” in the C++/Java sense specifically means a runtime vtable indirection resolving an ambiguity that a compiler couldn’t settle statically — Python has no compile-time resolution to distinguish that indirection from in the first place.

    #c-why-every-python-method-call-is-effectively-virtual-there-ar
  21. 21How "the right override gets called" works with no vtable — just MRO attribute lookup

    There’s no vtable (a compact, fixed-position array of function pointers, indexed by a compile-time-known offset, that C++/Java use to resolve virtual calls efficiently at runtime) — instead, it’s simply the exact same general attribute-lookup mechanism used for every attribute access in Python: Python walks type(obj).__mro__ in order, looking for method, and finds it defined on the most-derived class that actually defines it — since the MRO always places subclasses before the base classes they inherit from — and that’s the implementation that gets called. The “correct override wins” behavior that other languages implement via a dedicated, special vtable-indirection mechanism, Python gets entirely “for free” as an incidental side effect of how ordinary attribute lookup already works generally — it’s not a special case reserved for methods specifically at all.

    #c-how-the-right-override-gets-called-works-with-no-vtable-just
  22. 22Forcing a specific class's method implementation via `Base.method(self, ...)` — and why

    Base.method(self, ...), called explicitly by name on a specific class (rather than through ordinary instance-attribute-access dispatch), forces exactly that class’s implementation to run, entirely bypassing whatever override the instance’s actual type might otherwise have provided (super().method(...), called from within an overriding method, is subtly different, since it follows the MRO rather than hardcoding one specific class). A real, legitimate reason to want this: a subclass’s own override might deliberately want to invoke a sibling class’s implementation, or explicitly skip its own override logic for one narrow, specific call path — genuinely rare in practice, but real (e.g., a subclass overriding __eq__ for value-based comparison in general, but wanting plain object-identity comparison specifically in one particular internal codepath, achieved by explicitly calling object.__eq__(self, other) there).

    #c-forcing-a-specific-classs-method-implementation-via-basemeth
  23. 23`Base.method(self)` vs `super().method()` — where they diverge under multiple inheritance

    In straightforward single inheritance, both approaches happen to produce the same result, since there’s only one possible “next” implementation either way. In a genuinely cooperative multiple-inheritance diamond, though, super().method() follows the MRO — not simply “my one direct base class” — meaning it may end up calling a genuinely different class’s implementation than the class that’s literally, textually listed as the immediate parent, specifically so that every class along the diamond’s cooperative chain gets its turn to run exactly once, in the correctly-computed order. Base.method(self), by contrast, hardcodes one specific class explicitly, entirely bypassing that MRO-based cooperative chaining — which can silently cause a sibling class somewhere else in the diamond to have its own __init__/method never get called at all, a genuinely subtle bug. A great many developers use super() correctly purely by habit/convention, without being able to actually explain why it isn’t simply sugar for “call whatever my parent class defines.”

    #c-basemethodself-vs-supermethod-where-they-diverge-under-multi
  24. 24Bound methods — `__self__`, `__func__`, and the function-as-descriptor `__get__` that produces them

    Ordinary Python functions are themselves non-data descriptors — they implement __get__, even though people rarely think of “a function” as “a descriptor” in everyday use. Accessing instance.method triggers that underlying function’s __get__(instance, owner), which returns a brand-new bound method object — a small wrapper carrying __self__ (set to instance) and __func__ (set to the original, plain function) — and it’s specifically that bound-method object’s own __call__ behavior that automatically supplies instance as the first (self) argument whenever it’s actually invoked. This is exactly the same general descriptor machinery underlying property — there’s no separate, special-cased “method binding” logic living somewhere else in the language; it’s one single, unified mechanism applied consistently.

    #c-bound-methods-__self__-__func__-and-the-function-as-descript
  25. 25`MyClass.method` in Python 3 is just a plain function — why "unbound methods" were removed

    In Python 3, accessing MyClass.method (on the class itself, not an instance) simply gives you back the plain, ordinary, undecorated function object — no special wrapping type at all. Python 2’s now-removed “unbound method” concept existed mainly to add a runtime check enforcing that the first argument passed to a method, when called this way, was genuinely an instance of the correct class (or a subclass) — that specific enforcement was deemed not worth the added complexity and inconsistency it introduced, especially given Python’s broader duck-typing philosophy generally discourages this kind of rigid, nominal-type enforcement anyway.

    #c-myclassmethod-in-python-3-is-just-a-plain-function-why-unbou
  26. 26Why a function assigned onto an instance doesn't bind `self` — descriptors only fire via the class

    No, and the precise “why” is genuinely subtle: the descriptor protocol’s __get__ is only actually invoked when the attribute in question is found via lookup on the class (walked through type(obj).__mro__) — an attribute found directly sitting in the instance’s own __dict__ is simply returned exactly as-is, with no descriptor-protocol invocation at all applied to it, even though the object itself (an ordinary function) technically is a descriptor in the abstract. So instance.foo(), where foo was assigned directly onto the instance rather than defined on the class, calls the plain function with zero arguments automatically supplied — a genuine, subtle bug that surprises people attempting to “monkey-patch a method onto one specific instance,” expecting normal bound-method behavior. The fix, if that binding behavior is genuinely wanted: explicitly wrap it first via types.MethodType(some_function, instance) before assigning it onto the instance.

    #c-why-a-function-assigned-onto-an-instance-doesnt-bind-self-de
  27. 27How `classmethod` and `staticmethod` `__get__` differ from a plain function's

    A plain function’s __get__ binds whatever it’s accessed through as the first argument (self for an instance). classmethod.__get__ instead always supplies the class as the first argument (conventionally named cls) — regardless of whether it was accessed via an instance or directly via the class itself; even instance.some_classmethod() still receives the class, never the specific instance. staticmethod.__get__, by contrast, returns the underlying function completely unwrapped and unbound — with no first argument automatically supplied at all — which is precisely the mechanism letting a “static method,” despite syntactically living inside a class body and being accessed via ordinary instance/class attribute syntax, genuinely avoid receiving any implicit self/cls argument whatsoever.

    #c-how-classmethod-and-staticmethod-__get__-differ-from-a-plain