The world’s leading publication for data science, AI, and ML professionals.

Practical Python: Introduction to Decorators

How and when to use decorators explained with examples

Photo by Markus Spiske on Unsplash
Photo by Markus Spiske on Unsplash

Functions in Python are first-class objects which means they can be used as arguments for other functions. Decorators arise from this notion.

A decorator is a function that takes another function and extends its behavior. The base function is not modified. The decorator wraps it and add additional functionality.

Let’s try to associate it with a real life scenario. Consider you run 400 meters for your daily exercise. You running 400 meters is a function. If you want to measure the time it takes for you to finish 400 meters, you record the time before you start and after you finish your run. The time measurement can be considered as another function that adds behavior to the run function. This is basically how decorators work.

You may argue that why we do not implement the timing inside the run function. First of all, functions should be focused on one particular task. It is is the more appropriate and efficient way of using functions.

In addition to that, you may need to use the timing decorator to decorate other functions as well. For instance, you may need to time a different exercise. Without the decorators, you would have to implement the timing inside all the exercise functions.

We now have an idea of what decorators are used for. Let’s start on doing the examples.

Here is a simple greeting function.

def greeting():
    print("Hello there!")
greeting()
Hello there!

This function prints a greeting message. We want to decorate this function in a way that we do something before and after its execution.

def my_decorator(f):
    def wrapper():
        print("Something is done before the function")
        result = f()
        print("Something is done after the function")
        return result
    return wrapper

The my_decorator function takes a function as argument and decorates it with additional behavior. In this case, the additional behavior is just printing something before and after executing the function.

In other words, my_decorator is a function that extends the behavior of another function without modifying it which makes it a decorator.

We can now wrap the greeting function with my_decorator.

decorated_greeting = my_decorator(greeting)
decorated_greeting()
Something is done before the function 
Hello there! 
Something is done after the function

We have just done a simple decorating. However, there is nicer and cleaner way of doing it.

@my_decorator
def greeting():
    print("Hello there!")
greeting()
Something is done before the function 
Hello there! 
Something is done after the function

We can just put the decorator function with "@" sign right before the definition of the function we want to wrap.

The additional behavior in my_decorator seems to be useless. Let’s create a useful decorator. For instance, we can wrap a function with decorator that takes the time it takes to execute the function.

import time
def timing(f):
    def wrapper():
        start = time.time()
        result = f()
        print(f"The function took {time.time() - start} to execute")
        return result
    return wrapper

The timing is our decorator. In the next step, we will use it to wrap a function that multiplies three numbers.

@timing
def mult(a, b, c):
    """This function multiplies 3 numbers"""
    return a*b*c

Let’s call the mult function:

mult(2, 3, 4)
TypeError: wrapper() takes 0 positional arguments but 3 were given

We get a TypeError which is related to the function arguments. This is expected because the wrapper function does not have any idea about the arguments of the mult function. We can solve this problem by using *args and **kwargs as follows.

def timing(f):
    def wrapper(*args, **kwargs):
        start = time.time()
        result = f(*args, **kwargs)
        print(f"The function took {time.time() - start} to execute")
        return result
    return wrapper

The problem is solved. The mult function can be wrapped with the timing decorator now.

@timing
def mult(a, b, c):
    """This function multiplies 3 numbers"""
    return a*b*c
mult(2, 3, 4)
The function took 2.1457672119140625e-06 to execute
60

Docstrings are informative texts that tell us what a function does. There are various ways to access or view the docstring of a function. The doc method or help function can be used to view the docstring.

Let’s try it on the mult function we have just wrapped with the timing decorator.

help(mult)
Help on function wrapper in module __main__:  
wrapper(*args, **kwargs)

What help returns does not give us any particular information about the mult function. Instead, it returns the function used to wrap it which is called wrapper.

There is a built-in function of Python to overcome this issue which is the wraps function.


Wraps

Wraps is a built-in function under the functools module. It can be used as a decorator.

What the wraps function does is delivering extra information from the function f (argument) to the wrapper function. The docstring is a part of this extra information.

If we also the wraps function inside our decorator, the wrapper function will look like the wrapped function so we are able to access all the information about the wrapped function.

Here is how we can implement the wraps function.

from functools import wraps
def timing(f):
    @wraps
    def wrapper(*args, **kwargs):
        start = time.time()
        result = f(*args, **kwargs)
        print(f"The function took {time.time() - start} to execute")
        return result
    return wrapper
@timing
def mult(a, b, c):
    """This function multiplies 3 numbers"""
    return a*b*c

There is no change in terms of the execution:

mult(2,3,4)
The function took 2.1457672119140625e-06 to execute
24

However, we can access the docstring of the mult function now:

help(mult)
Help on function mult in module __main__:  
mult(a, b, c)     
This function multiplies 3 numbers

Conclusion

We have covered what decorators are and how they are useful. They allow adding extra behavior to a function without actually modifying it.

We can also implement the additional behavior inside the original function but it contradicts with the notion that a function should be focused on one task only.

Besides, using a decorator for the additional behavior makes it possible to use it on other functions as well.

There is more to cover about decorators such as stacking them. I will cover those topics with more examples in the next article.

Thank you for reading. Please let me know if you have any feedback.


Related Articles