Decorators in Python

Function decorators

Decorator is a basic structure design that allows programmers to expand and alter the behavior of a function, a method, or a class without changing their code.

The principle thought is that we place those callable objects, the functionality of which we have to change, inside a different object with new behavior. In this way, decorators are only wrappers around the initial objects.

Mostly, we use them to pass a function as an argument to a decorator to call this function later and perform certain activities before and after the call.

Syntax

In Python, the standard syntax for decorators is the @ sign preceding the name of a decorator, and afterward the object we need to decorate on the next line with the same indentation.

Decorators are called immediately before the body of a function, the behavior of which we might want to change. Here is an example of how the overall structure should resemble:

@decorator_function
def func():
    .....

Now, to better understand how it works, let’s see how to write our own simple decorator.

def our_decorator(other_func):
    def wrapper(args_for_function):
        print('This happens before we call the function')
        return other_func(args_for_function)
 
    return wrapper

Here we define the function our_decorator: it accepts another function as its argument and contains a wrapper that prints a message and calls the function that we’ve passed to our_decorator. Then we return this wrapper function that contains our changed one.

Presently, we define a function greet utilizing our_decorator:

@our_decorator
def greet(name):
    print('Hello,', name)

On calling greet the output will be:

greet('Susan')

#output
#This happens before we call the function
#Hello, Susan

However, not always do you need to write your decorators, sometimes you can just use decorators from the Python standard library.

Chaining decorators in Python

Multiple decorators can be nested in Python.

This is to state, a function can be decorated multiple times with various (or same) decorators. We basically place the decorators over the desired function.

def mod(func):
    def inner(*args, **kwargs):
        print("%" * 30)
        func(*args, **kwargs)
        print("%" * 30)
    return inner


def dash(func):
    def inner(*args, **kwargs):
        print("-" * 30)
        func(*args, **kwargs)
        print("-" * 30)
    return inner


@mod
@dash
def printer(msg):
    print(msg)


printer("Welcome")

#output
#%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
#------------------------------
#Welcome
#------------------------------
#%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%

The above syntax of,

@mod
@dash
def printer(msg):
    print(msg)

is equivalent to

def printer(msg):
    print(msg)
printer = mod(dash(printer))

The order in which we chain decorators matter. If we had reversed the order as

@dash
@mod
def printer(msg):
    print(msg)

The output will be

#output
#------------------------------
#%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
#Welcome
#%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
#------------------------------

Why use decorators?

The most significant purpose behind utilizing decorators is that they are a method of making your code increasingly more readable and clean.

Imagine that we have a set of functions. We need to measure, for example, how long it takes for every one of them to perform the algorithms so we include a timer in each code block:

import time
 
def func1(args_for_function):
    start = time.time()  # gets the current time
    ...                  # something happens here
    end = time.time()
    print('func1 takes', end - start, 'seconds')
 
 
def func2(args_for_function):
    start = time.time()
    ...
    end = time.time()
    print('func2 takes', end - start, 'seconds')

Although, when it is done, the two after issues may arise:

  • Specific lines would show up and be repeated in each function: the ones with start and end in our case
  • These lines would be repetitive to the actual functionality and the initial code.

These issues can be solved with a different reusable example that may then be applied to some other function. For our situation, we can make it like this:

def timer(func):
    def wrapper(args_for_function):
        start = time.time()
        func(args_for_function)
        end = time.time()
        print('func takes', end - start, 'seconds')
 
    return wrapper
 
 
@timer
def func1(args_for_function):
    ...  # define your function

In the example above, we have written a function decorator timer() that takes any function as an argument, notes the time, invokes the function, notes the time again, and prints how much time it took. Thus, we can use this decorator for any function later on and there will be no need to modify the code of the functions itself.

Python decorator handling exception

The following code will print the value of an array with respect to its index.

array = ['hey', 'hi', 'hello']
def valueOf(index):
   print(array[index])
valueOf(0)

#output
#hey

But what if we call the function for,

valueOf(10)

#output
#IndexError: list index out of range

As we haven’t handled whether the given index value exceeds the size of the array or not. So now we will decorate this function as follows:

array = ['hey', 'hi', 'hello']


def decorator(func):
    def newValueOf(pos):
        if pos >= len(array):
            print("Sorry! Array index is out of range")
            return
        func(pos)

    return newValueOf


@decorator
def valueOf(index):
    print(array[index])


valueOf(10)

#output
#Sorry! Array index is out of range

Conclusion

Now, we should go over the primary points we have learned in this topic:

  • Decorators permit us to change the behavior of the object without changing its source code
  • They are introduced with the @ symbol directly before the function, the function of which we need to modify
  • To make custom decorators, we have to specify a decorator function that will return a wrapper over the given function.