Back to Home
Polymorphism Project · Single Dispatch

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.

📅 October 21, 2026 ⏱ 60 min read 🎓 Intermediate 🐍 Python 3.11+
Polymorphism Guide Start the Project Open Dispatch Lab Open Playground

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.

    5 programs with walkthroughs 6 exercises with solutions Interactive dispatch lab Free-form Python playground Progress tracker Every output verified by running 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:

    TypeWho owns it?Can you add .format()?
    int, float, str, tupleThe Python core (C code)No — built-in types can't be modified.
    FractionThe standard libraryTechnically hackable, but editing someone else's class is a bad idea.
    MoneyYouYes — 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

    TermPlain-English meaningWhere you'll see it
    Single dispatchChoosing which function runs based on the type of the first argument only.@singledispatch
    Generic functionOne public name that is really a family of per-type implementations.format_quantity
    RegistryThe lookup table mapping each type to its implementation.format_quantity.registry
    FallbackThe undecorated base function; it handles any type with no better match."?? ..." output
    registerThe decorator that adds one type-specific implementation to the registry.@format_quantity.register
    MRO dispatchIf 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 registrationRegistering an abstract base class (like numbers.Real) to cover many concrete types at once.Program 4
    FractionAn exact rational number from fractions: Fraction(3, 2) is exactly 3/2, with no rounding error.Scaling recipes
    limit_denominatorFinds the closest fraction whose denominator is at most N. Turns 0.333 into 1/3.Float branch
    Mixed numberA whole part plus a fraction: 1 1/2. How cooks write quantities.to_mixed()
    RecursionA branch that calls the generic function again on the pieces it contains.Tuple ranges
    Open/Closed PrincipleOpen to extension (new types) but closed to modification (no edits to working code).Exercise 4
    singledispatchmethodThe 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))
    Expected output
    int branch | int branch | object branch True True ['int', 'object']
    📝 How to read this
    1. The undecorated function is the fallback. @singledispatch stores it in the registry under object — the ancestor of every type — so something always matches.
    2. show(True) used the int branch. Nothing is registered for bool, 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.
    3. .dispatch(type) lets you ask "who would answer?" without calling anything. Both identity checks print True: bool resolves to the very same function object as int, and str to the same as object.
    4. .registry lists what is registered — only two entries, int and object. bool and str are 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.

    MessageWhat it meansFix
    TypeError: show requires at least 1 positional argumentYou 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

    FlavorCould it format quantities?Verdict
    🧬 Method overridingNeeds every quantity to be an instance of your base class — you'd wrap every int and str first.Heavy; wrapping everywhere.
    🦆 Duck typingNeeds every value to have a .format() method. int, str and tuple don't, and can't gain one.Impossible for built-ins.
    ➕ Operator overloadingGreat for arithmetic (Money * 3) but not for choosing a text format by type.Used here for Money, not for dispatch.
    🎯 Single dispatchWorks 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

    QuestionAnswer
    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)}")
    Expected output
    Ladder failed: No format rule for Fraction ---------------------------------------- int → 2 float → 0.5 float → 1.25 float → 3 str → a pinch Fraction → ?? Fraction(3, 2)

    🔍 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 doesResult for 0.5Result for Fraction(3, 2)
    1Reads type(value)floatFraction
    2Looks the type up in its registryFound: the float function.Not found.
    3If not found, walks the type's parents until one is registered(not needed)Reaches object, the fallback.
    4Calls the chosen function with the original arguments"0.5""?? Fraction(3, 2)"
    LadderSingle dispatch
    Unknown typeRaises TypeError (or silently wrong)Reaches a fallback you control
    Adding a typeEdit the ladderAdd one registered function anywhere
    Each branchLives inside one big functionIs its own small, testable function
    Second operation (e.g. scaling)Another ladderAnother 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

    1. 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.
    2. from functools import singledispatch — the decorator that turns an ordinary function into a generic function.
    3. format_ladder, the int branch — isinstance(value, int) asks "are you an integer?" and returns the digits as text.
    4. The float branch and the rstrip trick — 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 .2f always contains a dot, which stops the first rstrip.
    5. The str branch and the final raise — strings like "a pinch" pass through untouched; anything else raises TypeError naming the offending type.
    6. 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.
    7. print("-" * 40) — a divider. Here * repeats a string (itself a small piece of polymorphism).
    8. @singledispatch def format_quantity(value) — the undecorated body is the fallback, registered for object. {value!r} uses repr, so Fraction(3, 2) shows its type clearly.
    9. @format_quantity.register with def _(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.
    10. The float and str registrations — the same pattern; the float body is exactly the ladder's float branch, now in its own function.
    11. The final loop — f"{type(item).__name__:<9}" left-aligns the type name in a 9-character column. Notice that 3.0 prints as 3 and 2 as 2.
    12. 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 True to the list in the loop. Which branch answers, and what does it print?
    • Change the fallback to raise TypeError(...). What now happens at the Fraction line?

    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))
    Expected output
    2 → 2 Fraction(3, 2) → 1 1/2 Fraction(1, 3) → 1/3 Fraction(2, 1) → 2 Fraction(7, 4) → 1 3/4 0.75 → 3/4 2.5 → 2 1/2 0.333 → 1/3 0.1 → 1/8 1.9 → 1 7/8 3602879701896397/36028797018963968 1/10 1/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.

    Inputdivmod(num, den)Case takenOutput
    Fraction(4, 2)Python simplifies to 2/1; divmod(2, 1) = (2, 0)rem == 02
    Fraction(1, 3)(0, 1)whole == 01/3
    Fraction(3, 2)(1, 1)whole and fraction1 1/2
    Fraction(7, 4)(1, 3)whole and fraction1 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

    1. to_mixed(frac) — a plain helper (not dispatched). Both the Fraction and float branches reuse it, so mixed-number logic exists exactly once.
    2. 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.
    3. if rem == 0: return str(whole) — an exact whole number such as 2/1; print only the whole part.
    4. if whole == 0 — a proper fraction such as 1/3; print only the fraction.
    5. The last return — both parts exist: "1 1/2", with a space between the whole and fraction as in a cookbook.
    6. The fallback and int branch — unchanged from Program 1. Programs build on each other; the generic function only grows.
    7. register for Fraction — one line: delegate to to_mixed. This is the whole cost of supporting a new type.
    8. register for float — converts the float to a Fraction, snaps it with limit_denominator(8), then reuses to_mixed. Note the float branch now delegates instead of formatting decimals itself.
    9. The loop with {item!r:<14} — !r prints the repr (so you see Fraction(3, 2) and not just 3/2), and :<14 pads it so the arrows line up.
    10. print(Fraction(0.1)) — the huge numerator and denominator: the exact value of the float.
    11. print(Fraction("0.1")) — from a string, Python reads decimal digits exactly: 1/10.
    12. 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) to 16. Predict, then check, what happens to 0.1 and 1.9.
    • Add Fraction(0, 5) to the list. Which branch of to_mixed catches 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)))
    Expected output
    flour 2 cups milk 1 1/2 cups vanilla 1/2 tsp salt a pinch butter 2–3 tbsp saffron PHP 250.00 per gram nutmeg 1/4–a pinch tsp ['Fraction', 'Money', 'float', 'int', 'object', 'str', 'tuple'] PHP 1,234.50

    🔍 Deep dive — six branches, one call site

    Registered typeExample valueWhat its branch doesOutput
    int2str(value)2
    FractionFraction(3, 2)mixed number1 1/2
    float0.5snap to eighths, then mixed number1/2
    str"a pinch"return unchangeda pinch
    MoneyMoney(250)use the class's own __str__PHP 250.00
    tuple(2, 3)format both ends, join with an en dash2–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

    1. class Money and round(amount, 2) — a tiny value class that stores two decimal places. (Production money code uses decimal.Decimal; floats keep this lesson short.)
    2. __str__ with {self.amount:,.2f} — the , adds thousands separators and .2f forces two decimals, so 250 prints as PHP 250.00.
    3. __repr__ — the developer view, used by the fallback's !r when a type is unregistered. Always define it.
    4. to_mixed and the first four registrations — carried over from Programs 1 and 2 exactly as they were.
    5. register for str — returns the string untouched, so "a pinch" works as a quantity.
    6. register for Money — the annotation value: Money works with your own classes just as it does with built-ins.
    7. register for tuple and low, high = value — unpacks a two-element range. A one- or three-element tuple would raise ValueError here; Program 5 shows a more forgiving version.
    8. f"{format_quantity(low)}–{format_quantity(high)}" — the recursive step. The inner calls go back through the dispatcher.
    9. The rows list — each row is (name, quantity, unit). The quantities are seven different shapes in one list, sharing one for loop.
    10. {format_quantity(qty):>12} — right-aligns the formatted quantity in a 12-character column so the numbers line up like a menu.
    11. sorted(t.__name__ for t in format_quantity.registry) — reads the dispatcher's own registry: seven keys including object, the fallback.
    12. 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 set such as {1, 2} to format_quantity. Which branch answers?
    • Delete the Money registration. What does the saffron row 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"))
    Expected output
    1. only int: ['int', 'int', 'fallback', 'fallback', 'fallback', 'fallback', 'fallback'] 2. + bool: ['int', 'bool', 'fallback', 'fallback', 'fallback', 'fallback', 'fallback'] 3. + Real (ABC): ['int', 'bool', 'Real', 'Real', 'fallback', 'fallback', 'fallback'] 4. + str|bytes, None: ['int', 'bool', 'Real', 'Real', 'fallback', 'text', 'none'] 5. + Decimal: ['int', 'bool', 'Real', 'Real', 'Decimal', 'text', 'none'] ['object', 'int', 'bool', 'Real', 'str', 'bytes', 'NoneType', 'Decimal'] True RuntimeError raised: True

    🔍 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.

    SampleStage 1FinalWhy
    1intintAn exact registration always beats an ABC, so adding Real doesn't steal it, even though int is a Real.
    Trueintboolbool is a subclass of int. With no bool branch it takes the int branch; stage 2 adds the exact match.
    2.5fallbackRealfloat is registered as a virtual subclass of numbers.Real, so registering Real covers it.
    Fraction(1, 2)fallbackRealFraction inherits from numbers.Rational, itself a kind of Real. One ABC registration, two unrelated types.
    Decimal("1.5")fallbackDecimalSurprise: 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"fallbacktextThe annotation str | bytes registers both types with one function.
    NonefallbacknoneNone 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

    1. The imports — Real comes from numbers (the numeric ABCs); Sized and Iterable from collections.abc.
    2. The kind function returns its own label — a debugging trick: instead of formatting something, each branch just names itself so you can see the routing.
    3. samples and show(label) — a fixed list of seven values and a helper that prints the branch name for each. :<22 aligns the stage labels.
    4. Stage 1 — only int registered. True still prints int (parent lookup); everything else non-int falls back.
    5. Stage 2: register for bool — the exact match now exists, so True becomes bool. Nothing else changes.
    6. Stage 3: @kind.register(Real) — the explicit form, because the function has no annotation. Floats and Fractions now share one branch.
    7. Stage 4: str | bytes and type(None) — a union annotation registers several types at once; None uses the explicit form.
    8. Stage 5: register(Decimal) — Decimal had to be added by name because it is not in the numeric tower.
    9. [t.__name__ for t in kind.registry] — the registry in registration order. str and bytes appear separately although one function was decorated.
    10. kind.dispatch(Fraction) is kind.dispatch(float) — both resolve to the single Real function, so the identity check is True.
    11. class Both — implements just enough of two protocols to satisfy two ABCs at once.
    12. probe with Sized and Iterable — two ABC registrations that overlap on Both. The call raises RuntimeError; we confirm its message starts with Ambiguous 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 does 2.5?
    • Add show("6. + float:") after registering float by name. Which sample moves from Real to the new branch?
    • Register Both itself on probe and call probe(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())
    Expected output
    Pancakes (serves 4) 2 cups flour 1 1/2 cups milk 2 tbsp sugar 1 eggs a pinch salt 2–3 tbsp butter 1/2 tsp vanilla Estimated cost: PHP 79.00 Pancakes (serves 6) 3 cups flour 2 1/4 cups milk 3 tbsp sugar 1 1/2 eggs a pinch salt 3–4 1/2 tbsp butter 3/4 tsp vanilla Estimated cost: PHP 118.50 Pancakes (serves 2) 1 cups flour 3/4 cups milk 1 tbsp sugar 1/2 eggs a pinch salt 1–1 1/2 tbsp butter 1/4 tsp vanilla Estimated cost: PHP 39.50

    🔍 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.

    IngredientStored asscale branchExact resultPrinted
    flour2 (int)intFraction(2) × 3/2 = 33
    milkFraction(3, 2)Fraction3/2 × 3/2 = 9/42 1/4
    eggs1 (int)int3/21 1/2
    salt"a pinch"strunchanged — you can't double a pincha pinch
    butter(2, 3)tuple → recurses(3, 9/2)3–4 1/2
    vanilla0.5 (float)float1/2 × 3/2 = 3/43/4
    costsMoney(...)Money (operator *)79.00 × 1.5PHP 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

    1. Money.__add__ — adds two Money objects and returns a new one. For any other type it returns NotImplemented (the polite "not my job" from the Polymorphism guide).
    2. Money.__mul__ and __rmul__ = __mul__ — multiplies by an int, float or Fraction. Converting with float(factor) keeps the stored amount an ordinary float and makes the intent explicit. __rmul__ makes 2 * price work too.
    3. to_mixed and the format_quantity branches — as in Program 3, except the tuple branch is now "–".join(...), which works for ranges of any length.
    4. @singledispatch def scale(value, factor) — a second, independent generic function. It dispatches on value only; factor is a plain extra argument. The fallback raises because an unknown type can't be scaled sensibly.
    5. scale for int — Fraction(value) * factor promotes the int to an exact fraction first, so 2 × 3/2 is exactly 3.
    6. scale for float — snaps with limit_denominator(16) before scaling. Floats are the only branch that loses a little precision, and it is a deliberate, documented choice (sixteenths).
    7. scale for str — returns the string as-is. "A pinch" stays "a pinch" at any serving size.
    8. scale for Money — just value * factor, leaning on the operator overloading defined earlier.
    9. scale for tuple — rebuilds the tuple by calling scale on every element: the same recursive trick as in format_quantity.
    10. @dataclass class Ingredient — generates __init__ and __repr__ for free. qty: object admits any quantity type; cost: Money | None = None means "optional price".
    11. Recipe.scaled(servings) — builds factor = Fraction(servings, self.servings), then creates a new Recipe whose quantities and costs are scaled. The original is untouched, so you can scale one recipe to many sizes.
    12. Recipe.total_cost() — sum(..., Money(0)) starts from a Money(0) so the additions are Money + Money. The if i.cost filter skips salt, which has no price.
    13. Recipe.render() — builds one text line per ingredient with fixed column widths (:>8 right-aligns the quantity, :<5 left-aligns the unit), then appends the cost through format_quantity so it uses the Money branch.
    14. 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 Decimal quantity and run scaled(6). Which message appears, and from which generic function?
    • Invent a custom Pinch class and register it in both generic functions. How many existing lines did you edit?
    • Change the float branch of scale to limit_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-up

    Task: 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)}")
    Expected output
    Decimal 1.50 → 1.5 Decimal 2.00 → 2 Decimal 100 → 100 Decimal 0.250 → 0.25 int 3 → 3

    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?

    Easy

    Task: 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')}")
    Expected output
    1 cup 2 cups 1/2 cup 3/2 cups 0.75 cup 2.5 cups (2, 3) cups a pinch 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

    Easy

    Task: 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)
    Expected output
    3 ValueError: A bool is not a quantity ValueError: A bool is not a quantity

    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

    Medium

    Task: 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))
    Expected output
    2 a splash (~15 ml) a handful 3 handfuls

    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

    Medium

    Task: 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)
    Expected output
    480 180.0 (240, 120.0) TypeError: Cannot convert str

    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

    Medium

    Task: 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))
    Current (wrong) output
    1 1/2 0 1/2 2 0/1 3/2–2 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)
    Expected output
    1 1/2 1/2 2 1 1/2–2 ValueError: A bool is not a quantity

    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.

    🐍 playground.py
    Press Run to execute your code.

    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.

    🍳 Dispatch Lab · 0 / 8 values 7 types registered
    Add a value to begin.

    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.

    Advertisement

    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

    FormUse when
    @f.register + annotationFunction has a typed first parameter
    @f.register(int)No annotation, explicit type
    f.register(int, func)Registering an existing function or lambda
    annotation str | bytesOne function for several types
    register(type(None))Handling None
    register(Real)Cover many types via an ABC

    🔎 Lookup Rules

    StepDetail
    1Read type of first positional argument
    2Exact type registered? Use it
    3Else nearest parent or ABC in the MRO
    4Else object: the fallback
    TieRuntimeError: Ambiguous dispatch

    ⚠️ Common Pitfalls

    MistakeFix
    True uses the int branchRegister bool
    Decimal not caught by RealRegister Decimal
    Re-def instead of registerUse .register
    Keyword argument callPass positionally
    No annotation on registerAnnotate or pass the type
    Fraction(0.1) is hugeFraction("0.1") or limit_denominator

    🧰 Handy Tools

    ToolPurpose
    f.dispatch(cls)Which function would answer?
    f.registrySee every registered type
    singledispatchmethodDispatch 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.

    0/5

    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.

    Shape Renderer

    A mini vector editor. Circle, Square, Triangle and FreeformPath all extend Shape and override draw(), area() and perimeter(). One canvas loop renders them all.

    🎨 Overriding Watch: shape.draw(ctx) hit four different classes
    Image Filter Plugin

    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.

    🦆 Duck Typing Watch: drop a new filter file in plugins/ — pipeline works unchanged
    Currency Converter

    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().

    ➕ Operators Watch: price * qty + tax run entirely through dunder methods
    You are here
    Recipe Formatter

    A recipe holds ingredients of many shapes — int, float, Fraction, str ("a pinch"), Money. One format_quantity() function uses @singledispatch to render each correctly.

    🎯 Single Dispatch Watch: one call site, six registered type branches
    Media Player

    Songs, podcasts, audiobooks and video clips are all MediaItem subclasses. The player calls play(), pause() and duration() — each item decides what those mean.

    🎵 Overriding Watch: one transport bar, four media kinds
    Test-Double Lab

    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.

    🦆 Duck Typing Watch: swap the file backend in one line of config
    Double-Entry Ledger

    Every journal entry is a Debit or Credit object that supports __neg__, __add__, __eq__ and __hash__. Balancing a ledger is literally sum(entries) == 0.

    ➕ Operators Watch: account totals computed with +, not .total()
    Log Router

    A logging system where emit(message) dispatches on the message type: str → plain text, dict → JSON, Exception → traceback, bytes → base64. One function, many formats.

    🎯 Single Dispatch Watch: register a new format at runtime, no edits to emit
    Chess Pieces

    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.

    ♟️ Overriding Watch: one move validator, six piece types
    Protocol Checker

    Paste any class and see live which typing.Protocol interfaces it satisfies — Iterable, Sized, Comparable, Hashable. Built with @runtime_checkable protocols.

    🦆 Duck Typing Watch: 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.

    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.

    6 type branches, 2 generic functions 5 programs · 6 exercises Interactive playground & lab ABCs, unions and ambiguity covered

    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 True uses the int branch until you register bool.
    • Recurse through the generic function for containers so nested values dispatch correctly.
    • Use exact Fraction arithmetic 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

    Polymorphism Guide Main Hub Back to Top

    Share This Project

    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.

    Weekly Python tutorials AI & ML deep-dives OOP series updates Free, cancel anytime

    🔒 Your email is never shared or sold.

    Follow Poetic Bytes

    Stay connected for Python tips, OOP insights, and new project walkthroughs.

    Advertisement