Hedronite · Dev Lesson · Polyglot-Dev / Python · Tue 2026-09-22

Python functools.singledispatch and register

One entry function. Many implementations. Dispatch on the type of the first argument.

Lesson Class: Dev (Python language depth)
Language Idiom: @singledispatch + @register on first-arg type
Paired Ops: Python SQS DLQ and redrive policy census
Paired Cert: AWS SAP Direct Connect resiliency / LAG
Grounding: Ramalho Ch.9 Single Dispatch Generic Functions pp.324-329
Base
@singledispatch marks the object fallback.
Register
Specialize by runtime type of arg 0.
Extend
Later modules add registers without editing the base.
Grow the registry. Do not grow the if-tower.

<!-- hal:authoritative:yaml -->

One entry function. Many implementations. Dispatch on the type of the first argument.

§I — Frame

Ops prints SQS redrive rows. Dev needs a formatter that grows when a new row shape appears, without a module-local if/elif tower. Stay in pure language depth. Leave dataclass order (09-19), Protocol plugins (09-10), and match/case (08-14) on their shelves.

Ramalho, printed p.324, opens Single Dispatch Generic Functions with htmlize: one public name, specialized bodies for str, int, containers, and so on. The point is extension by registration, not Java-style method overloading inside one class (printed p.329 soapbox).

§II — Language idiom: generic function, single argument

from functools import singledispatch
from numbers import Integral


@singledispatch
def render(obj) -> str:
    return f"<pre>{obj!r}</pre>"


@render.register
def _(text: str) -> str:
    return f"<p>{text}</p>"


@render.register
def _(n: Integral) -> str:
    return f"<em>{n}</em>"


@render.register(tuple)
def _(items: tuple) -> str:
    inner = "".join(f"<li>{render(x)}</li>" for x in items)
    return f"<ol>{inner}</ol>"

@singledispatch marks the base function (object fallback). Each @render.register (or @render.register(SomeType)) adds a specialized implementation selected by the runtime type of the first argument. That is single dispatch. Multiple arguments would be multiple dispatch; Python stdlib does not ship that here.

Ramalho stresses ABCs and typing.Protocol as good register targets: you register once against Integral instead of separately against int and bool quirks, and you let the MRO walk find the best match. bool is a subclass of int; singledispatch seeks the most specific registered type, so an explicit bool register (if you add one) wins over Integral for True/False.

§III — Worked example against the census row

from dataclasses import dataclass
from functools import singledispatch
from typing import Optional


@dataclass(frozen=True)
class RedriveRow:
    queue_arn: str
    max_receive: Optional[int]
    dlq_arn: Optional[str]
    dlq_visible: Optional[int]


@dataclass(frozen=True)
class BareQueue:
    queue_arn: str
    visible: int


@singledispatch
def line_for(row) -> str:
    return f"UNKNOWN {row!r}"


@line_for.register
def _(row: RedriveRow) -> str:
    hot = (row.dlq_visible or 0) > 0
    flag = "HOT" if hot else "armed"
    return (
        f"{flag} {row.queue_arn} maxReceive={row.max_receive} "
        f"dlq={row.dlq_arn} dlq_vis={row.dlq_visible}"
    )


@line_for.register
def _(row: BareQueue) -> str:
    return f"BARE {row.queue_arn} visible={row.visible}"


rows = [
    BareQueue("arn:aws:sqs:us-east-1:1:orders", 3),
    RedriveRow("arn:aws:sqs:us-east-1:1:payments", 5, "arn:aws:sqs:us-east-1:1:payments-dlq", 12),
    RedriveRow("arn:aws:sqs:us-east-1:1:notify", 3, "arn:aws:sqs:us-east-1:1:notify-dlq", 0),
]

for row in rows:
    print(line_for(row))

Prints:

BARE arn:aws:sqs:us-east-1:1:orders visible=3
HOT arn:aws:sqs:us-east-1:1:payments maxReceive=5 dlq=arn:aws:sqs:us-east-1:1:payments-dlq dlq_vis=12
armed arn:aws:sqs:us-east-1:1:notify maxReceive=3 dlq=arn:aws:sqs:us-east-1:1:notify-dlq dlq_vis=0

Three rules fall out.

Rule one. Register implementations next to the types that own them. A later module can @line_for.register a new row class without editing the base. That is the extensibility Ramalho contrasts with a growing if/elif or match/case dispatcher.

Rule two. Dispatch key is the first positional argument only. Keyword-only first args and later parameters do not select the implementation.

Rule three. Prefer ABC or Protocol registers for families. Registering on Integral covers the numeric tower you intend; registering only on int surprises you when a subclass arrives.

render.registry (and render.dispatch(type)) let you inspect which function wins for a type. Use that in tests when a new row class silently hits the base.

§IV — What not to do

Do not stuff ten overloads into one class and call it singledispatch (Ramalho soapbox, printed p.329). Do not rebuild the same tower with match/case inside the base and then also register. Do not register mutable default traps. Do not pull boto3 into this lesson: Ops owns the census client; Dev owns the dispatch grammar. Do not confuse @singledispatchmethod (for methods, where self is skipped) with @singledispatch on a free function.

§V — Close instruction

Type the line_for example. Confirm BareQueue and RedriveRow hit different registers. Add a third dataclass and register it in a second module import. Pair: Ops supplies the census; Cert is Direct Connect resiliency, not a Python topic.