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 :)