Recipe Formatter —
One Call,
Six Kinds of Quantity.
A recipe says 2 cups of flour, 1 1/2 cups of milk, a pinch of salt, 2–3 tablespoons of butter. Behind the scenes those quantities are an int, a Fraction, a str and a tuple — and one function, format_quantity(), has to print every one of them correctly.
This project teaches functools.singledispatch, Python's tool for choosing a function by the type of its first argument. You get five explained programs, six exercises, a live dispatch lab, a Python playground and a progress tracker. It is the "single dispatch" flavor from the Polymorphism guide, taken all the way down.
Progress Tracker
Tick off each milestone as you finish it. Progress is saved in this browser, so you can leave and come back anytime.
🎯 Your Journey Through Single Dispatch
0%Why Recipes Are Harder Than They Look
🍳 One Number, Many Shapes
A quantity looks like a number, but in real code it arrives in many different shapes. A user types 2. A scraper reads 0.3333333. Scaling a recipe for six people produces the exact fraction 9/4. The ingredient list says "a pinch". A range appears as (2, 3). A shopping feature adds a price.
Every one of those must print as something a person would write in a cookbook. The question this project answers is: where does the "how do I print this type?" knowledge live — and what happens when a new type shows up?
Every Python example below is interactive: press Run to execute it in your browser with Pyodide, or copy it into the Playground. Each program is followed by a deep dive, a line-by-line walkthrough, common mistakes and experiments so you can learn it, not just read it.
The Problem It Solves
The obvious first draft is an if / elif ladder on the type — and it works, right up until the first type nobody planned for.
def format_quantity(value):
if isinstance(value, int):
return str(value)
elif isinstance(value, float):
return f"{value:.2f}"
elif isinstance(value, str):
return value
# ... Fraction? tuple? Money? add another elif ...Smell: the same ladder is also needed for scaling a recipe, converting to metric, rounding, exporting. Every new type means editing every ladder — and missing one is a silent bug.
Goal: write format_quantity(value) once as a plain call, then add a new type by writing one small function somewhere else — touching no existing line.
Why Not Just Add a .format() Method?
In the previous guide, polymorphism worked by overriding methods in classes you own. Here that door is closed:
| Type | Who owns it? | Can you add .format()? |
|---|---|---|
int, float, str, tuple | The Python core (C code) | No — built-in types can't be modified. |
Fraction | The standard library | Technically hackable, but editing someone else's class is a bad idea. |
Money | You | Yes — but now formatting is spread across two places. |
The insight: when you can't put behavior inside the types, you put it beside them — in a function that picks the right implementation from the type of its argument. That is exactly what @singledispatch does.
Key Ideas Before the Code
Skim this glossary now and return whenever a word in the walkthroughs feels unfamiliar.
Glossary
| Term | Plain-English meaning | Where you'll see it |
|---|---|---|
| Single dispatch | Choosing which function runs based on the type of the first argument only. | @singledispatch |
| Generic function | One public name that is really a family of per-type implementations. | format_quantity |
| Registry | The lookup table mapping each type to its implementation. | format_quantity.registry |
| Fallback | The undecorated base function; it handles any type with no better match. | "?? ..." output |
register | The decorator that adds one type-specific implementation to the registry. | @format_quantity.register |
| MRO dispatch | If the exact type isn't registered, Python walks the type's parents (its MRO) to find the nearest registered one. | True using the int branch |
| ABC registration | Registering an abstract base class (like numbers.Real) to cover many concrete types at once. | Program 4 |
Fraction | An exact rational number from fractions: Fraction(3, 2) is exactly 3/2, with no rounding error. | Scaling recipes |
limit_denominator | Finds the closest fraction whose denominator is at most N. Turns 0.333 into 1/3. | Float branch |
| Mixed number | A whole part plus a fraction: 1 1/2. How cooks write quantities. | to_mixed() |
| Recursion | A branch that calls the generic function again on the pieces it contains. | Tuple ranges |
| Open/Closed Principle | Open to extension (new types) but closed to modification (no edits to working code). | Exercise 4 |
singledispatchmethod | The same idea for methods inside a class; it dispatches on the first argument after self. | Exercise 5 |
A 30-Second Experiment: Watch the Registry Work
One generic function, one registered branch (int), one fallback. Predict each result before pressing Run — especially the second one.
from functools import singledispatch
@singledispatch
def show(x):
return "object branch"
@show.register
def _(x: int):
return "int branch"
print(show(1), "|", show(True), "|", show("hi"))
print(show.dispatch(bool) is show.dispatch(int))
print(show.dispatch(str) is show.dispatch(object))
print(sorted(t.__name__ for t in show.registry))- The undecorated function is the fallback.
@singledispatchstores it in the registry underobject— the ancestor of every type — so something always matches. show(True)used theintbranch. Nothing is registered forbool, so Python walked up bool's parents:bool → int → object, and stopped at the first registered one. Remember this — it comes back in Programs 4 and Exercise 3..dispatch(type)lets you ask "who would answer?" without calling anything. Both identity checks printTrue:boolresolves to the very same function object asint, andstrto the same asobject..registrylists what is registered — only two entries,intandobject.boolandstrare not in it; they resolve through their parents.
Error Message Decoder
You will meet these while experimenting. Each points at a specific dispatch mistake. The first two are exact messages from Python.
| Message | What it means | Fix |
|---|---|---|
TypeError: show requires at least 1 positional argument | You called the generic function with a keyword only, such as show(x=1). Dispatch reads the first positional argument. | Pass the dispatched value positionally. |
TypeError: Invalid first argument to `register()`: … Use either `@register(some_class)` or plain `@register` on an annotated function. | You used bare @register but the function has no type annotation, so Python can't tell which type to register. | Annotate the first parameter, or write @register(int). |
RuntimeError: Ambiguous dispatch: … | The object matches two registered ABCs equally well (for example Sized and Iterable) and Python refuses to guess. | Register the concrete class, or a more specific ABC. |
Output shows ?? Decimal('1.5') | The fallback ran: no registered type matches. Not an error — the program's own "unknown" marker. | Register the missing type. |
AttributeError: 'str' object has no attribute 'numerator' | A type-specific function was called with the wrong type, usually because a second def with the same name replaced the first instead of registering. | Use @name.register, never re-def. |
ZeroDivisionError: Fraction(4, 0) | You built a Fraction with a zero denominator — in this project, a recipe whose base serving count is 0. | Validate servings > 0. |
How to study the programs: (1) read the code once without running it; (2) predict the output; (3) press Run and compare; (4) read the deep dive; (5) do the "Try it yourself" experiments in the Playground; (6) press Mark as learned. Your progress is saved.
Why Single Dispatch Here?
The Polymorphism guide showed four flavors. Each could in theory format a quantity — here is why only one fits this job well.
The Four Flavors, Applied to Quantities
| Flavor | Could it format quantities? | Verdict |
|---|---|---|
| 🧬 Method overriding | Needs every quantity to be an instance of your base class — you'd wrap every int and str first. | Heavy; wrapping everywhere. |
| 🦆 Duck typing | Needs every value to have a .format() method. int, str and tuple don't, and can't gain one. | Impossible for built-ins. |
| ➕ Operator overloading | Great for arithmetic (Money * 3) but not for choosing a text format by type. | Used here for Money, not for dispatch. |
| 🎯 Single dispatch | Works on any type, owned or not, without wrapping. New types register from outside. | The right fit. |
Rule of thumb: when you can't add methods to the types but need per-type behavior from one call site, reach for @singledispatch. If you do own the classes, a method is simpler.
What singledispatch Can and Cannot Do
| Question | Answer |
|---|---|
| Dispatches on which argument? | Only the first positional one. For two-argument dispatch you need something else (see Exercise 2's design note). |
| Looks at the exact type only? | No — it follows inheritance (MRO) and registered ABCs. |
| Can new types be added later? | Yes, from any module, at any time. That is the whole point. |
| Does it check the return type? | No. Keeping branches consistent is your job. |
| Works on methods? | Not directly — use functools.singledispatchmethod (Exercise 5). |
Does Python's def f twice overload? | No — the second silently replaces the first. Dispatch must go through .register. |
The build plan: Program 1 replaces the ladder. Program 2 teaches exact fractions. Program 3 adds Money, str and tuple (with recursion). Program 4 explains the dispatch rules. Program 5 assembles a full recipe scaler and formatter.
Five Programs, Explained Line by Line
Program 1 — From isinstance Ladder to @singledispatch
We start with the ladder, watch it fail on a type it never heard of, then rebuild the same feature as a generic function. This is the smallest complete example of single dispatch: a fallback plus three registered branches.
from fractions import Fraction
from functools import singledispatch
def format_ladder(value):
if isinstance(value, int):
return str(value)
elif isinstance(value, float):
return f"{value:.2f}".rstrip("0").rstrip(".")
elif isinstance(value, str):
return value
raise TypeError(f"No format rule for {type(value).__name__}")
try:
format_ladder(Fraction(3, 2))
except TypeError as err:
print("Ladder failed:", err)
print("-" * 40)
@singledispatch
def format_quantity(value):
return f"?? {value!r}"
@format_quantity.register
def _(value: int):
return str(value)
@format_quantity.register
def _(value: float):
return f"{value:.2f}".rstrip("0").rstrip(".")
@format_quantity.register
def _(value: str):
return value
for item in [2, 0.5, 1.25, 3.0, "a pinch", Fraction(3, 2)]:
print(f"{type(item).__name__:<9} → {format_quantity(item)}")🔍 Deep dive — how one call finds its function
Follow format_quantity(0.5). The decorator replaced the name format_quantity with a small dispatcher object. Every call goes through it:
| # | What the dispatcher does | Result for 0.5 | Result for Fraction(3, 2) |
|---|---|---|---|
| 1 | Reads type(value) | float | Fraction |
| 2 | Looks the type up in its registry | Found: the float function. | Not found. |
| 3 | If not found, walks the type's parents until one is registered | (not needed) | Reaches object, the fallback. |
| 4 | Calls the chosen function with the original arguments | "0.5" | "?? Fraction(3, 2)" |
| Ladder | Single dispatch | |
|---|---|---|
| Unknown type | Raises TypeError (or silently wrong) | Reaches a fallback you control |
| Adding a type | Edit the ladder | Add one registered function anywhere |
| Each branch | Lives inside one big function | Is its own small, testable function |
| Second operation (e.g. scaling) | Another ladder | Another generic function (Program 5) |
Why a fallback that doesn't crash? Here the fallback returns a visible ?? marker so a missing type is obvious in output. In stricter code, raise TypeError inside the fallback — you still choose the failure, in exactly one place.
📖 Line-by-line walkthrough
from fractions import Fraction— only used here to create a type the ladder has never seen. It returns in Program 2 as a first-class citizen.from functools import singledispatch— the decorator that turns an ordinary function into a generic function.format_ladder, theintbranch —isinstance(value, int)asks "are you an integer?" and returns the digits as text.- The
floatbranch and therstriptrick —f"{value:.2f}"gives two decimals ("0.50","3.00");rstrip("0")removes trailing zeros ("0.5","3.");rstrip(".")removes a leftover dot ("3"). It is safe only because.2falways contains a dot, which stops the firstrstrip. - The
strbranch and the finalraise— strings like"a pinch"pass through untouched; anything else raisesTypeErrornaming the offending type. - The first
try / except—Fraction(3, 2)matches none of the three branches, so the ladder raises and we print the message. This is the failure we are about to design away. print("-" * 40)— a divider. Here*repeats a string (itself a small piece of polymorphism).@singledispatch def format_quantity(value)— the undecorated body is the fallback, registered forobject.{value!r}usesrepr, soFraction(3, 2)shows its type clearly.@format_quantity.registerwithdef _(value: int)— the bare decorator reads the annotation of the first parameter to learn which type this function is for. The function is named_because its name is never used again; the registry holds it.- The
floatandstrregistrations — the same pattern; thefloatbody is exactly the ladder'sfloatbranch, now in its own function. - The final loop —
f"{type(item).__name__:<9}"left-aligns the type name in a 9-character column. Notice that3.0prints as3and2as2. - The last item,
Fraction(3, 2)— no branch exists, so the fallback answers with?? Fraction(3, 2). No crash, no edits to existing code — and Program 2 fixes it by registering one new function.
⚠️ Common mistakes
Forgetting the annotation. @format_quantity.register on def _(value): raises TypeError: Invalid first argument to `register()`. Either annotate the first parameter or write @format_quantity.register(int).
Applying rstrip("0") to text without a dot. "100".rstrip("0") becomes "1". The trick above works only because .2f guarantees a decimal point. Never reuse it on integers.
Writing def format_quantity twice instead of registering. The second def silently replaces the first. Only .register adds a branch.
🧪 Try it yourself
Change one thing at a time:
- Add
print(sorted(t.__name__ for t in format_quantity.registry))after the registrations. Which four names appear? - Add
Trueto the list in the loop. Which branch answers, and what does it print? - Change the fallback to
raise TypeError(...). What now happens at theFractionline?
Program 2 — Exact Fractions and Kitchen Rounding
Cooks write 1 1/2, not 1.5 or 3/2. This program registers Fraction, turns any fraction into a mixed number, and teaches the float branch to snap messy decimals like 0.333 to the nearest kitchen fraction.
from fractions import Fraction
from functools import singledispatch
def to_mixed(frac):
whole, rem = divmod(frac.numerator, frac.denominator)
if rem == 0:
return str(whole)
if whole == 0:
return f"{rem}/{frac.denominator}"
return f"{whole} {rem}/{frac.denominator}"
@singledispatch
def format_quantity(value):
return f"?? {value!r}"
@format_quantity.register
def _(value: int):
return str(value)
@format_quantity.register
def _(value: Fraction):
return to_mixed(value)
@format_quantity.register
def _(value: float):
return to_mixed(Fraction(value).limit_denominator(8))
for item in [2, Fraction(3, 2), Fraction(1, 3), Fraction(4, 2), Fraction(7, 4), 0.75, 2.5, 0.333, 0.1, 1.9]:
print(f"{item!r:<14} → {format_quantity(item)}")
print(Fraction(0.1))
print(Fraction("0.1"))
print(Fraction(0.1).limit_denominator(8))🔍 Deep dive — mixed numbers and why floats need rounding
divmod(a, b) returns the quotient and remainder together. For Fraction(7, 4), that is divmod(7, 4) = (1, 3): one whole and three quarters left over.
| Input | divmod(num, den) | Case taken | Output |
|---|---|---|---|
Fraction(4, 2) | Python simplifies to 2/1; divmod(2, 1) = (2, 0) | rem == 0 | 2 |
Fraction(1, 3) | (0, 1) | whole == 0 | 1/3 |
Fraction(3, 2) | (1, 1) | whole and fraction | 1 1/2 |
Fraction(7, 4) | (1, 3) | whole and fraction | 1 3/4 |
The float trap. Computers store 0.1 in binary, where it is not exactly one tenth. Fraction(0.1) faithfully captures that error and gives 3602879701896397/36028797018963968 — see the first of the three final prints. Fraction("0.1") (from text) is exactly 1/10. For floats you do not control, use limit_denominator.
What limit_denominator(8) means. "Give me the closest fraction whose bottom number is 8 or less." So 0.333 becomes 1/3, 2.5 becomes 5/2 (shown as 2 1/2), and 0.1 becomes 1/8 — because with denominators capped at 8, 1/8 = 0.125 is the nearest choice. Eighths are what a measuring spoon set can actually do.
📖 Line-by-line walkthrough
to_mixed(frac)— a plain helper (not dispatched). Both theFractionandfloatbranches reuse it, so mixed-number logic exists exactly once.whole, rem = divmod(frac.numerator, frac.denominator)— splits the top and bottom into whole units and a leftover numerator. Tuple unpacking assigns both at once.if rem == 0: return str(whole)— an exact whole number such as2/1; print only the whole part.if whole == 0— a proper fraction such as1/3; print only the fraction.- The last
return— both parts exist:"1 1/2", with a space between the whole and fraction as in a cookbook. - The fallback and
intbranch — unchanged from Program 1. Programs build on each other; the generic function only grows. registerforFraction— one line: delegate toto_mixed. This is the whole cost of supporting a new type.registerforfloat— converts the float to aFraction, snaps it withlimit_denominator(8), then reusesto_mixed. Note the float branch now delegates instead of formatting decimals itself.- The loop with
{item!r:<14}—!rprints therepr(so you seeFraction(3, 2)and not just3/2), and:<14pads it so the arrows line up. print(Fraction(0.1))— the huge numerator and denominator: the exact value of the float.print(Fraction("0.1"))— from a string, Python reads decimal digits exactly:1/10.Fraction(0.1).limit_denominator(8)— shrinks the huge fraction to the nearest allowed one:1/8.
⚠️ Common mistakes
Building a Fraction directly from a messy float. Fraction(0.1) is technically exact but useless to a cook. Always cap the denominator or parse from text.
Feeding negative quantities to to_mixed. divmod rounds toward negative infinity, so -3/2 would split as -2 whole plus 1/2 — a wrong answer. Recipe amounts are never negative; validate before formatting.
Choosing the denominator limit carelessly. A limit of 8 hides tenths; a limit of 100 shows ugly fractions like 33/100. Pick the limit to match your measuring tools.
🧪 Try it yourself
Experiments:
- Change
limit_denominator(8)to16. Predict, then check, what happens to0.1and1.9. - Add
Fraction(0, 5)to the list. Which branch ofto_mixedcatches it? - Add
1/3(the float, not the Fraction) to the list. What do you get and why?
Program 3 — Strings, Money and Ranges (with Recursion)
Now the generic function covers all six branches: int, Fraction, float, str, a custom Money class, and tuple for ranges like 2–3. The tuple branch calls format_quantity again on each end of the range — dispatch inside dispatch.
from fractions import Fraction
from functools import singledispatch
class Money:
def __init__(self, amount):
self.amount = round(amount, 2)
def __str__(self):
return f"PHP {self.amount:,.2f}"
def __repr__(self):
return f"Money({self.amount})"
def to_mixed(frac):
whole, rem = divmod(frac.numerator, frac.denominator)
if rem == 0:
return str(whole)
if whole == 0:
return f"{rem}/{frac.denominator}"
return f"{whole} {rem}/{frac.denominator}"
@singledispatch
def format_quantity(value):
return f"?? {value!r}"
@format_quantity.register
def _(value: int):
return str(value)
@format_quantity.register
def _(value: Fraction):
return to_mixed(value)
@format_quantity.register
def _(value: float):
return to_mixed(Fraction(value).limit_denominator(8))
@format_quantity.register
def _(value: str):
return value
@format_quantity.register
def _(value: Money):
return str(value)
@format_quantity.register
def _(value: tuple):
low, high = value
return f"{format_quantity(low)}–{format_quantity(high)}"
rows = [
("flour", 2, "cups"),
("milk", Fraction(3, 2), "cups"),
("vanilla", 0.5, "tsp"),
("salt", "a pinch", ""),
("butter", (2, 3), "tbsp"),
("saffron", Money(250), "per gram"),
("nutmeg", (Fraction(1, 4), "a pinch"), "tsp"),
]
for name, qty, unit in rows:
print(f"{name:<8} {format_quantity(qty):>12} {unit}")
print(sorted(t.__name__ for t in format_quantity.registry))
print(format_quantity(Money(1234.5)))🔍 Deep dive — six branches, one call site
| Registered type | Example value | What its branch does | Output |
|---|---|---|---|
int | 2 | str(value) | 2 |
Fraction | Fraction(3, 2) | mixed number | 1 1/2 |
float | 0.5 | snap to eighths, then mixed number | 1/2 |
str | "a pinch" | return unchanged | a pinch |
Money | Money(250) | use the class's own __str__ | PHP 250.00 |
tuple | (2, 3) | format both ends, join with an en dash | 2–3 |
Recursion makes ranges free. The tuple branch never mentions int, Fraction or str. For (Fraction(1, 4), "a pinch") it simply calls format_quantity on each element and the dispatcher picks the right branch for each — giving 1/4–a pinch (the nutmeg row). Any type you register later works inside ranges automatically.
Why Money delegates to __str__. The class already knows how to print itself. The dispatch branch is a thin bridge, not a duplicate of that logic. Both mechanisms coexist: methods for types you own, dispatch as the unifying layer across everything.
Reading the registry. The last two prints show what is registered (note capital letters sort before lowercase in Python's string order, so Fraction and Money come first) and that Money output uses thousands separators: PHP 1,234.50.
📖 Line-by-line walkthrough
class Moneyandround(amount, 2)— a tiny value class that stores two decimal places. (Production money code usesdecimal.Decimal; floats keep this lesson short.)__str__with{self.amount:,.2f}— the,adds thousands separators and.2fforces two decimals, so250prints asPHP 250.00.__repr__— the developer view, used by the fallback's!rwhen a type is unregistered. Always define it.to_mixedand the first four registrations — carried over from Programs 1 and 2 exactly as they were.registerforstr— returns the string untouched, so"a pinch"works as a quantity.registerforMoney— the annotationvalue: Moneyworks with your own classes just as it does with built-ins.registerfortupleandlow, high = value— unpacks a two-element range. A one- or three-element tuple would raiseValueErrorhere; Program 5 shows a more forgiving version.f"{format_quantity(low)}–{format_quantity(high)}"— the recursive step. The inner calls go back through the dispatcher.- The
rowslist — each row is(name, quantity, unit). The quantities are seven different shapes in one list, sharing oneforloop. {format_quantity(qty):>12}— right-aligns the formatted quantity in a 12-character column so the numbers line up like a menu.sorted(t.__name__ for t in format_quantity.registry)— reads the dispatcher's own registry: seven keys includingobject, the fallback.format_quantity(Money(1234.5))— a final direct call showing the thousands separator.
⚠️ Common mistakes
Calling str(value) inside the tuple branch instead of format_quantity. You would lose every branch: the range (Fraction(1, 4), 1) would show raw Fraction(1, 4)-style text instead of 1/4–1. Recurse through the dispatcher.
Defining the class after the function that names it. The annotation value: Money is evaluated when the function is defined, so Money must already exist above it.
Assuming a tuple is always a pair. low, high = value fails for (1, 2, 3) with ValueError: too many values to unpack. Decide what your data guarantees, or loosen the branch.
🧪 Try it yourself
Experiments:
- Add a row with the nested range
((1, 2), 4). Predict the output, then run it. - Pass a
setsuch as{1, 2}toformat_quantity. Which branch answers? - Delete the
Moneyregistration. What does thesaffronrow print now, and why does the registry shrink by one?
Program 4 — The Dispatch Rules: bool, ABCs, Unions and Ambiguity
Single dispatch has rules, and knowing them prevents the most confusing bugs. This program uses a tiny kind() function that just reports which branch ran, registers types in five stages, and prints the same seven samples after each stage so you can watch the answers change.
from collections.abc import Iterable, Sized
from decimal import Decimal
from fractions import Fraction
from functools import singledispatch
from numbers import Real
@singledispatch
def kind(value):
return "fallback"
@kind.register
def _(value: int):
return "int"
samples = [1, True, 2.5, Fraction(1, 2), Decimal("1.5"), "x", None]
def show(label):
print(f"{label:<22}", [kind(s) for s in samples])
show("1. only int:")
@kind.register
def _(value: bool):
return "bool"
show("2. + bool:")
@kind.register(Real)
def _(value):
return "Real"
show("3. + Real (ABC):")
@kind.register
def _(value: str | bytes):
return "text"
@kind.register(type(None))
def _(value):
return "none"
show("4. + str|bytes, None:")
@kind.register(Decimal)
def _(value):
return "Decimal"
show("5. + Decimal:")
print([t.__name__ for t in kind.registry])
print(kind.dispatch(Fraction) is kind.dispatch(float))
class Both:
def __len__(self):
return 0
def __iter__(self):
return iter(())
@singledispatch
def probe(x):
return "fallback"
@probe.register(Sized)
def _(x):
return "sized"
@probe.register(Iterable)
def _(x):
return "iterable"
try:
probe(Both())
except RuntimeError as err:
print("RuntimeError raised:", str(err).startswith("Ambiguous dispatch"))🔍 Deep dive — reading the table column by column
Each column below is one sample; each row is one stage. Read down a column to see how its answer evolves.
| Sample | Stage 1 | Final | Why |
|---|---|---|---|
1 | int | int | An exact registration always beats an ABC, so adding Real doesn't steal it, even though int is a Real. |
True | int | bool | bool is a subclass of int. With no bool branch it takes the int branch; stage 2 adds the exact match. |
2.5 | fallback | Real | float is registered as a virtual subclass of numbers.Real, so registering Real covers it. |
Fraction(1, 2) | fallback | Real | Fraction inherits from numbers.Rational, itself a kind of Real. One ABC registration, two unrelated types. |
Decimal("1.5") | fallback | Decimal | Surprise: Decimal is not a Real — Python deliberately keeps it out of the numeric tower. It stays in the fallback until stage 5 registers it by name. |
"x" | fallback | text | The annotation str | bytes registers both types with one function. |
None | fallback | none | None has the type NoneType, obtained with type(None). It can't be written as an annotation word, so register(type(None)) is used. |
The lookup order, in one sentence: exact type first, then the type's parents and any registered ABCs (the "composed MRO"), nearest first, and finally object. If two ABCs tie, Python raises RuntimeError: Ambiguous dispatch rather than guess — which is exactly what the last six lines demonstrate.
Why Both is ambiguous. It has __len__ (so it counts as Sized) and __iter__ (so it counts as Iterable). Neither ABC is more specific than the other. Python refuses to pick. Fix it by registering Both itself.
📖 Line-by-line walkthrough
- The imports —
Realcomes fromnumbers(the numeric ABCs);SizedandIterablefromcollections.abc. - The
kindfunction returns its own label — a debugging trick: instead of formatting something, each branch just names itself so you can see the routing. samplesandshow(label)— a fixed list of seven values and a helper that prints the branch name for each.:<22aligns the stage labels.- Stage 1 — only
intregistered.Truestill printsint(parent lookup); everything else non-int falls back. - Stage 2:
registerforbool— the exact match now exists, soTruebecomesbool. Nothing else changes. - Stage 3:
@kind.register(Real)— the explicit form, because the function has no annotation. Floats and Fractions now share one branch. - Stage 4:
str | bytesandtype(None)— a union annotation registers several types at once;Noneuses the explicit form. - Stage 5:
register(Decimal)—Decimalhad to be added by name because it is not in the numeric tower. [t.__name__ for t in kind.registry]— the registry in registration order.strandbytesappear separately although one function was decorated.kind.dispatch(Fraction) is kind.dispatch(float)— both resolve to the singleRealfunction, so the identity check isTrue.class Both— implements just enough of two protocols to satisfy two ABCs at once.probewithSizedandIterable— two ABC registrations that overlap onBoth. The call raisesRuntimeError; we confirm its message starts withAmbiguous dispatch.
⚠️ Common mistakes
Forgetting that bool is an int. format_quantity(True) printing 1 or True as a quantity is almost always a bug upstream. Register bool explicitly to reject or special-case it (Exercise 3).
Assuming Decimal counts as a number ABC. @register(Real) won't catch it. Register Decimal (or numbers.Number) separately.
Registering overlapping ABCs. Sized + Iterable (or Sequence + Mapping-like protocols) can tie on one object. Prefer concrete classes when a tie is possible.
Expecting dispatch on a keyword argument. kind(value=1) raises TypeError: kind requires at least 1 positional argument. Always pass the dispatched value positionally.
🧪 Try it yourself
Experiments:
- Register
numbers.Rational(returning"Rational") after stage 3. Which samples change — and does2.5? - Add
show("6. + float:")after registeringfloatby name. Which sample moves fromRealto the new branch? - Register
Bothitself onprobeand callprobe(Both())again. What happens to the error?
Program 5 — The Full Recipe Scaler and Formatter
Everything comes together. Two generic functions share the same set of types: scale() multiplies a quantity by a factor, format_quantity() prints it. A Recipe class scales an entire ingredient list from 4 servings to 6 or to 2, totals the cost with Money arithmetic, and renders it — without a single isinstance check.
from dataclasses import dataclass
from fractions import Fraction
from functools import singledispatch
class Money:
def __init__(self, amount):
self.amount = round(amount, 2)
def __add__(self, other):
if not isinstance(other, Money):
return NotImplemented
return Money(self.amount + other.amount)
def __mul__(self, factor):
if not isinstance(factor, (int, float, Fraction)):
return NotImplemented
return Money(self.amount * float(factor))
__rmul__ = __mul__
def __str__(self):
return f"PHP {self.amount:,.2f}"
def __repr__(self):
return f"Money({self.amount})"
def to_mixed(frac):
whole, rem = divmod(frac.numerator, frac.denominator)
if rem == 0:
return str(whole)
if whole == 0:
return f"{rem}/{frac.denominator}"
return f"{whole} {rem}/{frac.denominator}"
@singledispatch
def format_quantity(value):
return f"?? {value!r}"
@format_quantity.register
def _(value: int):
return str(value)
@format_quantity.register
def _(value: Fraction):
return to_mixed(value)
@format_quantity.register
def _(value: float):
return to_mixed(Fraction(value).limit_denominator(8))
@format_quantity.register
def _(value: str):
return value
@format_quantity.register
def _(value: Money):
return str(value)
@format_quantity.register
def _(value: tuple):
return "–".join(format_quantity(v) for v in value)
@singledispatch
def scale(value, factor):
raise TypeError(f"Cannot scale {type(value).__name__}")
@scale.register
def _(value: int, factor):
return Fraction(value) * factor
@scale.register
def _(value: Fraction, factor):
return value * factor
@scale.register
def _(value: float, factor):
return Fraction(value).limit_denominator(16) * factor
@scale.register
def _(value: str, factor):
return value
@scale.register
def _(value: Money, factor):
return value * factor
@scale.register
def _(value: tuple, factor):
return tuple(scale(v, factor) for v in value)
@dataclass
class Ingredient:
name: str
qty: object
unit: str = ""
cost: Money | None = None
class Recipe:
def __init__(self, title, servings, ingredients):
self.title = title
self.servings = servings
self.ingredients = ingredients
def scaled(self, servings):
factor = Fraction(servings, self.servings)
items = [
Ingredient(i.name, scale(i.qty, factor), i.unit,
scale(i.cost, factor) if i.cost else None)
for i in self.ingredients
]
return Recipe(self.title, servings, items)
def total_cost(self):
return sum((i.cost for i in self.ingredients if i.cost), Money(0))
def render(self):
lines = [f"{self.title} (serves {self.servings})"]
for i in self.ingredients:
lines.append(f" {format_quantity(i.qty):>8} {i.unit:<5} {i.name}")
lines.append(f" Estimated cost: {format_quantity(self.total_cost())}")
return "\n".join(lines)
pancakes = Recipe("Pancakes", 4, [
Ingredient("flour", 2, "cups", Money(24)),
Ingredient("milk", Fraction(3, 2), "cups", Money(18)),
Ingredient("sugar", 2, "tbsp", Money(4)),
Ingredient("eggs", 1, "", Money(10)),
Ingredient("salt", "a pinch"),
Ingredient("butter", (2, 3), "tbsp", Money(15)),
Ingredient("vanilla", 0.5, "tsp", Money(8)),
])
print(pancakes.render())
print()
print(pancakes.scaled(6).render())
print()
print(pancakes.scaled(2).render())🔍 Deep dive — following each ingredient from 4 to 6 servings
Scaling from 4 to 6 servings uses the exact factor Fraction(6, 4) = 3/2. Because scale returns Fraction objects (never floats), no rounding error can accumulate. Only at the very end does format_quantity turn the exact fractions into cookbook text.
| Ingredient | Stored as | scale branch | Exact result | Printed |
|---|---|---|---|---|
| flour | 2 (int) | int | Fraction(2) × 3/2 = 3 | 3 |
| milk | Fraction(3, 2) | Fraction | 3/2 × 3/2 = 9/4 | 2 1/4 |
| eggs | 1 (int) | int | 3/2 | 1 1/2 |
| salt | "a pinch" | str | unchanged — you can't double a pinch | a pinch |
| butter | (2, 3) | tuple → recurses | (3, 9/2) | 3–4 1/2 |
| vanilla | 0.5 (float) | float | 1/2 × 3/2 = 3/4 | 3/4 |
| costs | Money(...) | Money (operator *) | 79.00 × 1.5 | PHP 118.50 |
The payoff. Support for a seventh type, Decimal say, now costs exactly two small registered functions (one for scale, one for format_quantity) and zero edits to Recipe, Ingredient or any existing branch. With ladders you'd edit two ladders — and any others you'd forgotten existed.
Three polymorphisms, cooperating. Money uses operator overloading (*, +). scale and format_quantity use single dispatch. The Recipe loop is simply duck typed — it only needs each ingredient to have .qty, .unit and .cost. Real programs mix flavors freely.
📖 Line-by-line walkthrough
Money.__add__— adds twoMoneyobjects and returns a new one. For any other type it returnsNotImplemented(the polite "not my job" from the Polymorphism guide).Money.__mul__and__rmul__ = __mul__— multiplies by anint,floatorFraction. Converting withfloat(factor)keeps the stored amount an ordinary float and makes the intent explicit.__rmul__makes2 * pricework too.to_mixedand theformat_quantitybranches — as in Program 3, except the tuple branch is now"–".join(...), which works for ranges of any length.@singledispatch def scale(value, factor)— a second, independent generic function. It dispatches onvalueonly;factoris a plain extra argument. The fallback raises because an unknown type can't be scaled sensibly.scaleforint—Fraction(value) * factorpromotes the int to an exact fraction first, so2 × 3/2is exactly3.scaleforfloat— snaps withlimit_denominator(16)before scaling. Floats are the only branch that loses a little precision, and it is a deliberate, documented choice (sixteenths).scaleforstr— returns the string as-is. "A pinch" stays "a pinch" at any serving size.scaleforMoney— justvalue * factor, leaning on the operator overloading defined earlier.scalefortuple— rebuilds the tuple by callingscaleon every element: the same recursive trick as informat_quantity.@dataclass class Ingredient— generates__init__and__repr__for free.qty: objectadmits any quantity type;cost: Money | None = Nonemeans "optional price".Recipe.scaled(servings)— buildsfactor = Fraction(servings, self.servings), then creates a newRecipewhose quantities and costs are scaled. The original is untouched, so you can scale one recipe to many sizes.Recipe.total_cost()—sum(..., Money(0))starts from aMoney(0)so the additions areMoney + Money. Theif i.costfilter skipssalt, which has no price.Recipe.render()— builds one text line per ingredient with fixed column widths (:>8right-aligns the quantity,:<5left-aligns the unit), then appends the cost throughformat_quantityso it uses theMoneybranch.- The three prints — the original recipe, then ×1.5 and ×0.5. The blank
print()calls add spacing between the blocks.
⚠️ Common mistakes
Calling sum() on Money without a start value. sum(costs) begins with the integer 0, and 0 + Money(...) fails with a TypeError because Money has no __radd__. Pass Money(0) as the start.
Scaling with floats. 0.5 * 1.5 looks fine, but chains of float arithmetic drift. Use exact Fraction factors throughout and format once at the end, as this program does.
Not validating servings. scaled(0) doesn't crash — it quietly produces a recipe full of zeros. A base recipe with servings = 0 does crash, with ZeroDivisionError: Fraction(4, 0). Check servings > 0 at construction time.
Forgetting to scale the cost. If scaled() copied cost unchanged, the 6-serving recipe would still claim PHP 79.00. Every field that depends on quantity must be scaled.
🧪 Try it yourself
Experiments:
- Scale to 3 servings (
factor = 3/4). Predict the flour and milk lines before running. - Add an ingredient with a
Decimalquantity and runscaled(6). Which message appears, and from which generic function? - Invent a custom
Pinchclass and register it in both generic functions. How many existing lines did you edit? - Change the float branch of
scaletolimit_denominator(4). Which ingredient's result changes?
Hands-On Exercises
Reading code teaches recognition; writing it teaches understanding. Try each exercise first in the Playground, then open the solution. Every solution runs right here and its output was verified.
Exercise 1 — Add a Decimal Branch
Warm-upTask: a data feed sends quantities as decimal.Decimal values such as Decimal("1.50"). Register a Decimal branch that prints them without trailing zeros: 1.5, 2, 100, 0.25. Do not touch the existing int branch.
Hint: Decimal.normalize() strips trailing zeros but may switch to scientific notation (Decimal("100").normalize() becomes 1E+2). The format code "f" forces plain notation.
Show solution & explanation
from decimal import Decimal
from functools import singledispatch
@singledispatch
def format_quantity(value):
return f"?? {value!r}"
@format_quantity.register
def _(value: int):
return str(value)
@format_quantity.register
def _(value: Decimal):
return format(value.normalize(), "f")
for item in [Decimal("1.50"), Decimal("2.00"), Decimal("100"), Decimal("0.250"), 3]:
print(f"{type(item).__name__:<8} {item!s:>6} → {format_quantity(item)}")normalize() reduces 1.50 to 1.5 and 2.00 to 2. For 100 it would produce the exponent form 1E+2, so format(..., "f") converts it back to 100. The point of the exercise: a whole new type arrived and the only change was adding a function. Compare that with editing a ladder.
Exercise 2 — Singular or Plural Units?
EasyTask: write plural(qty, unit) returning "cup" or "cups" to match the quantity. Rules: exactly 1 is singular; Fraction amounts up to 1 are singular (1/2 cup) and above 1 plural (1 1/2 cups); a range uses its upper end; a str quantity like "a pinch" stays singular.
Hint: the quantity must be the first parameter — dispatch only looks at the first argument. Let the float branch convert to a Fraction and call plural again.
Show solution & explanation
from fractions import Fraction
from functools import singledispatch
@singledispatch
def plural(qty, unit):
return unit
@plural.register
def _(qty: int, unit):
return unit if qty == 1 else unit + "s"
@plural.register
def _(qty: Fraction, unit):
return unit if qty <= 1 else unit + "s"
@plural.register
def _(qty: float, unit):
return plural(Fraction(qty).limit_denominator(8), unit)
@plural.register
def _(qty: tuple, unit):
return plural(qty[-1], unit)
for q in [1, 2, Fraction(1, 2), Fraction(3, 2), 0.75, 2.5, (2, 3), "a pinch"]:
print(f"{q!s:>10} {plural(q, 'cup')}")The fallback returns the singular unit, which covers "a pinch" and any unknown type. The float and tuple branches delegate back to plural, so each reuses the other branches' logic.
Design note — dispatch on two arguments? singledispatch reads only the first argument. If a function truly depends on the types of two arguments, choose one: put the more important value first (as here), look the pair up in a dictionary keyed by (type, type), or dispatch on the first argument and branch on the second inside each function. The standard library has no built-in multiple dispatch.
Exercise 3 — Reject the Boolean
EasyTask: because bool is a subclass of int, format_quantity(True) currently prints True as if it were a quantity. Make booleans raise ValueError("A bool is not a quantity") while real integers still work.
Hint: remember Program 4 stage 2 — an exact match on bool beats the parent lookup that finds int.
Show solution & explanation
from functools import singledispatch
@singledispatch
def format_quantity(value):
return str(value)
@format_quantity.register
def _(value: int):
return str(value)
@format_quantity.register
def _(value: bool):
raise ValueError("A bool is not a quantity")
for item in [3, True, False]:
try:
print(format_quantity(item))
except ValueError as err:
print("ValueError:", err)Registering bool costs three lines and no isinstance(value, bool) check inside the int branch. The dispatcher handles the distinction for you. Notice that the order of the two register calls does not matter: dispatch is decided by type specificity, not by registration order.
Exercise 4 — Extend Without Editing
MediumTask: a "plugin author" writes two new quantity classes, Splash(ml) and Handful(count=1). They must print as a splash (~15 ml), a handful and 3 handfuls. The plugin code must register itself after and outside the core format_quantity definition — no edits to the core.
Hint: the core is only the fallback plus the int branch. Everything the plugin adds is a new class plus one register function per class. This is the Open/Closed Principle in action.
Show solution & explanation
from functools import singledispatch
@singledispatch
def format_quantity(value):
return f"?? {value!r}"
@format_quantity.register
def _(value: int):
return str(value)
class Splash:
def __init__(self, ml):
self.ml = ml
class Handful:
def __init__(self, count=1):
self.count = count
@format_quantity.register
def _(value: Splash):
return f"a splash (~{value.ml} ml)"
@format_quantity.register
def _(value: Handful):
return "a handful" if value.count == 1 else f"{value.count} handfuls"
for item in [2, Splash(15), Handful(), Handful(3)]:
print(format_quantity(item))In a real project the code after def _(value: int) would live in a separate file that imports format_quantity and registers on it. The core file is never reopened. Each plugin ships its own class and its own formatting rule, which keeps the knowledge about a type next to the type.
Exercise 5 — Dispatch Inside a Class
MediumTask: write a class Cup with a method to_ml(qty) that converts a quantity to millilitres at 240 ml per cup. int gives an int, Fraction gives a float, a tuple converts each end, and anything else raises TypeError("Cannot convert ...").
Hint: plain @singledispatch on a method would dispatch on self — always the same class. Use functools.singledispatchmethod, which dispatches on the first argument after self.
Show solution & explanation
from fractions import Fraction
from functools import singledispatchmethod
class Cup:
ML = 240
@singledispatchmethod
def to_ml(self, qty):
raise TypeError(f"Cannot convert {type(qty).__name__}")
@to_ml.register
def _(self, qty: int):
return qty * self.ML
@to_ml.register
def _(self, qty: Fraction):
return float(qty * self.ML)
@to_ml.register
def _(self, qty: tuple):
return tuple(self.to_ml(q) for q in qty)
cup = Cup()
print(cup.to_ml(2))
print(cup.to_ml(Fraction(3, 4)))
print(cup.to_ml((1, Fraction(1, 2))))
try:
cup.to_ml("a pinch")
except TypeError as err:
print("TypeError:", err)singledispatchmethod works like singledispatch but skips self. Inside the tuple branch, self.to_ml(q) goes back through the dispatcher for each element, giving 240 for the int and 120.0 for the half-cup fraction. Subclasses of Cup can add new registrations without disturbing these.
Exercise 6 — Debug the Broken Formatter
MediumTask: the formatter below runs without errors but prints four wrong results. Expected: 1 1/2, 1/2, 2, 1 1/2–2, and a ValueError for True. Find and fix all four bugs.
from fractions import Fraction
from functools import singledispatch
@singledispatch
def format_quantity(value):
return str(value)
@format_quantity.register(int)
def _(value):
return str(value)
@format_quantity.register(Fraction)
def _(value):
whole, rem = divmod(value.numerator, value.denominator)
return f"{whole} {rem}/{value.denominator}"
@format_quantity.register(tuple)
def _(value):
return f"{value[0]}–{value[1]}"
print(format_quantity(Fraction(3, 2)))
print(format_quantity(Fraction(1, 2)))
print(format_quantity(Fraction(2, 1)))
print(format_quantity((Fraction(3, 2), 2)))
print(format_quantity(True))Hint: read each wrong line separately. Line 2 has a pointless 0; line 3 an empty fraction; line 4 skipped something Program 3 did; line 5 is the Program 4 trap. Write a helper like to_mixed and register bool.
Show solution & explanation
from fractions import Fraction
from functools import singledispatch
def to_mixed(f):
whole, rem = divmod(f.numerator, f.denominator)
if rem == 0:
return str(whole)
if whole == 0:
return f"{rem}/{f.denominator}"
return f"{whole} {rem}/{f.denominator}"
@singledispatch
def format_quantity(value):
return str(value)
@format_quantity.register(int)
def _(value):
return str(value)
@format_quantity.register(bool)
def _(value):
raise ValueError("A bool is not a quantity")
@format_quantity.register(Fraction)
def _(value):
return to_mixed(value)
@format_quantity.register(tuple)
def _(value):
return f"{format_quantity(value[0])}–{format_quantity(value[1])}"
print(format_quantity(Fraction(3, 2)))
print(format_quantity(Fraction(1, 2)))
print(format_quantity(Fraction(2, 1)))
print(format_quantity((Fraction(3, 2), 2)))
try:
print(format_quantity(True))
except ValueError as err:
print("ValueError:", err)Bug 1: Fraction(1, 2) printed 0 1/2 because the whole part was never skipped when zero. Bug 2: Fraction(2, 1) printed 2 0/1 because a zero remainder wasn't handled. Both are fixed by to_mixed's three cases. Bug 3: the tuple branch used an f-string on the raw elements instead of recursing, so Fraction(3, 2) showed as 3/2; calling format_quantity on each end sends them back through the dispatcher. Bug 4: True took the int branch; registering bool gives it its own rule. Also notice what is not a bug: nothing needed re-wiring, because each fix touched exactly one function.
Free-Form Python Playground
Write and run any Python here — no installation. Use it for the exercises, the "Try it yourself" experiments, or to teach the formatter a new type. The default example leaves float and str unregistered, so two lines print the ?? fallback — your first job is to fix them. The runtime loads once and stays cached.
Try these challenges: (1) register float and str so the last two lines print properly; (2) add a tuple branch that recurses; (3) print format_quantity.registry.keys(); (4) register bool and make it raise; (5) invent your own Pinch class and register it.
The Dispatch Lab
Add values and watch the dispatcher choose a branch for each. Every row shows the lookup walk — which types Python tried (✗) before it found a registered one (✓) — and the text that came out. Two buttons are deliberately unregistered: bool and Decimal. Add them first and watch where they land. Then press Register and see every existing row change at once.
What to notice: the render loop only ever calls format_quantity(value). It never inspects the type. bool lands on the int branch (True ✗ → int ✓) until you register it, Decimal lands on the amber fallback until you register it, and a tuple's trace shows the recursive calls for each end of the range. Each press of a "+" button cycles through a few sample values per type.
Where Single Dispatch Shows Up
The recipe formatter is a small stage for a technique that appears across real Python programs.
Custom Serialization
Turning arbitrary objects into JSON, YAML or database rows: one generic to_jsonable(obj) with a branch per type, ideal inside a JSON encoder's default hook.
Pretty-Printers
Rendering values for humans — logs, reports, tables, CLIs. A generic render() keeps formatting rules out of your data classes.
Visitor Pattern, Simplified
Walking an AST or document tree with one function per node type instead of an accept()/visit() pair on every class.
Unit & Currency Handling
Converting, scaling and rounding quantities that come in several numeric types — exactly Programs 2 and 5.
Plugin Registries
Third parties add support for their own types by registering functions. The core never changes. Exercise 4 is this in miniature.
Input Validation
Normalizing messy input — strings, numbers, lists — at the boundary of a program, with a clear fallback for the unexpected.
Best Practices for Single Dispatch
Habits That Keep It Safe
✓ DO: Choose the fallback deliberately. Raise a clear TypeError for types that must never reach the function, or return an obvious marker while developing. Never leave it accidentally permissive.
✓ DO: Keep every branch returning the same kind of thing. If format_quantity returns text for most types it must return text for all of them. The dispatcher won't check this for you.
✓ DO: Delegate shared logic to plain helpers like to_mixed, so branches stay one-liners and the logic is tested once.
✓ DO: Register bool when it matters. It silently rides the int branch otherwise.
✓ DO: Put the dispatched value first in the parameter list, and pass it positionally.
✓ DO: Recurse through the generic function for containers (tuples, lists) so nested values reach the right branch.
✗ DON'T: Define the same function name twice. It replaces; it never overloads.
✗ DON'T: Use it when you own the classes and a method would do. A .format() method on your own class is simpler and discoverable. Use dispatch for types you cannot change, or to unify many types behind one call.
✗ DON'T: Register overlapping ABCs (Sized and Iterable, say) unless you are sure no object satisfies both.
✗ DON'T: Hide registrations in surprising places. If a branch lives in a module nobody imports, the type silently falls back. Keep registrations near the type or the generic function and import them explicitly.
✗ DON'T: Dispatch on a type you could test by capability. If you only need "has a write() method", duck typing (see the Polymorphism guide) is lighter.
Single Dispatch Cheat Sheet
✍️ Ways to Register
| Form | Use when |
|---|---|
| @f.register + annotation | Function has a typed first parameter |
| @f.register(int) | No annotation, explicit type |
| f.register(int, func) | Registering an existing function or lambda |
| annotation str | bytes | One function for several types |
| register(type(None)) | Handling None |
| register(Real) | Cover many types via an ABC |
🔎 Lookup Rules
| Step | Detail |
|---|---|
| 1 | Read type of first positional argument |
| 2 | Exact type registered? Use it |
| 3 | Else nearest parent or ABC in the MRO |
| 4 | Else object: the fallback |
| Tie | RuntimeError: Ambiguous dispatch |
⚠️ Common Pitfalls
| Mistake | Fix |
|---|---|
| True uses the int branch | Register bool |
| Decimal not caught by Real | Register Decimal |
| Re-def instead of register | Use .register |
| Keyword argument call | Pass positionally |
| No annotation on register | Annotate or pass the type |
| Fraction(0.1) is huge | Fraction("0.1") or limit_denominator |
🧰 Handy Tools
| Tool | Purpose |
|---|---|
| f.dispatch(cls) | Which function would answer? |
| f.registry | See every registered type |
| singledispatchmethod | Dispatch inside a class |
| divmod(a, b) | Whole part and remainder |
| limit_denominator(n) | Nearest fraction, bottom up to n |
| Fraction(a, b) | Exact rational arithmetic |
Quick Knowledge Check
Five questions on the ideas in this project.
Great effort — review any missed questions above.
More Polymorphism in Action
This page is one of ten live projects built around the same idea: one call site, many object types, each answering in its own way. Each project exposes a specific flavor — method overriding, duck typing, operator overloading, or single dispatch. You are on the Recipe Formatter; open any other to compare.
A mini vector editor. Circle, Square, Triangle and FreeformPath all extend Shape and override draw(), area() and perimeter(). One canvas loop renders them all.
shape.draw(ctx) hit four different classes
Blur, Grayscale, Sharpen, Sepia — four unrelated classes with a single apply(pixel) method. The pipeline never imports them by name; it just calls .apply() on whatever the plugin registry hands it.
plugins/ — pipeline works unchanged
A live Money class with __add__, __mul__, __eq__, __lt__ and __repr__. The whole app is arithmetic on Money objects — no helper functions, no .add(), no .multiply().
price * qty + tax run entirely through dunder methods
A recipe holds ingredients of many shapes — int, float, Fraction, str ("a pinch"), Money. One format_quantity() function uses @singledispatch to render each correctly.
Songs, podcasts, audiobooks and video clips are all MediaItem subclasses. The player calls play(), pause() and duration() — each item decides what those mean.
A miniature testing playground. Real file, fake file, flaky file, slow file — all share the same read() / write() methods. The app under test never imports a real filesystem.
Every journal entry is a Debit or Credit object that supports __neg__, __add__, __eq__ and __hash__. Balancing a ledger is literally sum(entries) == 0.
+, not .total()
A logging system where emit(message) dispatches on the message type: str → plain text, dict → JSON, Exception → traceback, bytes → base64. One function, many formats.
emit
Every piece is a Piece subclass overriding legal_moves() and symbol(). The board loop asks each piece for its legal moves — the knight, bishop, queen each reply differently.
Paste any class and see live which typing.Protocol interfaces it satisfies — Iterable, Sized, Comparable, Hashable. Built with @runtime_checkable protocols.
isinstance(x, Protocol) succeed without inheritance
A good exercise: open the Log Router — the other single-dispatch project — and compare it with this one. Where is the generic function? Which types are registered? Add a new type: how many existing lines did you edit? If the answer is zero, the project was built correctly.
Explore the Full OOP Series
This project is a deep dive into one corner of Polymorphism. Return to the parent guide, or revisit any of the other pillars.
Bundle data with methods. Control access. Protect state. The guardian of object integrity.
Explore →Define the contract that dispatch relies on. Hide complexity, expose only what is necessary.
Explore →Build on existing classes. The family tree that dispatch walks when an exact type isn't registered.
Explore → Parent guideOne interface, many forms. All four flavors, five programs, six exercises and ten live projects.
Back to the guide →Wrapping Up
🏁 What You've Learned
You built a recipe formatter that prints six different kinds of quantity through one call, and a scaler that resizes the whole recipe through a second one. Along the way you saw how single dispatch moves per-type behavior out of if-ladders and into small, separate functions that anyone can add to later.
You also learned the rules that keep it predictable: exact type first, then parents and ABCs, then the fallback; bool rides the int branch; Decimal is not a Real; ties raise instead of guessing. Put the behavior beside the types you cannot change.
Key Takeaways
- Single dispatch = one function name, per-type implementations chosen by the first argument's type.
- The undecorated function is the fallback — you decide what an unknown type does.
- Registration is open: new types plug in from any module without editing existing code.
- Lookup follows inheritance and ABCs, which is why
Trueuses theintbranch until you registerbool. - Recurse through the generic function for containers so nested values dispatch correctly.
- Use exact
Fractionarithmetic for scaling and format once at the end — never chain float errors.
✨ One recipe, many shapes: two cups, a cup and a half, a pinch, a handful — and one call that knows how to write each one.
— Glenn Junsay Pansensoy, Poetic Bytes
Subscribe to Poetic Bytes
Level up your Python knowledge.
In-depth Python tutorials, AI insights, and software engineering guides — delivered straight to your inbox. No spam, ever.
🔒 Your email is never shared or sold.