Understanding Decorators in Python

What are decorators?
Decorators are wrappers around functions (or classes) in Python that change the way that function works. A decorator abstracts its own functionality. Decorator notation is generally the least invasive. A developer can write his code however he wants and use decorators only to extend the functionality. This all sounds very abstract, so let’s look at some examples.
In Python, decorators are used to “decorate” functions (or methods). Perhaps one of the most popular decorators is @property
:
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 seen in the last line, you can access area
our Rectangle
as an attribute, i.e. you don’t need to call the method area
. Instead, when accessing area
as an attribute (without ()
), the method is called implicitly because of the decorator @property
How it works?
Write @property
before the function definition is the same as writing
area = property(area)
. In other words: property
is a function that takes another function as an argument and returns a third one. This is how decorators behave.
As a result, decorators change the behavior of the function they are applied to.
We write our decorators
retry decorator
Let’s write our own decorators according to this vague definition in order to understand how they work.
Let’s say we have a function that we want to retry if it fails. We need a function (decorator) that will call our function once or twice (depending on how the function ends the first time).
Given our initial decorator definition, we can write a 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
– the name of our decorator, which takes any function as an argument (func
). Inside the decorator, a new function is defined and returned (_wrapper
). At first glance, the definition of one function within another may seem somewhat unusual. Syntactically, however, this is perfectly normal and has a certain advantage, because the function _wrapper
only exists inside our decorator’s namespace retry
.
Note that in the example we only decorated our function with @retry
. After decorator @retry
no parentheses. Thus, when calling the function might_fail()
decorator @retry
will be called with our function (might_fail
) as the first argument.
As a result, we process three functions:
retry
_wrapper
might_fail
Sometimes you want a decorator to take arguments. In our case, we can set the number of retries as a parameter. However, the decorator must accept our function as the first argument. Remember, we didn’t need to call the decorator when decorating a function with it, so we just wrote @retry
but not @retry()
.
A decorator is nothing but a function (which takes another function as an argument)
To use a decorator, you need to place it before the function definition without calling it
Therefore, we could write a fourth function that takes the parameter we want as a configuration and returns a function that is actually a decorator (which takes another function as an argument).
Let’s try like 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()
Let’s break it down into components:
First we had a function
retry
;Retry
takes an arbitrary argument (max_retries
in our case) and returns a function;retry_decorator
is the function returnedretry
and in fact our decorator;_wrapper
works the same as before (now it just does the maximum number of retries).
To define our decorator:
This time
might_fail
decorated with a function call, i.e.@retry(2)
;retry(2)
causesretry
which returns the decorator itself;might_fail
will eventually be decoratedretry_decorator
since this function is the result of callingretry(2)
.
Timer Decorator
Here is another example of a useful decorator. Let’s write a decorator that will return the execution time of functions.
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())
Conclusion:
complex_calculation took 0.5041 secs
42
As we can see, the timer decorator executes the code before and after the function and works exactly the same as in the last example.
functools.wraps
You may have noticed that the function itself _wrapper
decorated @functools.wraps
. This fact does not change the logic or functionality of our timer decorator in any way. You might as well not use at all @functools.wraps
.
However, since our decorator @timer
could have been written as: complex_calculation = timer(complex_calculation)
the decorator will definitely change our function complex_calculation
. In particular, it changes some attributes of special methods:
__module__
__name__
__qualname__
__doc__
__annotations__
Using @functools.wraps
all of these attributes revert to their default values.
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’ve looked at function decorators. However, they can also be used for classes.
Let’s take our timer from the example above. We can quite easily wrap a class in it:
@timer
class MyClass:
def complex_calculation(self):
time.sleep(1)
return 42
my_obj = MyClass()
my_obj.complex_calculation()
As a result:
Finished 'MyClass' in 0.0000 secs
It is obvious that the execution complex_calculation
didn’t take time. Remember that the @ notation is just the equivalent of writing MyClass = timer(MyClass)
, i.e. the decorator will only be called when you “call” the class. Calling a class means creating an instance of it, so the timer will only work for the line my_obj = MyClass()
.
Class methods are not automatically decorated when the class is decorated. Simply put, when using a decorator for an ordinary class, only its constructor is decorated (method __init__
).
You can change the behavior of the class as a whole by using a different form of the constructor. However, let’s first see if decorators can work the other way around, that is, if we can decorate a function with a class. It 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()
Conclusion:
Called 1 times
Called 2 times
Called 3 times
How it works:
__init__
called at checkoutsome_function
. Again, remember that decorating is the same assome_function = MyDecorator(some_function)
.__call__
called when an instance of the class is used, such as when a function is called. Insofar assome_function
is now an instance of my decorator, but we still want to use it as a function, we’ll need a special method__call__
.
Decorating a class in Python works like modifying a class from outside (i.e. from a decorator).
See:
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())
Conclusion:
MyClass __init__
42
Again, remember the definition of a decorator, because everything that happens here follows the same logic:
my_obj = MyClass()
first calls the decorator,Decorator
add_calc
adds a methodcalc
to the classAs a result, the class is created using the constructor.
You can use decorators to change classes like inheritance. Whether this is good or bad depends largely on the architecture of your project as a whole. Decorator dataclass
from the standard library is a great example of sensible usage where decorators are preferred over inheritance. We’ll talk about it now.
Using Decorators
Decorators in the Python Standard Library
In the following sections, we’ll take a look at some of the popular and useful decorators that are already in the standard library.
property
As we already know, the decorator @property
is probably one of the most used decorators in Python. It is needed so that you can access the result of the method as an attribute. Of course, there is an analogue @property
with which you can call the method under the hood 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 popular decorator – staticmethod
. It is needed if you want to call a function defined inside a class without creating an instance of the class:
class C:
@staticmethod
def the_static_method(arg1, arg2):
return 42
print(C.the_static_method())
functools.cache
When you are dealing with functions that perform complex calculations, you may want to cache their result.
You can do something like this:
_cached_result = None
def complex_calculations():
if _cached_result is None:
_cached_result = something_complex()
return _cached_result
Saving a global variable such as _cached_result
checking it for None
and putting the result into that variable are repetitive tasks. All this makes them an ideal candidate for the position of decorator. Luckily, the Python standard library has a decorator that does all this for us:
from functools import cache
@cache
def complex_calculations():
return something_complex()
Now every time you call complex_calculations()
Python first checks for a cached result before calling something_complex
. If there is a result in the cache, something_complex
will not be called twice.
Dataclasses
In the section on class decorators, we saw that they can be used to change the behavior of classes in a similar way to inheritance.
Module dataclasses
in the standard library is a good example where using a decorator is preferable to using inheritance. Let’s first see how to use dataclasses
:
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
At first glance, the decorator @dataclass
only added a constructor, so we avoided boilerplate 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 write a REST API for your Python project and need to convert your Python objects to JSON strings, there is a package called dataclasses-json
(not in the standard library), which decorates classes and ensures that objects are serialized and deserialized to JSON strings and vice versa.
Let’s see what it looks like:
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}
From this we can draw two conclusions:
Decorators can be nested. The order in which they are written is important.
Decorator
@dataclass_json
added a method to our classto_dict
Of course, we could write a mixin class that does the hard work of implementing the method to_dict
data type safe and then inherit the class InventoryItem
From him.
However, in this case, the decorator only adds technical functionality (as opposed to extending functionality). As a result, we can simply “turn on and off” the decorator without changing the behavior of our application. The “natural” class hierarchy is preserved, and no changes to the code need to be made. We can add a decorator dataclasses-json
into the project without changing existing methods.
In this case, modifying a class with a decorator looks more elegant (because it maintains modularity) than inheritance or using mixins.
Tomorrow at OTUS there will be an open lesson “Docker for a Python developer”. Consider the best practices for writing Dockerfiles and working with docker in general. We will discuss the nuances of both a general nature and Python-specific ones. Registration – here.