What is the Python decorator really?
Intro
If you could understand this statement: Python function is a first-class function, then I believe you should have no problem understanding the Python decorator too. This statement means that the function is also a value, just like any other primitive types (int, str, float, etc
), and can be passed as arguments to function or returned as function outputs.
You may heard of the technical term - high-order function, which means that its arguments contain a function or (and) it returns a function. So we know that the Python decorator is a kind of high-order function.
In my opinion, the greatest advantage of using the Python decorator is that we are allowed to change the semantics of a function without changing the function itself. No wonder that it’s called decorator.
The Python decorator is a syntactic sugar
Before we dive into the details of Python decorator. I would like to tell you that python decorator is just syntax sugar. For example, consider the following code example.
@some_decorator
def foobar():
...
It’s equivalent to the following code.
def foobar():
...
foobar = some_decorator(foobar)
If you use multiple Python decorators to decorate a function, the same principle applies: the decorator closest to the decorated function takes effect first.
@foo
@bar
def hello():
...
# ------------------
def hello():
...
hello = foo(bar(hello))
Revisiting Python Decorators
Previously, I said that the Python decorator is a high-order function whose argument is a function and returns a function. This gives us some hints about writing the Python decorator. However, a function may have arguments or none at all, or it could have positional arguments or keyword arguments. With this in mind, we can write the following code.
def some_decorator(func):
def inner(*args, **kwargs):
# do something before the function call
func(*args, **kwargs)
# do something after the function call
return inner
Let’s consider a more complex situation: What if the decorator itself has arguments. We can try to infer this from the perspective of syntactic sugar.
@some_decorator(k1=v1, k2=v2)
def foobar():
...
This is equivalent to
def foobar():
...
some_decorator(k1=v1, k2=v2)(foobar)
In the aforementioned code template, the argument of some_decorator
is the function func
being decorated. However, now we have some_decorator(k1=v1, k2=v2)
and thus we know the some_decorator(k1=v1, k2=v2)
should return a function. Let’s add one more layer of function as shown below.
def some_decorator(k1, k2):
def wrapper(func):
def inner(*args, **kwargs):
# do something before the function call
func(*args, **kwargs)
# do something after the function call
return inner
return wrapper
The commonly used Python decorators
@property
Usually, we would encapsulate attributes (or fields) in Object-oriented programming, and the external world could only access these attributes by getter/setter. The @property
allows us to access/delete/modify the encapsulated attributes just like they are not encapsulated at all
class Name:
def __init__(self, x: str | None = None):
self.__first_name = x
@property
def first_name(self):
return self.__first_name
@first_name.setter
def first_name(self, value: str):
self.__first_name = value
@first_name.deleter
def first_name(self, value: str):
del self.__first_name
me = Name()
print(me.first_name) # without parenthesis
me.first_name = "Martin"
print(me.first_name)
@functools.wraps
The naive and intuitive Python decorator has a drawback: the name of the decorated function will be altered. Take the following example as an illustration.
@some_decorator
def hello():
print("hello")
print(hello.__name__)
# inner
The name of hello
becomes the name of the inner function inner
, which is unfriendly in debug setting. To handle such a situation, we could use the @functools.wraps
import functools
def some_decorator(func):
@functools.wraps(func) # <------------
def inner(*args, **kwargs):
# do something before the function call
print("Before")
func(*args, **kwargs)
print("After")
# do something after the function call
return inner
@some_decorator
def hello():
print("hello")
print(hello.__name__)
# hello
After using the @functools.wraps
, the name of the decorated function is reserved.
@cache
In competitive programming, we usually want to cache the calculated function outputs to avoid redundant computations. You can maintain a dictionary to remember the mapping between function arguments and function outputs. Or you could just use the @cache
decorator.
from functools import cache
@cache
def fib(n: int) -> int:
if n == 0:
return 0
elif n == 1 or n == 2:
return 1
else:
return fib(n - 1) + fib(n - 2)
@timeit
I used this decorator when I wanted to know how long a function runs and it’s trivial to write.
import time
import functools
def timeit(func):
@functools.wraps(func)
def inner(*args, **kwargs):
start = time.time()
func(*args, **kwargs)
end = time.time()
print(f"Execute {func.__name__} in {end - start:.2f} seconds")
return inner
@timeit
def foobar():
ans = 0
for i in range(10000000):
ans += i
return ans
foobar()
Wrap-up
That’s all for this post. One thing I didn’t mention is that the Python decorator can also decorate a Python Class, because I barely use the object-oriented feature in Python. However, I do believe you could understand it by yourself if you could understand this post :)