What is the Python decorator really?

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.

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.

python

@some_decorator
def foobar():
    ...

It’s equivalent to the following code.

python

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.

python

@foo
@bar
def hello():
    ...

# ------------------

def hello():
    ...

hello = foo(bar(hello))

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.

python

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.

python

@some_decorator(k1=v1, k2=v2)
def foobar():
    ...

This is equivalent to

python

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.

python

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

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

python

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)

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.

python

@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

python

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.

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.

python

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)

I used this decorator when I wanted to know how long a function runs and it’s trivial to write.

python

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

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