Decorators in Python [Explained]

Decorators Featured Image

Let’s talk about a niche topic today – Decorators in Python. This is a simple but powerful tool that, as the name suggests, decorates functions.

Pre-Requisite knowledge

There are a few concepts we need to know before proceeding with Decorators in Python, namely, higher-order functions and nested functions – two sides of the same coin.

1. Higher-order functions

Python has a neat way of passing and returning functions to and from other functions. This is not supported in many programming languages and allows the programmer to do a range of versatile operations.

This is where the concept of higher-order functions comes from – any function that accepts or returns another function is called a higher-order function.

For example:

def hof(func, num):
    int res = func(num)
    return res

As you can notice, the first parameter of hof() is func, which is a function that is called later. In a similar manner, higher-order functions also return other functions.

Recommended read – Python recursive functions

2. Nested Functions

Another versatile feature that Python offers is it allows you to declare functions inside functions which are conveniently called nested functions.
Consider this example:

def func(num):
    def nested_func():
        return num
    return nested_func

Here, func() is a higher-order function because it returns another function, and nested_func() is a nested function (obviously) because it is defined inside another function.

You can see that the nested function’s definition entirely changes depending on what you send to the outer function.

This is used to implement encapsulation and create closures, something which is out of the scope of the current tutorial.

What are Decorators in Python?

As we discussed earlier, in layman’s terms, a decorator decorates functions. What this means is that a decorator wraps code or functionality around a function in order to enhance what the function does.

Let’s look at an example:

First we will look at an undecorated simple function that adds two numbers:

def sum(a, b):
    print(a + b)
Undecorated Function - Decorators in Python
A simple summation function that prints the result

Now imagine you are going to make a ton of these mathematical functions that take two numbers and perform some mathematical operation on them and print their result (see Python print)

Now let’s say you want to add one line before printing the result that tells what is being done and what numbers are being operated on. So the output looks something like this:

## sum of 1 and 2 ##
3

You can add this line while defining each function, but if there are too many functions and the decoration is much more than one line, it is better to use a decorator.

Syntax of a Python Decorator

def decorator(function):
    def wrapper(num1, num2):
        print("##", function.__name__, "of", num1, "and", num2, "##")
        function(num1, num2)
    return wrapper

Understanding this piece of code can be a little difficult, so we’ll go through this line by line:

  • def decorator(function): There are a few things to note here. Firstly, a decorator is defined as a function and behaves like a function. It’s best to think of it as a function. Second, and more importantly, the argument a decorator accepts is the function it is decorating. Note that the name of the decorator can be anything. A decorator can also accept multiple arguments, but that is a topic for another discussion.
  • def wrapper(num1, num2): This is probably the most confusing part of the code. A decorator must always return a function that has added some functionality to the original function. This is commonly referred to as a wrapper function. This new function will replace the original function, which is why it must accept exactly the same number of arguments the original function has (in this case two). So obviously, this decorator will not decorate a function that doesn’t have exactly two parameters, although there are ways to get around this using *args.
  • print(...): This, in our case, would be the functionality the decorator is adding to the original function. Note that we are printing the function name and the two arguments the exact same way we wanted to. After this, we need to execute the function so that the actual output is printed.
  • function(num1, num2): It is clear how wrapper() is doing the same thing as function(), but with added functionality, which is what we needed, so the next step is obvious.
  • return wrapper: So basically, decorator() took a function from us, wrapped some decoration around it using wrapper(), and then returned wrapper() which will replace the first function. Because wrapper() is calling the first function and doing additional stuff, it is basically an enhanced version of the first function.

The rest of this will be clear when we see how to use the decorator.

Using Decorators in Python

Now that we have defined a decorator by the name decorator, we will use it to enhance three functions – sum (which we saw earlier), difference, and product.

@decorator
def sum(a, b):
    print(a + b)

@decorator
def difference(a, b):
    print(a - b)

@decorator
def product(a, b):
    print(a * b)

Here, the symbol @ is used to tell Python that a decorator is being used on the next function.

So, after defining the function, it will essentially be passed to the decorator, which will return an enhanced version of it. Whatever function the decorator returns will replace the original function.

Let us look at the result:

Decorators in Python Output

Notice that calling sum() will execute its enhanced version.

Note: Using a decorator will disrupt the function’s metadata. In our example, calling sum.__name__ will return wrapper instead of sum because that is the function we’re essentially using. The docstring will also change depending on what docstring the wrapper has.

To avoid this, simply import wraps from functools and then decorate the wrapper inside the decorator like so:

from functools import wraps
def decorator(function):
    @wraps(function)
    def wrapper(num1, num2):
        print("##", function.__name__, "of", num1, "and", num2, "##")
        function(num1, num2)
    return wrapper

In this, the wrapper itself is decorated using the metadata of the function so that it retains function meta like __name__ and its docstring.

Conclusion

That was an in-depth explanation of how to use a decorator and what does the “@” sign does. Hope you learned something, and see you in another tutorial.

References – https://www.python.org/dev/peps/pep-0318/