Inferior decorating
It’s another programming post, sorry Mum!
Python’s decorator syntax is an extremely lovely piece of incremental language design. It adds just a tiny bit of syntactic sugar to the language, but that sugar makes you think about structuring your code in ways that I (at least, and apparently plenty of other people) find extremely productive.
There are two sane ways to write a decorator:1
def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): # do what the decorator does and... return func(*args, **kwargs) return wrapper def decomaker(): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): # do what the decorator does and... return func(*args, **kwargs) return wrapper return decorator |
The tl;dr of this article is: always use the second one.
That’s right, I’m saying you should write the version with more boilerplate, not less. The reason is very simple: the second form allows you to extend your decorator with keyword arguments, while preserving backwards compatibility with any code that doesn’t know about them. The first form, on the other hand, can only be extended by breaking backward compatibility and requiring all uses of the decorator to be updated.
Let’s work that through in a little example. Here are two implementations of a simple entry/exit logging decorator.
def log_entry_exit_bare(func): @functools.wraps(func) def wrapper(*args, **kwargs): logging.debug('Entering %s', func.func_name) try: return func(*args, **kwargs) finally: logging.debug('Exiting %s', func.func_name) return wrapper def log_entry_exit_args(): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): logging.debug('Entering %s', func.func_name) try: return func(*args, **kwargs) finally: logging.debug('Exiting %s', func.func_name) return wrapper return decorator |
Now here’s some code that uses them:
@log_entry_exit_bare def my_first_function(a=1000): for b in range(1, a): my_second_function(b) @log_entry_exit_args() def my_second_function(a=1000): for b in range(a, 1, -1): my_first_function(b) |
All well and good. Only now, it turns out that what my_second_function
does is important enough to merit an INFO
log rather than plain DEBUG
. Easily done:
def log_entry_exit_args(log_level=logging.DEBUG): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): logging.log(log_level, 'Entering %s', func.func_name) try: return func(*args, **kwargs) finally: logging.log(log_level, 'Exiting %s', func.func_name) return wrapper return decorator @log_entry_exit_args(log_level=logging.INFO) def my_second_function(a=1000): for b in range(a, 1, -1): my_first_function(b) |
Crucially, any other code that also uses log_entry_exit_args
continues to work exactly as it did before.
I can’t work the same magic with log_entry_exit_bare
. I can easily upgrade it to take an argument (rewriting its definition to match log_entry_exit_args
), but then I’ll have to track down every place it is used in my code, and add ()
to each usage. If it’s in a library I’ve released into the wider world, everyone using that library will have to change their code for the next release that incorporates this change. (Or, more likely, I won’t ever make such a release.)
Let’s face it: if you care enough about code reuse to use decorators at all, you probably care about this kind of backwards compatibility too. So don’t get stuck with inferior decorating.
Notes:
- There are less insane ways than I would have expected, because Guido in his wisdom followed a gut feeling and restricted the syntax of decorator expressions. [↪]