Understand how decorators work in python with some examples.

ยท

4 min read

Decorators in python are a way to wrap a function around some code. You can think of it as a middleware for a function in which we can run some code before and after executing the main function. So, A decorator is a function that takes another function as an argument and returns a wrapper function. The wrapper function we can define in three parts.

  • Code to execute before executing the main function

  • Execute the main function

  • Code to execute after executing the main function

Let's try to understand by some examples:-

Let's suppose we have a function that takes a list of integers and a max_num as arguments and returns a list that contains numbers that are less than or equal to the max_num.

from typing import List


def filter_nums(nums: List[int], max_num: int) -> List[int]:
    return list(filter(lambda x: x <= max_num, nums))


print(filter_nums([10, 20, 30, 1, 6], 10))
# output: [10, 1, 6]

Now we want to add functionality to print the time taken to execute the function.

from typing import List
from time import time


def filter_nums(nums: List[int], max_num: int) -> List[int]:
    return list(filter(lambda x: x <= max_num, nums))


def log_time(func):
    def wrapper(*args, **kwargs):
        start = time() # before executing main function
        res = func(*args, **kwargs) # execute the main function
        total_time = time() - start  # after executing main function
        print("Time taken to execute '{}' : {}".format(func.__name__, total_time))
        return res

    return wrapper


wrapped_function = log_time(filter_nums)
print(wrapped_function([10, 20, 30, 1, 6], 10))
# output:
# Time taken to execute 'filter_nums' : 1.0251998901367188e-05
# [10, 1, 6]

We have created a log_time function that is basically a decorator function. It accepts a function as an argument and returns a wrapper. The wrapper executes some code before and after calling the main function and returns the result of the main function.

This is basically what a decorator function does. But we can't every time create a wrapped_function variable for every function since it decreases the code readability. So, there is a way in python to call the decorator function in a more clean way.

Let's see the actual way of using the decorator function:-

from typing import List
from time import time


def log_time(func):
    def wrapper(*args, **kwargs):
        start = time()
        res = func(*args, **kwargs)
        total_time = time() - start
        print("Time taken to execute '{}' : {}".format(func.__name__, total_time))
        return res

    return wrapper


# The below line is added!
@log_time
def filter_nums(nums: List[int], max_num: int) -> List[int]:
    return list(filter(lambda x: x <= max_num, nums))


print(filter_nums([10, 20, 30, 1, 6], 10))
# output:
# Time taken to execute 'filter_nums' : 1.0251998901367188e-05
# [10, 1, 6]

print(filter_nums)
# output:
# <function log_time.<locals>.wrapper at 0x7fa0d0d5fb70>

Now you can see the actual and clean way of using decorators. It is working the same as before but now we don't have to manually pass our filter_nums function to log_time function to get the wrapper.


This was a very basic example. Now let's try to understand how Flask or other web frameworks uses decorators to map routes with function handlers.

app = Flask()


@app.route("home/")
def home(request):
    return "Home Page"

This is a basic flask code we all have used. let's see if we have to create our own web framework, then how we will map routes with the respective handlers using decorators.


class ABD:
    def __init__(self):
        self.routes = {}

    def route(self, path):
        if path in self.routes:
            raise Exception("route `{}` is already defined".format(path))

        def wrapper(handler):
            self.routes[path] = handler

        return wrapper


app = ABD()


@app.route("/home")
def home(request):
    return "Home Page"


@app.route("/about")
def about(request):
    return "About Page"


print(app.routes)

# output:
# {'/home': <function home at 0x7f8e97530048>, '/about': <function about at 0x7f8e975300d0>}

If you look into the above example you can see that now we are actually calling the decorator function by passing the path parameter whereas previously decorator was not being called like this. The effect of this is that previously our main function was being passed to the decorator but now we are calling the decorator with a string value and our main function is being passed to the wrapper that the decorator is returning.

Below is an example of how it's actually working behind the scenes:-

def home(request):
    return "Home Page"

def about(request):
    return "About Page"

app = ABD()
wrapper = app.route('/home')
wrapper(home)
wrapper = app.route('/about')
wrapper(about)
print(app.routes)

# output:
# {'/home': <function home at 0x7f5cc2a10e18>, '/about': <function about at 0x7f5cc2a1b048>}

This is what the decorator was doing. It calls app.route by passing the path parameter and then passing our handler to the wrapper returned by the handler.


Conclusion:-

I hope by these examples, you might have got an understanding that what actually happens when we use decorators. Our main function gets passed as an argument to the decorator function and when we call our function we basically call the wrapper function returned by the decorator.

If you have any questions or need any help then write them down in the comments or feel free to reach out to me.

Till then happy coding and have a great time ahead ๐Ÿ˜‡๏ธ.

ย