What are decorators
Decorators are wrappers around Python functions (or classes) that change how these classes work. A decorator abstracts its own functioning as far away as possible. The Decorator notation is designed to be as minimally invasive as possible. A developer can develop his code within his domain as he is used to and only use the decorator to extend the functionality. Because this sounds very abstract, let's look at some examples.
In Python, decorators are used primarily to decorate functions (or methods, respectively). Maybe, one of the most commonly used decorators is the @property decorator:
class Rectangle:
def __init__(self, a, b):
self.a = a
self.b = b
@property
def area(self):
return self.a * self.b
rect = Rectangle(5, 6)
print(rect.area)
# 30
As you see in the last line, you can access the area of our Rectangle like an attribute, i.e., you don't have to call the area method. Instead, when accessing area like an attribute (without the ()) the method is called implicitly because of the @property decorator.
How does it work?
Writing @property in front of a function definition is the equivalent to writing area = property(area). In other words: property is a function that takes another function as an argument and returns a third function. And this is exactly what decorators do.
As a result, decorators change the behaviour of the decorated function. <!-- Common use cases include TODO -->
Writing Custom Decorators
Retry Decorator
With that vague definition, let's write our own decorators to understand how they work.
Let's say we have a function that we want to retry if it fails. We need a function (our decorator) that calls our function once or twice (depending on whether it failed the first time).
According to our initial definition of a decorator, we could write this simple decorator like this:
def retry(func):
def _wrapper(*args, **kwargs):
try:
func(*args, **kwargs)
except:
time.sleep(1)
func(*args, **kwargs)
return _wrapper
@retry
def might_fail():
print("might_fail")
raise Exception
might_fail()
retry is the name of our decorator, which accepts any function as an argument (func). Inside the decorator, a new function (_wrapper) is defined and returned. It can look somewhat unfamiliar to define a function inside another function at first sight. However, this is syntactically perfectly fine and has the advantage that our _wrapper function is just valid inside the namespace of our retry decorator.
Note that in this example, we decorated our function just with @retry. There are no parentheses (()) after the @retry decorator. Thus, when calling our might_fail() function, the retry decorator is called with our function (might_fail) as a first argument.
In total, we handle three functions here:
-
retry _wrappermight_fail
In some cases, we need the decorator to accept arguments. In our case, we could make the number of retries a parameter. However, a decorator must take our decorated function as the first argument. Remember that we did not need to call our decorator when decorating a function with it, i.e. we just wrote @retry as opposed to @retry() in front of our decorated function definition.
- The decorator is nothing else than a function (which accepts another function as argument)
- The decorator is used by putting it in front of a function definition without calling it
Hence, we could introduce a fourth function which accepts the parameter we want as configuration and returns a function that actually is a decorator (which accepts another function as argument).
Let's try this:
def retry(max_retries):
def retry_decorator(func):
def _wrapper(*args, **kwargs):
for _ in range(max_retries):
try:
func(*args, **kwargs)
except:
time.sleep(1)
return _wrapper
return retry_decorator
@retry(2)
def might_fail():
print("might_fail")
raise Exception
might_fail()
Tearing that one apart:
- On the first level, we have a function called
retry. -
retryaccepts an arbitrary argument (max_retriesin our case) and returns a function -
retry_decoratoris the function returned byretryand is our actual decorator -
_wrapperworks in the same way as before (it now just obeys the maximum number of retries)
That's for the definition of our decorator.
-
might_failis decorated by a function call this time, i.e.@retry(2). -
retry(2)cause the functionretryto be called and it returns the actual decorator -
might_failis eventually decorated byretry_decoratoras this function is the result of theretry(2)call.
Timer Decorator
Here is another example of a useful decorator: Let's create a decorator which measures the runtime of the functions decorated with it.
import functools
import time
def timer(func):
@functools.wraps(func)
def _wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
runtime = time.perf_counter() - start
print(f"{func.__name__} took {runtime:.4f} secs")
return result
return _wrapper
@timer
def complex_calculation():
"""Some complex calculation."""
time.sleep(0.5)
return 42
print(complex_calculation())
Output:
complex_calculation took 0.5041 secs
42
As we see, the timer decorator executes some code before and after the decorated function and works in the exact same way as in the last example.
functools.wraps
You might have noticed that the _wrapper function itself is decorated with @functools.wraps. This does not in any way change the logic or functionality of our timer decorator. You could as well decide to not use functools.wraps.
However, since our @timer decorator could have as well been written as: complex_calculation = timer(complex_calculation), the decorator necessarily changes our complex_calculation function. Insbesondere, it changes some of the magic reflection attributes:
__module____name____qualname____doc____annotations__
When using @functools.wraps, these attributes are set back to their originals
Without @functools.wraps
print(complex_calculation.__module__) # __main__
print(complex_calculation.__name__) # wrapper_timer
print(complex_calculation.__qualname__) # timer.<locals>.wrapper_timer
print(complex_calculation.__doc__) # None
print(complex_calculation.__annotations__) # {}
With @functools.wraps
print(complex_calculation.__module__) # __main__#
print(complex_calculation.__name__) # complex_calculation
print(complex_calculation.__qualname__) # complex_calculation
print(complex_calculation.__doc__) # Some complex calculation.
print(complex_calculation.__annotations__) # {}
Class Decorators
So far, we have just looked at decorators for functions. It's, however, possible to decorate classes, too.
Let's take the timer decorator from the example above.
It's perfectly fine to wrap a class with this decorator like so:
@timer
class MyClass:
def complex_calculation(self):
time.sleep(1)
return 42
my_obj = MyClass()
my_obj.complex_calculation()
The result?
Finished 'MyClass' in 0.0000 secs
So, there is obviously no timing printed for our complex_calculation method. Remember that the @ notation is just the equivalent for writing MyClass = timer(MyClass), i.e., the decorator will get called only when you "call" the class. Calling a class means instantiating it, so the timer is only executed at the line my_obj = MyClass().
Class methods are not automatically decorated when decorating a class. To put it simple, using a normal decorator to decorate a normal class decorates its constructor (__init__ method), only.
However, you can change the behaviour of a class as a whole by using another form of a constructor. However, let's first see if decorators can work the other way around, i.e. whether we can decorate a function with a class. Turns out we can:
class MyDecorator:
def __init__(self, function):
self.function = function
self.counter = 0
def __call__(self, *args, **kwargs):
self.function(*args, **kwargs)
self.counter+=1
print(f"Called {self.counter} times")
@MyDecorator
def some_function():
return 42
some_function()
some_function()
some_function()
Output:
Called 1 times
Called 2 times
Called 3 times
The way this works:
-
__init__is called when decoratingsome_function. Again, remember that decorating is just likesome_function = MyDecorator(some_function). -
__call__is called when an instance of a class is used, like calling a function. Assome_functionis now an instance ofMyDecoratorbut we still want to use it as a function, the DoubleUnderscore magic method__call__is responsible for this.
Decorating a class in Python, on the other hand works by changing the class from the outside (i.e., from the decorator).
Consider this:
def add_calc(target):
def calc(self):
return 42
target.calc = calc
return target
@add_calc
class MyClass:
def __init__():
print("MyClass __init__")
my_obj = MyClass()
print(my_obj.calc())
Output:
MyClass __init__
42
Again, if we recap the definition of a decorator, everything which happens here follows the same logic:
-
my_obj = MyClass()is calling the decorator first - the
add_calcdecorator patches thecalcmethod to the class - eventually, the class is instantiated by using the constructor.
You can use decorators to change classes in a way inheritance would do. If this is a good choice or not heavily depends on the architecture of your Python project as a whole. The standard library's dataclass decorator is an excellent example of a sensible usage choosing decorators over inheritance. We'll discuss that in a second.
Using decorators
decorators in Python's standard library
In the following sections, we will get to know a few of the most popular and most useful decorators that are already included in the standard library.
property
As already discussed, the @property decorator is probably one of the most commonly used decorators in Python.
It's purpose is that you can access the result of a method like an attribute. Of course, there is also a counterpart to @property so that you could call a method behind the scenes when performing an assignment operation.
class MyClass:
def __init__(self, x):
self.x = x
@property
def x_doubled(self):
return self.x * 2
@x_doubled.setter
def x_doubled(self, x_doubled):
self.x = x_doubled // 2
my_object = MyClass(5)
print(my_object.x_doubled) # 10
print(my_object.x) # 5
my_object.x_doubled = 100 #
print(my_object.x_doubled) # 100
print(my_object.x) # 50
staticmethod
Another familiar decorator is staticmethod. This decorator is used when you want to call a function defined inside a class without instantiating the class:
class C:
@staticmethod
def the_static_method(arg1, arg2):
return 42
print(C.the_static_method())
functools.cache
When you deal with functions that carry on a complex calculation, you might want to cache its result.
You could do something like this:
_cached_result = None
def complex_calculations():
if _cached_result is None:
_cached_result = something_complex()
return _cached_result
Storing a global variable like _cached_result, checking it for None, and putting the actual result into that variable if not present are repetitive tasks. This makes an ideal candidate for a decorator. Luckily, there is a decorator in Python's standard library which does exactly this for us:
from functools import cache
@cache
def complex_calculations():
return something_complex()
Now, whenever you call complex_calculations(), Python will check for a cached result first before it calls something_complex. If there is a result in the cache, something_complex will not get called twice.
dataclasses
In the section about class decorators we saw that decorators can be used to modify the behaviour of classes in the same way inheritance would change it.
The dataclasses module in the standard library is a good example when using a decorator is preferable over using inheritance. Let's first see how to use dataclasses in action:
from dataclasses import dataclass
@dataclass
class InventoryItem:
name: str
unit_price: float
quantity: int = 0
def total_cost(self) -> float:
return self.unit_price * self.quantity
item = InventoryItem(name="", unit_price=12, quantity=100)
print(item.total_cost()) # 1200
On the first sight, the @dataclass decorator only added a constructor for us, so we avoided boiler plate code like this:
...
def __init__(self, name, unit_price, quantity):
self.name = name
self.unit_price = unit_price
self.quantity = quantity
...
However, if you decide to build a REST-API for your Python project and need to convert your Python objects into JSON strings.
There is a package called dataclasses-json (not in the standard library) which decorates dataclasses and provide the serialisation and deserialisation of objects to JSON strings and vice versa.
Let's see how that looks:
from dataclasses import dataclass
from dataclasses_json import dataclass_json
@dataclass_json
@dataclass
class InventoryItem:
name: str
unit_price: float
quantity: int = 0
def total_cost(self) -> float:
return self.unit_price * self.quantity
item = InventoryItem(name="", unit_price=12, quantity=100)
print(item.to_dict())
# {'name': '', 'unit_price': 12, 'quantity': 100}
There are two takeaways here:
- decorators can be nested. The order of their appearance is important.
- the
@dataclass_jsondecorator added a method calledto_dictto our class
Of course, we could have written a mixin class that does the heavy work of implementing a data type safe to_dict method and then let our InventoryItem class inherit from that mixin.
In the present case, however, the decorator only adds a technical functionality (as opposed to an extension within the subject domain). As a result, we can simply switch the decorator on and off without our domain application changing its behaviour. Our "natural" class hierarchy is preserved and no changes need to be made to the actual code. We could also add the dataclasses_json decorator to a project without changing existing method bodies.
In such a case, changing a class with a decorator is much more elegant (because it is more modular) than inheriting or using mixins.
Top comments (1)
Thanks for taking the time to breakdown decorators. It's going to take another read and a bit of using it to really get an understanding. 🧐