All a decorator does is it allows you to build upon your function in some way i.e. it's a higher-order function. Ultimately, I prefer explaining them by "unrolling" the decorator.
# This:
@decorator
def foo(...):
...
# Becomes this:
def _foo(...):
...
def foo(...):
...
You can also express the same logic using OOP.
from abc import ABC, abstractmethod
class Decorator(ABC):
def foo(...):
...
@abstractmethod
def _foo(...):
...
class Foo(Decorator):
def _foo(...):
...
Inheritance makes sense in this case because you're essentially encapsulating logic that subclasses share. This example doesn't always work because you might want to use different method names for example, but concept is definitely there, and there's more sophisticated ways you can work around it all.
Ultimately however, decorators are higher-order functions. With that, I would suggest exercising them with some restraint, remembering that higher-order functions are for when you need the function itself and cannot separate the logic from it.
Common examples where using decorators might overcomplicate things in my experience include:
- Pre-/post-processing e.g. input checking, which should just be a function call at the start/end(s) of your function.
# Turn this:
@type_check
def foo(...):
...
# Into this:
def foo(...):
type_check(...)
...
- Treating the function call like a block of code e.g. for timing, which can be done using
with
blocks instead.
# Turn this:
@timed
def foo(...):
...
# Into this:
def foo(...):
with Timer():
...
Decorators are good for cases where the function being decorated is itself necessary, such as a function which needs to be saved to an API library that will call it for your API.