skip to content

Where does functools.singledispatch beat an if/elif isinstance chain, and where does it not?

level: seniorimportance: should knowfreq 38%

answer

  1. Who adds the next case?
  2. Branch order versus class specificity
  3. Extension without editing the operation
  4. Registry costs discoverability, buys openness
  5. Register at import, not under traffic

basics

~20 s

functools.singledispatch wins when the set of handled classes grows from outside the module that defines the operation: each class's owner registers its own implementation, and resolution is by specificity, not branch order. A short closed chain stays a chain.

solid answer

~50 s

Take a flight-schedule differ that renders diff entries — cancellations, gate moves, equipment swaps — where new entry classes arrive from separate modules. As an `if/elif isinstance` chain, every new class means editing one function that every team touches, and the chain is order-sensitive: a base-class branch written above a subclass branch makes the lower one unreachable, silently. With `functools.singledispatch` each module registers its own implementation, resolution is by specificity so order is irrelevant, and `dispatch()` lets a test assert coverage. It loses when the behaviour belongs on classes you own (a plain method is more discoverable), when the decision depends on values rather than classes, or on more than one argument, and when the chain is two local branches — then the registry is indirection with no payoff. Register at import time, never lazily under concurrency.

code

python · 24 lines
python
import functools

class ScheduleEntry: pass
class Cancellation(ScheduleEntry): pass

def render_chain(entry):
    if isinstance(entry, ScheduleEntry):   # written first: swallows subclasses
        return "generic entry"
    elif isinstance(entry, Cancellation):  # unreachable, and silent
        return "cancellation"

@functools.singledispatch
def render(entry):
    return "unknown"

@render.register
def _(entry: ScheduleEntry):
    return "generic entry"

@render.register
def _(entry: Cancellation):
    return "cancellation"

print(render_chain(Cancellation()), render(Cancellation()), sep=" | ")

go deeper

for a junior

Know the basic contrast: an isinstance chain is checked top to bottom and lives in one function, while a generic function keeps implementations in a registry that other modules can add to. Both are legitimate; the chain is fine when it is short.

for a middle

Be able to name the concrete wins: order-independent resolution by specificity, extension without editing the operation's module, and an inspectable registry. Also name the costs, chiefly that implementations are scattered across whichever modules register them.

for a senior

Demonstrate operating judgement: registrations belong at import time rather than under live traffic, a coverage test using the dispatch attribute catches unimported plugin modules, and the choice is about extensibility, not about speed.

for a principal

Own the boundary decision for the codebase — which operations become extension points open to other teams, who may register, how registrations are discovered at startup, and when behaviour should simply live as a method on classes your organisation controls.

## The concrete comparison Picture a flight-schedule differ: it compares two published schedules and emits entries — a cancellation, a gate move, an equipment swap, a codeshare change — and something must render each entry class for a report. The naive implementation is one function with a chain: ```python def render(entry): if isinstance(entry, Cancellation): ... elif isinstance(entry, GateMove): ... elif isinstance(entry, ScheduleEntry): # base class ... ``` This is perfectly good code when there are three branches and one team owns all of them. The question is what happens as the set of entry classes grows. ## What the chain costs as it grows **Order is load-bearing, and wrong order fails silently.** The chain above is evaluated top to bottom, so a branch testing a base class captures every subclass written below it. Move the `ScheduleEntry` test up two lines and the specific branches become unreachable — no exception, just wrong output. `functools.singledispatch` resolves by walking the argument class's ancestors and taking the nearest registered one, so the order in which registrations were written is irrelevant and a more specific registration always wins over a more general one. That property alone removes a whole bug class. **Every new class edits one shared function.** Teams adding an entry class must modify a function they do not own, which means merge conflicts on the same lines and a review bottleneck. With a generic function, the module that defines `CodeshareChange` also registers how to render it, next to the class, and the differ's own module never changes. **Coverage is inspectable.** A chain's coverage is only knowable by reading it. A generic function's `registry` attribute enumerates what is registered, and `dispatch(cls)` returns the implementation a class would select — so a test can assert that some class does not fall through to the `object` fallback, which is also the cheapest way to catch a plugin module nobody imported. ## Where singledispatch is the wrong answer **When you own the classes.** If `Cancellation` and `GateMove` are your classes and nothing external extends them, a `render()` method on each is more discoverable than a registry: the reader finds the behaviour on the class instead of grepping for registrations. Dispatch machinery earns its keep for classes you cannot or should not modify — builtins, third-party classes, or classes deliberately kept free of presentation concerns. **When the decision is not about a class.** Dispatch keys are classes only. If the branch depends on a field — a cancellation within 24 hours versus one months out — that is a value test, and it stays an `if` inside whichever implementation was selected. Trying to express it through the registry means minting marker classes that exist only to be dispatched on. **When more than one argument decides.** Only the first positional argument is examined. An operation whose behaviour depends on two classes at once cannot be expressed here, and forcing it produces a generic function whose implementations each contain their own inner chain — worse than the honest chain you started with. **When the chain is small and closed.** Two or three branches in a private helper are clearer as branches. Indirection has a real cost: with a chain the reader sees every case at once; with a registry the implementations are wherever their modules are. ## The operational rule: register at import time Registration mutates state shared by every caller — it inserts into the registry and invalidates the per-class resolution cache. So registering lazily, from worker threads handling live traffic, races on that shared state for no benefit: the entry that a request resolves depends on which registrations have landed by then, and the same input can render differently before and after warm-up. Do the registrations at import time, in the module that defines the class, and make sure those modules are actually imported — from the package's own initialisation, or by discovering third-party contributions through installed entry points and importing them during startup. ## Do not choose on speed After the first call for a class, resolution is a cached lookup keyed by that class, so a dispatched call costs roughly a dictionary hit plus a function call — a handful of branches' worth. If a differ has a 92nd-percentile latency budget, that budget is being spent on comparing schedules and building output, not on choosing a renderer. Rewriting a readable two-branch chain into a generic function to make it faster is a misuse; rewriting a twenty-branch chain into one to make it extensible is the point. Measure before you assume either direction.

  • How do you guarantee every implementation module has been imported before the first call?
    Import them explicitly from the owning package's initialisation so importing the package registers everything, and for contributions from separate distributions discover them through installed entry points at startup and import each one. Then assert coverage in a test using the generic function's dispatch attribute, so a forgotten import fails the build rather than silently rendering the fallback.
  • Why is registering implementations lazily, on first use, a bad idea in a threaded service?
    Registration mutates the shared registry and invalidates the resolution cache while other threads are dispatching, so the implementation a request selects depends on what has landed by then and the same input can behave differently before and after warm-up. It is shared mutable state changed under live traffic, and it buys nothing that an import-time registration does not.
  • The branch depends on a field of the entry rather than its class. What then?
    Keep it as an ordinary condition inside the selected implementation. Dispatch keys are classes, so a value test cannot be expressed through the registry without inventing marker classes whose only purpose is to be dispatched on. Class-based dispatch first, value-based branching second, is the readable split.

A chain of isinstance tests is a receptionist with a printed list of departments: correct until someone new moves in, and only the receptionist can update the list. A generic function is a directory board each department writes its own line on.

saying these in an interview costs you the question

  • Claims a generic function is always cleaner than branches
  • Registers implementations lazily inside request handling
  • Rewrites a two-branch chain for performance
  • Assumes implementations are found without importing their modules
  • Expects dispatch to consider two arguments
  • Says isinstance chains are never acceptable

context