class Response:
def __bool__(self):
return self.ok
@property
def ok(self):
...
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): ...= staticmethod(f) 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.
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):
...
= property(__bool__) ok
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):
= func(*args, **kwargs)
value return value
return wrapper_repeat
return decorator_repeat
@repeat(num_times=4)
def greet(name):
print(f"Hello {name}")
"World") greet(
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):
= func(*args, **kwargs)
value return value
def greet(name):
print(f"Hello {name}")
4, greet, "World") repeat(
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
= partial(repeat, 4, greet)
greet_4x "World") greet_4x(
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:
= partial(partial, repeat, 4)
repeats
@repeats
def greet(name):
print(f"Hello {name}")
"World") greet(
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:
= backoff.on_exception(backoff.expo, requests.exceptions.RequestException)(requests.get) get_url
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:
- code blocks:
with
andfor
and customizable - flat functions
- nested functions: using
partial
- decorated functions