Understanding python decorators with step-by-step examples

Bharat Kalluri / 2020-10-27T15:27:47+05:30

Decorators are used to add functionality to an existing code. They allow you to wrap a function within another function, thereby adding functionality. Let us look at some examples to get a sense of what decorators are. Before that, let us get some concepts cleared out about functions in python

Functions are first class objects in python

A function can be used like a value in python. For example

def printer():
    print("Printing")
a = printer
a()
# printing

Here we assigned the function to a variable and now the variable holds the function. Functions can also be passed around as arguments between functions (since they just act like values). Another popular example is map. The first argument of the function is a function (Callable) and the second argument of the function is an Iterable(List, tuple etc.). Then map runs over the Iterable and executes the function. Let us write our own simple map implementation

from typing import Callable, Iterable

def mapper(func: Callable, list_to_iterate: Iterable):
    return [func(el) for el in list_to_iterate]

def double(number: int):
    return number * 2
mapper(double, [1,2])
# [2, 4]

Functions can also be nested inside one another. For example

def outer_func():
    def inner_func():
        print("Inner func")
    print("Outer func")
outer_func()
# Outer func
# Inner func

Armed with this information, let us create our first decorator

A simple decorator

Let us create a function that counts the number of seconds a function took to execute. A very similar example to the mapperfunction.

from time import time, sleep
from typing import Callable

def timer_decorator(func: Callable) -> Callable:
    def wrapper():
        start_time = time()
    	func()
	    end_time = time()
    	print(end_time-start_time)
    return wrapper

def sleeper() -> None:
    sleep(5)

timed_sleeper: Callable = timer_decorator(sleeper)

timed_sleeper()
# 5.00

The above example pretty much combines everything we have learned up until now. The timer_decoratorreturns a wrapper function. The function starts up a timer, runs the input function, and then ends the timer and calculates the difference to print it out. sleeperjust sleeps for 5 seconds. When timer_decorator is called with sleeper as an argument. We get back a function which wraps the sleeper func with the time difference calculation logic. Now, if timed_sleepis called, it runs and prints out the number of seconds it took to run the sleeperfunc. Takes some time and make sure you understand what is happening. This is what decorators are. Python gives us some syntactic sugar over the same idea. Using decorators syntax, the sleeper function would look like this

@timer_decorator
def sleeper():
    sleep(5)

sleeper()
# 5.00

That is it! Decorators wrap a function and modify its functionality/behaviour

Decorating functions with arguments

The current code does not work well for functions with arguments, let us see why

from time import time, sleep
from typing import Callable

def timer_decorator(func: Callable) -> Callable:
    def wrapper():
        start_time = time()
    	func()
	    end_time = time()
    	print(end_time-start_time)
    return wrapper

@timer_decorator
def sleeper(sec: int):
    sleep(sec)

sleeper(5)

# ---> 16 sleeper(5)

# TypeError: wrapper() takes 0 positional arguments but 1 was given

The error is pretty clear, wrapper does not take any arguments. So now we should have a mechanism to cascade arguments to child functions. Python's *args and **kwargscome in handy here. Let us modify the decorator so that it cascades all arguments to child functions.

from time import time, sleep
from typing import Callable

def timer_decorator(func: Callable) -> Callable:
    def wrapper(*args, **kwargs):
        start_time = time()
    	func(*args, **kwargs)
	    end_time = time()
    	print(end_time-start_time)
    return wrapper

@timer_decorator
def sleeper(sec: int):
    sleep(sec)

sleeper(5)
# 5.00

And, it works as expected!

Returning values from decorated functions

Give it a thought, what would happen if sleeper were to return the number of seconds it was sleeping. Who would have the return value? According to the current implementation, the decorator is not giving back anything. We just call the funcbut are not returning anything from the wrapper function. The returns also need to cascade upwards so that the caller get back the expected value.

from time import time, sleep
from typing import Callable

def timer_decorator(func: Callable) -> Callable:
    def wrapper(*args, **kwargs):
        start_time = time()
    	ret_val = func(*args, **kwargs)
	    end_time = time()
    	print(end_time-start_time)
        return ret_val
    return wrapper

@timer_decorator
def sleeper(sec: int):
    sleep(sec)
    return sec

sleep_time = sleeper(5)
print(sleep_time)
# 5.00 (This print is from the decorator)
# 5 (This is from the print statement)

Passing arguments to decorators

What if we want to have a timer_decoratorthat takes in an argument, say in_minwhich can be true or false. And if true, then the time should be printed in minutes instead of seconds. Remember that the only requirement from a decorator is that it should return a function that takes in a function as an argument.

from time import time, sleep
from typing import Callable


def timer_decorator(in_min: bool = False):
    def decorator(func: Callable) -> Callable:
        def wrapper(*args, **kwargs):
            start_time = time()
            ret_val = func(*args, **kwargs)
            end_time = time()
            time_consumed = end_time - start_time
            print(time_consumed if in_min is False else time_consumed / 60)
            return ret_val
        return wrapper
    return decorator


@timer_decorator(in_min=True)
def sleeper(sec: int):
    sleep(sec)
    return sec


sleep_time = sleeper(5)
# 0.08336979548136393 (time consumed in minutes)

In this implementation, timer_decoratortakes in an argument and gives back a decorator (!). This decorator already has access to the variable in_min and will use it. This might be the most complicated example of the post. Take your time and make sure you understand everything clearly.

Practical use cases of decorators

  • Flask uses decorators for routing, authentication guards etc..: It makes the code much more clean and readable
  • Decorators can be used for profiling: Like the example we just wrote)
  • Retry decorator on certian exceptions: An @retrydecorator which takes in a list of exception types as arguments and retry's the function if the function exceptions out (Celery does this)

And many more...

Congratulations on coming this far! Decorators are quite tricky in the beggining, but once you understand them. They are really powerful and useful.

Further Reading

Hand crafted by Bharat Kalluri