The @functools.singledispatch decorator provides a mechanism for implementing generic functions—functions that can behave differently depending on the type of their first argument. This is a form of single-dispatch polymorphism, a concept familiar in many other programming languages. It allows you to separate the core logic of a function from the type-specific implementations, leading to cleaner, more maintainable, and more extensible code. Instead of writing a single function riddled with isinstance() checks or complex if/elif chains, you define a base implementation and then register specialized versions for specific types.

Core Concept and Base Function Registration

The process begins by defining a base function decorated with @singledispatch. This base function serves as the default implementation, which will be called if no more specific implementation is registered for the type of the first argument. It’s a best practice to design this default to handle a generic case or to raise a meaningful NotImplementedError for unhandled types.

import functools

@functools.singledispatch
def serialize(obj):
    """Base function for serializing objects to a string."""
    # A common pattern is to raise a NotImplementedError for unhandled types.
    raise NotImplementedError(f"Object of type {type(obj).__name__} cannot be serialized.")

Registering Type-Specific Implementations

The power of singledispatch comes from using the .register() decorator attached to the base function. This decorator is applied to new functions that will handle specific types. The type is either passed as an argument to the decorator or inferred from the function’s type annotation on its first parameter. The latter is the modern and preferred approach.

@serialize.register
def _(obj: int) -> str:
    return f"int({obj})"

@serialize.register
def _(obj: float) -> str:
    return f"float({obj})"

@serialize.register
def _(obj: list) -> str:
    inner = ", ".join(serialize(item) for item in obj)
    return f"[{inner}]"

# Using the decorator with an explicit type argument
@serialize.register(str)
def _(obj):
    return f"str('{obj}')"

When serialize() is called, the dispatcher checks the type of the first argument and routes the call to the appropriate registered function. The function names (like _ above) are irrelevant to the dispatch mechanism; only the registered type matters.

Inheritance and the MRO

A crucial aspect of singledispatch is its integration with Python’s Method Resolution Order (MRO). The dispatcher doesn’t just look for an exact type match; it traverses the class’s inheritance hierarchy to find the closest registered type. This allows you to register a handler for a base class (e.g., numbers.Integral) and have it handle all its subclasses (e.g., int, bool).

import numbers

@serialize.register(numbers.Integral)
def _(obj):
    return f"Integral({obj})"

# This will now use the 'Integral' handler, not the 'int' one.
print(serialize(True))  # Output: Integral(1)

The lookup follows the MRO of the argument’s class, meaning it finds the most specific registered type in the inheritance chain. If int and numbers.Integral were both registered, an int argument would use the int handler because it is more specific.

Working with Custom Classes

singledispatch truly shines when you need to extend functionality to custom classes without modifying their source code—adhering to the open/closed principle. You can easily add support for your own types.

class User:
    def __init__(self, name, id):
        self.name = name
        self.id = id

@serialize.register
def _(obj: User) -> str:
    return f"User(name='{obj.name}', id={obj.id})"

user = User("Alice", 123)
print(serialize(user))  # Output: User(name='Alice', id=123)

Common Pitfalls and Best Practices

A significant pitfall involves abstract base classes (ABCs) from modules like collections.abc. The MRO lookup is a runtime mechanism. If you register a handler for collections.abc.Sequence, it will correctly handle list, tuple, and str. However, due to the history of these ABCs, str is a sequence of itself, so the str handler (if registered) would take precedence over a Sequence handler. Always be mindful of the MRO precedence.

It’s also easy to forget that dispatch is only on the type of the first argument. All other arguments are passed through unchanged to the chosen function. Your function signatures must be consistent. If the base function is def func(obj, verbose=False), all registered functions must also accept a verbose keyword argument, even if they don’t use it.

@serialize.register
def _(obj: dict, verbose=False) -> str:  # Signature must match base `serialize`
    if verbose:
        return "A verbose dictionary representation"
    inner = ", ".join(f"{k}: {serialize(v)}" for k, v in obj.items())
    return f"{{{inner}}}"

# This works correctly.
print(serialize({"a": 1, "b": 2.0}))		   # Output: {a: int(1), b: float(2.0)}
print(serialize({"a": 1, "b": 2.0}, verbose=True)) # Output: A verbose dictionary representation

Best practice dictates using type annotations for registration, as it makes the code more readable and is the modern standard. Furthermore, while the functools module itself doesn’t provide it, singledispatch can be combined with functools.singledispatchmethod to create generic methods within classes, though this requires careful consideration of the self or cls argument.