How to Use Decorators in Python?

Photo by Andrew Neel on Unsplash

How to Use Decorators in Python?

Decorators in Python are a powerful tool that allows you to modify the behavior of functions and classes. In this tutorial, we'll take a deeper look at advanced decorators and how they can be used to solve common programming challenges.

This tutorial will talk about the different kinds of decorators, decorator chaining, and some common useful decorators

Decorator Functions

A decorator function takes a function as an argument and returns a modified version of it. It is used to modify the behavior of the original function.

Here's an example of a simple decorator function

# The decorator function adds two numbers to the result of the original function
def add_two(func):
    def wrapper(*args, **kwargs):
        # Original Function is invoked
        result = func(*args, **kwargs)
        # Result of original function is modified by adding 2
        return result + 2
    return wrapper

@add_two
def multiply(a, b):
    return a * b

@add_two
def divide(a,b):
  return int(a/b)

print(multiply(3, 4)) # 14
print(divide(10,2)) #7

In this example, the add_two decorator function takes the multiply function as an argument and returns the wrapper function, which adds two to the result of multiply. The @add_two syntax before the multiply function is a shorthand for the following:

def multiply(a, b):
    return a * b

multiply = add_two(multiply)

Since we do not know the number of parameters the original function might have we use args and kwargs

Decorator Classes

In addition to decorator functions, you can also use decorator classes to modify the behavior of functions and classes.

Here's an example of a simple decorator class that adds two numbers to the result of the original function:

class AddTwo:
    def __init__(self, func):
        self.func = func

    def __call__(self, *args, **kwargs):
        result = self.func(*args, **kwargs)
        return result + 2

@AddTwo
def multiply(a, b):
    return a * b

@AddTwo
def divide(a,b):
  return int(a/b)

print(multiply(3, 4)) # 14
print(divide(10,2)) #7

__init__ and __call__ are called dunder methods or magic methods

In this example, the AddTwo decorator class takes the multiply function as an argument and returns a callable object (an instance of AddTwo) that adds two to the result of multiply. The @AddTwo syntax before the multiply function is a shorthand for the following

def multiply(a, b):
    return a * b

multiply = AddTwo(multiply)

Parametrized Decorators

So far, we've looked at simple decorators that modify the behavior of a function in a fixed way. However, you can also create parametrized decorators that take parameters to control their behavior.

Here's an example of a parametrized decorator function that adds a specified number to the result of the original function:

def add_n(n):
    def decorator(func):
        def wrapper(*args, **kwargs):
            result = func(*args, **kwargs)
            return result + n
        return wrapper
    return decorator

@add_n(10)
def multiply(a, b):
    return a * b

@add_n(12)
def divide(a,b):
  return int(a/b)

print(multiply(3, 4)) # 22
print(divide(10,2)) # 17

In this example, the add_n decorator function takes a parameter n and returns a decorator function decorator that adds n to the result of the original function. The @add_n(10) syntax before the multiply function is a shorthand for the following:

def multiply(a, b):
    return a * b

multiply = add_n(10)(multiply)

Below is an image comparing our first decorator add_two and the parameterized decorator add_n

Decorators with parameters in Python

  • Before we were wrapping the original function and returning the wrapper

  • Now, we are wrapping the decorator (that wraps the original function) and returning the decorator

Decorating Classes

In our previous examples, we looked at how to decorate functions. But you can decorate classes as well

Here's an example of a simple class decorator that adds a class attribute n to the decorated class:

def add_attribute(n):
    def class_decorator(cls):
        cls.n = n
        return cls
    return class_decorator

@add_attribute(10)
class MyClass:
    pass

print(MyClass.n) # 10

In this example, the add_attribute decorator function takes a parameter n and returns a class decorator function class_decorator that adds the class attribute n to the decorated class. The @add_attribute(10) syntax before the MyClass class is a shorthand for the following:

class MyClass:
    pass

MyClass = add_attribute(10)(MyClass)

Below is another example fo decorating classes

def add_method(cls):
    def new_method(self):
        return "Hello, World!"
    setattr(cls, 'new_method', new_method)
    return cls

@add_method
class MyClass:
    pass

my_obj = MyClass()
print(my_obj.new_method()) # Hello, World

It simply adds new_method to every instance of MyClass

Decorator Chaining

One of the most powerful features of decorators is the ability to chain them together to modify the behavior of a function or class in multiple ways. Remember, a decorator simply returns a modified function. This modified function can be passed to another decorator to further modify it.

Here's an example of how to chain two decorators to modify the behavior of a function:

# Adds n to the result of original function
def add_n(n):
    def decorator(func):
        def wrapper(*args, **kwargs):
            result = func(*args, **kwargs)
            return result + n
        return wrapper
    return decorator

# Multiplies result of original function by n
def multiply_by_m(m):
    def decorator(func):
        def wrapper(*args, **kwargs):
            result = func(*args, **kwargs)
            return result * m
        return wrapper
    return decorator

@add_n(10)
@multiply_by_m(5)
def multiply(a, b):
    return a * b

@multiply_by_m(5)
@add_n(12)
def divide(a,b):
  return int(a/b)

print(multiply(3, 4)) # 70
print(divide(10, 2)) #85

We have two parameterized decorators to multiply and add. The order of applications of the decorators chained is from bottom to top. I.e the last decorator is applied first and the first decorator is applied last.

In this example, the multiply function is decorated by both the add_n(10) and multiply_by_m(5) decorators. The multiply_by_m(5) decorator is applied first, followed by the add_n(10) decorator.

In the second case, however, The add_n(12) decorator is applied first and multiply_by_m(5) is applied second.

Use Cases of Decorators

1. Timing Decorator

A timing decorator can be used to measure the time taken by a function to execute. Here's an example:

import time

def timing_decorator(func):
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        end = time.time()
        print(f"Time taken: {end - start:.2f} seconds")
        return result
    return wrapper

@timing_decorator
def long_running_function():
    time.sleep(3)

long_running_function()

In this example, the timing_decorator decorator adds timing information to the long_running_function function. When the long_running_function is executed, the wrapper function provided by the decorator measures the time taken for the function to execute and prints the result.

2. Authorization Decorator

An authorization decorator can be used to enforce access control on a function. Here's an example:

def authorized(func):
    def wrapper(*args, **kwargs):
        user = get_current_user()
        if not user.is_authenticated():
            raise Exception("Unauthorized")
        return func(*args, **kwargs)
    return wrapper

@authorized
def restricted_function():
    print("Access granted")

In this example, the authorized decorator adds an authorization check to the restricted_function function. When the restricted_function is executed, the wrapper function provided by the decorator retrieves the current user and checks if the user is authenticated. If the user is not authenticated, the wrapper raises an exception.

3. Logging Decorator

A logging decorator can be used to log the execution of a function. Here's an example:

def logging_decorator(func):
    def wrapper(*args, **kwargs):
        print(f"Calling function: {func.__name__}")
        result = func(*args, **kwargs)
        print(f"Returning from function: {func.__name__}")
        return result
    return wrapper

@logging_decorator
def some_function(a, b):
    return a + b

print(some_function(3, 4)) # 7

In this example, the logging_decorator decorator adds logging information to the some_function function. When the some_function is executed, the wrapper function provided by the decorator logs the function name before and after the function is executed.

We can chain this with the timing decorator

@logging_decorator
@timing_decorator
def long_running_function():
    time.sleep(3)

'''
Calling function: wrapper
Time taken: 3.00 seconds
Returning from function: wrapper
'''
long_running_function()

4. Cache Decorator

A cache decorator can be used to cache the results of a function for faster access. Here's an example:

def cache_decorator(func):
    cache = {}
    def wrapper(*args, **kwargs):
        key = f"{args[0]}"
        print(cache)
        if key not in cache:
            print("Calling function")
            cache[key] = func(*args, **kwargs)
        else:
            print("Getting value from cache")
        return cache[key]
    return wrapper

@cache_decorator
def expensive_function(n):
    result = 0
    for i in range(n):
        result += i
    return result

'''
{}
Calling function
4950
{'100': 4950}
Getting value from cache
4950
'''
print(expensive_function(100)) # 4950
print(expensive_function(100)) # 4950

In this example, the cache_decorator decorator adds a cache to the expensive_function function. When the expensive_function is executed, the wrapper function provided by the decorator checks if the result is already present in the cache. If it is, the cached result is returned, otherwise the function is executed and the result is added to the cache.

Notice how the cache a dictionary is still able to persist between calls to the decorated function because the wrapper function created by the decorator retains a reference to the cache dictionary. This allows the wrapper function to access and modify the cache dictionary.

If a new function is decorated with the cache decorator, the wrapper function will have a reference to a new empty cache dictionary

'''
{}
Calling function
100
'''
print(outputResult(100)) 

'''
{'100': 4950}
Getting value from cache
4950
'''
print(expensive_function(100))

5. Singleton Class Decorator

A singleton class decorator can be used to ensure that only one instance of a class is created. Here's an example:

def singleton(cls):
    instances = {}
    def get_instance(*args, **kwargs):
        if cls not in instances:
            instances[cls] = cls(*args, **kwargs)
        return instances[cls]
    return get_instance

@singleton
class MyClass:
    pass

obj1 = MyClass()
obj2 = MyClass()
obj3 = MyClass()
print(obj1 is obj2) # True
print(obj2 is obj3) # True
print(obj1 is obj3) # True

In this example, the singleton decorator is used to decorate the MyClass class. The decorator works by creating a get_instance function that stores an instance of the class in a dictionary. When the get_instance function is called, it checks if an instance of the class already exists and returns it if it does, otherwise, it creates a new instance of the class and adds it to the dictionary.

Conclusion

In conclusion, decorators are a powerful feature in Python that allow you to add functionality to a function without modifying its original code. Decorators can be used to add caching, logging, validation, and other functionality to functions in a modular and reusable way.

In this tutorial, we learned how to define and use decorators in Python, including how to define decorator functions and decorator classes, and how to use decorators to add caching to a function.

If you'd like a more in-depth tutorial about decorators, you should watch this awesome Pycon Talk

Overall, decorators are a valuable tool in your Python toolbox, and mastering decorators can help you write more modular, reusable, and maintainable code


This article was originally published on RealPythonProject.com on March 4th,2023.