Decorators in Python [With Examples]

Decorators in Python [With Examples]

posted 11 min read

Decorators in Python [With Examples]

I. Introduction

Decorator seems to be a fancy terminology, rather it is a very powerful feature in Python, which can add functionality, or modify the behaviour of its functions, class methods without having to directly change the code and structure of the function or class itself. Moreover, the syntax for using decorators is extremely simple and convenient. In this tutorial, we will try to understand decorators, try to figure out how to create our own custom decorators, and discuss some common use cases like timing, logging and caching.

II. Understanding Decorators

Decorators in Python are functions that take functions or classes as arguments and modify or add /extend the functionality to these functions or methods of classes. Basically, they add wrappers around the function or classes.

Note: Functions and Classes in Python can be passed as arguments to other functions and returned from other functions

Syntax for applying decorators:

@decorator_name
def function_name()

This syntax is equivalent to the statement:

function_name = decorator_name(function_name)

So, basically when we are defining our function with @decorator_name applied to it, the decorator will modify the function definition itself. After that, when we call our function, we are essentially calling it's modified version and not the original function. So, the modified version of that function will be executed every time we call the function.

III. Function Decorators

Function decorators, as the name indicates are functions that take functions as arguments and returns a new modified function.

Well, let us suppose we have a function which requires a lot of time to process. Let us simulate this by making a very slow multiplier using a for loop nested inside another for loop. That’s a multiplication implementation that will definitely test our patience, but will definitely simulate an interesting test case to demonstrate function decorators.

def my_counter_multiplier(count1,count2):
    count = 0
    for i in range(count1):
        for j in range(count2):
            count += 1
    return count

Let us now run this multiplier for a large input twice for the same input.

print(my_counter_multiplier(10000,10000))
print(my_counter_multiplier(10000,10000))

Did you notice that both times this function is run it takes the same amount of large time. What should we do now. Let us try memoization.

def my_counter_multiplier_decorator(my_func):
    my_dict = {}

    def my_counter_multiplier_wrapper(*args):
        if args in my_dict.keys():
            return my_dict[args]
        else:
            my_dict[args] = my_func(*args)
            return my_dict[args]
    return my_counter_multiplier_wrapper

def my_counter_multiplier1(count1,count2):
    count = 0
    for i in range(count1):
        for j in range(count2):
            count += 1
    return count


Now we have created a function my_counter_multiplier_decorator which is a function that takes another function as its argument, adds a cache in the form of dictionary my_dict to it.
Now, let us use this function to add the functionality of caching to our function my_counter_multiplier1, without actually having to modify its code.
For this we will simply reassign it as:

my_counter_multiplier1 =  my_counter_multiplier_decorator(my_counter_multiplier1) 

This reassignment will modify the behaviour of our my_counter_multiplier1, to include caching capability.

Now, let us see if our outputs are being cached. Run the function for the same arguments twice. :

print(my_counter_multiplier1(10000,10003))
print(my_counter_multiplier1(10000,10003))

Well, we have actually created our own function decorator!!

Another way of achieving the same behaviour would be:

# Another way of defining the same:
@my_counter_multiplier_decorator
def my_counter_multiplier2(count1,count2):
    count = 0
    for i in range(count1):
        for j in range(count2):
            count += 1
    return count

Now, let us see if our outputs are being cached for this implementation. Run the function for the same arguments twice.

print(my_counter_multiplier2(10000,10000))
print(my_counter_multiplier2(10000,10000))

This second implementation is syntactically clean. Just by adding @decorator_name before a function name, will add the functionality corresponding to that decorator.

Now, when we run the function my_counter_multiplier1()or my_counter_multiplier2() with the same set of arguments twice:
Only the first time the output will be actually calculated, so the first print statement will take time to be seen on the console.
We will get cached outputs immediately for the second time the functions are run with the same arguments. So, we will be able to see the second output immediately once the first output is printed. Thus, the functionality of caching has been implemented using decorators.

IV. Class Method Decorators

Class Method Decorators are functions that operate on a method within a class.
Let us now modify the method of a class using Decorators.

class MyAlgorithms:
    def __init__(self, offset):
        self.offset = offset

    def add_with_offset(self, a, b):
        return a + b + self.offset

Here, we have defined a simple class which adds an offset to addition of two numbers. The offset, here is an attribute belonging to the instances of the class when defined. Let's create an instance of the class and execute its method:

Algo_instance = MyAlgorithms(4)
print(Algo_instance.add_with_offset(2, 5))

This should give a result of 11.
Now suppose, we decide to increase the offset by 8. Let us implement this using class decorators.
First, we define a new function where this extra offset of 8 is added.

def new_func(self, a, b):
    return a + b + self.offset + 8

Now, we will simply define a function, which takes a class as an argument, modifies its method and returns the class.

def class_decorator(my_class):
    my_class.add_with_offset = new_func
    return my_class 

Next, using the very convenient syntax of using decorators we will decorate our class.

@class_decorator
class MyAlgorithms1:
    def __init__(self, offset):
        self.offset = offset

    def add_with_offset(self, a, b):
        return a + b + self.offset

Now, we have 2 classes with identical internal structure, one decorated and the other undecorated.
Let us see the results when we execute their methods.

Algo_instance = MyAlgorithms(4)
print(Algo_instance.add_with_offset(2, 5))
Algo_instance1 = MyAlgorithms1(4)
print(Algo_instance1.add_with_offset(2, 5))

As, we can see we get different results. The decorated class, returns the result with the additional 8 offset.
Using this concept, decorators can be applied to complex class methods.

V. Decorating with Arguments

Now, using the same example above suppose instead of 8 we want to apply an arbitrary offset of offset_x. This can be achieved by modifying our decorator function to accept arguments. Let's see how:

def class_decorator_with_arg(offset_x):
    def new_func1(self, a, b):
        return a + b + self.offset + offset_x
    def class_decorator(my_class):
        my_class.add_with_offset = new_func1
        return my_class
    return class_decorator

@class_decorator_with_arg(30)
class MyAlgorithms2:
    def __init__(self, offset):
        self.offset = offset

    def add_with_offset(self, a, b):
        return a + b + self.offset

As, we can see here we just have to pass the argument to the decorator in round brackets () and we can easily test our functions with arbitrary offset values.

VI. Chaining Decorators

We can use multiple decorators on functions and methods to add functionality, or modify their behaviour.
It can be achieved using the following syntax:

@decorator1
@decorator2
def function_name()

Let us see a simple example

def decorator1(my_func):
    print("modifying function using decorator 1")
    def wrapper1(*args, **kwargs):
        print("executing wrapper 1")
        my_func(*args, **kwargs)
    return wrapper1


def decorator2(my_func):
    print("modifying function using decorator 2")
    def wrapper2(*args, **kwargs):
        print("executing wrapper 2")
        my_func(*args, **kwargs)
    return wrapper2

@decorator1
@decorator2
def my_print():
    print("hello from print function")

When we define the function the decorator modifies the function in the order, first by decorator 2 and then by decorator 1.

On executing my_print() function we are actually executing wrapper 1 then wrapper 2

VII. Decorating Built-in Functions and Methods

We can even decorate the Built-in-Functions and methods in Python to modify or enhance their functionality. Let's see this with a simple example. Suppose we want that the instances of a Book class be printed in a more readable format. To do this we can modify the built-in __str__ method of the class.

def my_str_decorator(class_name):
    def new_str_func(self):
        return f"Title: {self.title}, Subject: {self.subject}"

    class_name.__str__ = new_str_func 
    return class_name

@my_str_decorator
class Book:
    def __init__(self, title, subject):
        self.title = title
        self.subject = subject

B1 = Book("Python Decorators", "Programming")
print(B1)


By decorating the built-in-method of the class, we are able to improve the understanding about its instances when we use print() as it calls the __str__ method. This is one use-case of modifying the built-in methods for custom functionality.

VIII. Defining Custom Decorators

We have already built some custom decorators, like my_counter_multiplier_decorator which is a function decorator, class_decorator which we used to modify the method of a class, my_str_decorator to modify the built-in method of a class.
While defining a decorator, it is always beneficial to make them as generic as possible, so that they can be used for other functions as well.

IX. Practical Use Cases

Decorators are used to implement common behaviours like logging, caching, timing and authentication in Python. There are some decorators already available in Python for the same.
Let's look at some examples:

Caching:

The decorator cache can be used to provide the functionality of caching to our functions. It can be imported from the functools module in Python.

from functools import cache
@cache
def my_counter_multiplier(count1,count2):
    count = 0
    for i in range(count1):
        for j in range(count2):
            count += 1
    return count

Logging:

Another common use case of decorators is Logging. We can import the logging module, configure some basic settings like the log file name, type of logging, and the actual logging information. Using decorators, we can modify our function to always log this information.

import logging

def my_logging_decorator(func):
    def my_logging_wrapper(*args, **kwargs):
        logging.basicConfig(filename = f"{func.__name__}.log", level=logging.INFO)
        logging.info(f"Function {func.__name__} called with arguments: {args}, {kwargs}")
        result = func(*args, **kwargs)
        logging.info(f"Function {func.__name__} returned: {result}")
        return result
    return my_logging_wrapper

@my_logging_decorator
def my_addition(a,b,offset):
    return a + b + offset

my_addition(2, 3, offset=3)

A log file my_addition.log will be created which will log the information related to the function, its arguments and return value.

Authentication:

One of the widely used decorators in Django (which is a Python based web framework) is the login_required decorator, which is essentially used to ensure, that a view is only accessible to users who have logged in to a website.

 from django.contrib.auth.decorators import login_required
@login_required(redirect_field_name="my_redirect_field")
def my_view(request): ... 

https://docs.djangoproject.com/en/5.0/topics/auth/default/#the-login-required-decorator

Timing:

Sometimes, we need to time our functions to assess their efficiency. To do so, we can use the time module of python to provide us with the current time. We can define our decorator which will wrap the function in another wrapper function which first records the start time, then runs our function and then again records the stop time. It then prints the time difference and returns the result of the original function.

import time

def timer(func):
    def wrapper(*args, **kwargs):
        start_time = time.time()
        result = func(*args, **kwargs)
        end_time = time.time()
        print(f"'{func.__name__}' function took a total of {end_time - start_time} seconds to execute.")
        return result
    return wrapper

@timer
def count(n):
    init = 0
    for i in range(n):
        for j in range(n):
            init +=1
    return init

print(count(10001))

Using this kind of implementation of decorators, we will lose the metadata of the original function like its docstring and name.

Preserving the Metadata of functions while using decorators:

If the metadata of a function, like the function name, its docstring are important, then we can use a wraps decorator from functools module. This will ensure that the metadata of our original function is preserved. Let us see this for the timing decorator implementation.

import time
from functools import wraps
def timer(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        start_time = time.time()
        result = func(*args, **kwargs)
        end_time = time.time()
        print(f"'{func.__name__}' function took a total of {end_time - start_time} seconds to execute.")
        return result
    return wrapper

@timer
def count(n):
    """ this is the doc string of the count function """
    init = 0
    for i in range(n):
        for j in range(n):
            init +=1
    return init

Now if we try to access the metadata of the original function, we will still get it.

print(count.__name__)
print(count.__doc__)

Tip: functools.wraps decorator preserves Metadata of functions

X. Best Practices and Considerations

  • We should try to use descriptive names for our decorators, using the naming conventions in Python which clearly describe their functionality.
  • Using decorators means additional function calls, which will lead to slight performance overhead. If we have a Performance critical use-case, we need to use decorators wisely.
  • If we want our function to have a side-effect like print statement, then using a decorator like cache might not be suitable, because we will directly get the cached return value of the function.
  • While nesting decorators, we should thoroughly understand the order in which the decorator will be applied to our functions, methods. Itis very easy to mess up with the functionality of our function while chaining decorators if the order is not correct.

XI. Conclusion

Decorators is an advanced concept in Python which lets us reuse a function to add functionality to various other functions and methods without much effort. The same functionality can be applied to different functions, methods with a very user-friendly syntax using the same decorator. The key is to understand how passing a function, or a class to another function can have such a powerful impact to achieve added/modified functionality without having to internally modify the function or class itself. Python offers a whole lot of inbuilt decorators which I would encourage you to explore. I hope this tutorial has given us a good overview of the power of decorators and we are now ready to create our own custom decorators.

If you read this far, tweet to the author to show them you care. Tweet a Thanks

More Posts

Multithreading and Multiprocessing Guide in Python

Abdul Daim - Jun 7, 2024

Mastering Lambda Functions in Python: A comprehensive Guide

Abdul Daim - May 6, 2024

Regular Expressions in Python

aditi-coding - Apr 5, 2024

Mastering Data Visualization with Matplotlib in Python

Muzzamil Abbas - Apr 18, 2024

Testing in Python: Writing Test Cases with unittest

Abdul Daim - Apr 8, 2024
chevron_left