Decorator overuse

Decorators versus blocks and partial functions.

style
Author

A. Coady

Published

March 4, 2023

Decorators versus blocks and partial functions.

Decorators are a beloved feature of Python, but like any good thing can be overused. The key is acknowledging that decorators are just functions.

A function returning another function, usually applied as a function transformation using the @wrapper syntax. Common examples for decorators are classmethod() and staticmethod().

The decorator syntax is merely syntactic sugar, the following two function definitions are semantically equivalent:

def f(arg):
   ...
f = staticmethod(f)

@staticmethod
def f(arg):
   ...

Renamed

So the critical feature of the @ syntax is to retain the defined object’s name; otherwise it is just a function call. Which leads to the first example of overuse: defining a new object just to change the name. Consider this example adapted from a popular project.

class Response:
    def __bool__(self):
        return self.ok

    @property
    def ok(self):
        ...

Since a property wraps a function, it is natural to make the function have the implementation instead. Then it becomes clear that the property does not share the same name, so why bother with @.

class Response:
    def __bool__(self):
        ...

    ok = property(__bool__)

A related scenario is where the local name of the function is irrelevant, which is typical in wrapped functions:

@functools.wraps(wrapped, assigned=WRAPPER_ASSIGNMENTS, updated=WRAPPER_UPDATES)

This is a convenience function for invoking update_wrapper() as a function decorator when defining a wrapper function. It is equivalent to partial(update_wrapper, wrapped=wrapped, assigned=assigned, updated=updated). For example:

>>> from functools import wraps
>>> def my_decorator(f):
...     @wraps(f)
...     def wrapper(*args, **kwds):
...         print('Calling decorated function')
...         return f(*args, **kwds)
...     return wrapper

The “convenience function” is useless indirection when the wrapper is immediately returned. Even the documentation points out that wraps is just a partial function. The example could be simply:

def my_decorator(f):
    def wrapper(*args, **kwds):
        print('Calling decorated function')
        return f(*args, **kwds)
    return update_wrapper(wrapper, f)

Giving partial(update_wrapper, wrapped=f) a short name does not make it any clearer conceptually.

With blocks

Another sign is if the decorator’s functionality only executes code before or after the wrapped function. Context managers are inherently more flexible by providing the same functionality for any code block. In some cases a function boundary is natural to bookend, e.g., logging or timing. The question is whether the function block is too broad a context to manage.

Decorators were introduced in version 2.4; context managers in 2.5. All ancient history now, but decorators had a ~2 year head start. For example, transactions are a seminal use case for context managers, but Django pre-dates 2.5, so it had a transaction decorator first. This is how transactions are currently presented:

atomic is usable both as a decorator:

from django.db import transaction

@transaction.atomic
def viewfunc(request):
    # This code executes inside a transaction.
   do_stuff()

and as a context manager:

from django.db import transaction

def viewfunc(request):
    # This code executes in autocommit mode (Django's default).
    do_stuff()

    with transaction.atomic():
        # This code executes inside a transaction.
        do_more_stuff()

So it has both, but the decorator is presented first, and is it a good example? Seems likely that a full request would have setup and teardown work that is unrelated to a database transaction. It is uncontroversial to want try blocks to be as narrow as possible. Surely there is no benefit to a request operation rolling back a vacuous transaction, nor a response operation rolling back a transaction that was committable.

Any context manager can be trivially transformed into a decorator; the converse is not true. And even if the function block is coincidentally perfect, a with block has negligible impact on readability. It is just indentation.

Partial functions

Next is a lack of appreciation of partially bound functions. Many decorator examples go out of their way to write an unnecessary def statement, in order to make using a decorator look natural. The below example is common in Python tutorials.

import functools

def repeat(num_times):
    def decorator_repeat(func):
        @functools.wraps(func)
        def wrapper_repeat(*args, **kwargs):
            for _ in range(num_times):
                value = func(*args, **kwargs)
            return value
        return wrapper_repeat
    return decorator_repeat

@repeat(num_times=4)
def greet(name):
    print(f"Hello {name}")

greet("World")
Hello World
Hello World
Hello World
Hello World

First the obligatory observation that abstracting a for loop in Python is not necessarily a good idea. But assuming that is the goal, it is still worth questioning why repeating 4 times is coupled to the name greet. Is print supposed to represent the “real” function in this example, or should the wrapped function be named greet_4x? It is much simpler to start with the basic functionality and postpone how to wrap it.

def repeat(num_times, func, *args, **kwargs):
    for _ in range(num_times):
        value = func(*args, **kwargs)
    return value

def greet(name):
    print(f"Hello {name}")

repeat(4, greet, "World")
Hello World
Hello World
Hello World
Hello World

We can stop there really. But even assuming that the goal is to bind the repetition, using partial functions is still simpler.

from functools import partial

greet_4x = partial(repeat, 4, greet)
greet_4x("World")
Hello World
Hello World
Hello World
Hello World

Not exactly the same without wraps, but that would be trivial to add. Futhermore it is less useful because partial objects can be easily introspected. Now onto the next - and dubious - assumption: that we really want it used as a decorator. This requires assuming the body of greet is not a simple call to an underlying wrapped function, and yet for some reason the repetition is supposed to be coupled to the wrapper function’s name anyway. Still simpler:

repeats = partial(partial, repeat, 4)

@repeats
def greet(name):
    print(f"Hello {name}")

greet("World")
Hello World
Hello World
Hello World
Hello World

Nested partials may appear a little too clever, but they are just the flatter version of the original nested repeat functions. And again, none of this indirection is necessary.

For loops

A real-world example of repeat is retrying functions until success, optionally with delays. A popular one uses examples like:

@backoff.on_exception(backoff.expo, requests.exceptions.RequestException)
def get_url(url):
    return requests.get(url)

The same pattern (ahem) repeats. The decorated function is a trivial wrapper around the “real” function. Why not:

get_url = backoff.on_exception(backoff.expo, requests.exceptions.RequestException)(requests.get)

Furthermore, for loops can be customized via the __iter__ protocol, just as with blocks are customizable. The author’s waiter package demonstrates the same functionality with for loops and undecorated functions.

Advocacy

So before assuming a decorator is the right abstraction, start with whether a def function is the right abstraction. Building out functionality in this progression works well:

  1. code blocks: with and for and customizable
  2. flat functions
  3. nested functions: using partial
  4. decorated functions