Your app turns values into JSON-friendly data. Integers stay integers, Decimal
values become strings, and an unknown type is an error. A chain of
isinstance checks works until another module adds a Money type, and then
someone has to edit your function. With functools.singledispatch, that module
registers its own handler instead:
from decimal import Decimal
from functools import singledispatch
@singledispatch
def encode(value: object) -> object:
raise TypeError(f"no encoding for {type(value).__name__}")
@encode.register
def encode_int(value: int) -> object:
return int(value)
@encode.register
def encode_decimal(value: Decimal) -> object:
return str(value)
print(encode(3), encode(Decimal("1.50"))) # 3 1.50
print(encode(True)) # 1
The @singledispatch decorator makes encode the fallback for any type that
has no handler. Each @encode.register reads the type annotation on the
handler's first parameter and stores the handler under that type. On every
call, encode looks at the type of its first argument and runs the matching
handler. Only the first argument counts, and you must pass it by position. A
call such as encode(value=3) raises TypeError.
A subclass uses its parent's handler
The call encode(True) printed 1. A bool is a subclass of int, and with no
bool handler, singledispatch picks the handler for the closest parent
class. That handler's int(value) call then turns True into 1. Register
bool separately if you want to keep booleans.
singledispatch always picks the closest class. For a string, it picks a
str handler over a Sequence handler, because str is the more specific
class.
Register containers by their class
At runtime, a list does not record the type of its items, so list[int]
cannot pick a handler. For a handler annotated value: list[object],
register raises a TypeError that says "list[object] is not a class". Pass
the class yourself, and keep the detailed annotation on the handler:
def encode_list(value: list[object]) -> object:
return [encode(item) for item in value]
encode.register(list, encode_list)
print(encode([2, Decimal("0.25"), True])) # [2, '0.25', 1]
The handler calls encode on each item, so a list of any registered type
works, including a list of lists.
Registrations last as long as the function
The registry belongs to encode itself. Registering the same class again
replaces its handler, and nothing ever removes one. A class defined inside a
function is a new class on every call, so registering it on every call adds a
new entry each time. Register long-lived types once, when your program starts.
The exercise registers a class from inside solve on purpose. It shows that a
handler for one call's class does not match the next call's class.
Methods use singledispatchmethod
For a method, use singledispatchmethod. It picks the handler by the first
argument after self:
from functools import singledispatchmethod
class Formatter:
@singledispatchmethod
def format(self, value: object) -> str:
return str(value)
@format.register
def format_int(self, value: int) -> str:
return f"integer: {value}"
print(Formatter().format(4)) # integer: 4
print(Formatter().format("four")) # four
Check your understanding
Another module defines Money and wants encode(Money(250)) to return {"cents": 250}. What does it write, and what changes in your module?
Show answer
Hide answer
It writes a handler such as def encode_money(value: Money) -> object in its
own module and decorates it with @encode.register. Your module does not
change at all, which is the point of singledispatch. With a chain of
isinstance checks, someone would have to edit your function for each new
type.
Going further
- Since Python 3.11,
registeralso accepts a union, so one handler annotatedvalue: int | floatcovers both types. - A value can match two registered abstract types from
collections.abcequally well. For example, with handlers for bothSizedandIterable, the callencode([1])raisesRuntimeError, because a list is both. Register the concrete class, herelist, to settle it.
The functools documentation gives the full rules.