When writing code, you’ll sometimes need to extend a function without actually modifying its code directly. In this situation, decorators can help out.

A decorator is a special type of function that wraps around another function. They let you add extra code before and after the original function, providing an elegant way to extend existing functions.

How Do Decorators Work?

Decorated boxes
Pixabay -- no attribution required Link

Python functions are first-class citizens, which means you can assign them to variables, pass them as arguments to other functions, and return them from functions. These properties facilitate the creation of decorators.

A decorator function returns another function. It acts as a meta-function that adds extra functionality to a target function. When you decorate a function, Python calls the decorator function and passes the target function as an argument.

The decorator function can perform any necessary actions before and after calling the target function. It can also modify the target function's behavior by returning a new function or replacing the original function altogether.

Creating a Decorator Function

To create a decorator, define a function that takes a target function as an argument and returns a wrapper function. The wrapper function contains the additional functionality and a call to the target function.

 def decorator_func(target_func):
    def wrapper_func():
        # Action before calling target_func
        # ...

        # Call the target function
        target_func()
        
        # Action after calling target_func
        # ...

    return wrapper_func

Here, decorator_func takes target_func as an argument. It returns wrapper_func which calls target_func and contains the additional functionality.

Decorating a Function With the Decorator

To apply the decorator to a target function, use the @ syntax and place the decorator directly above the target function definition.

 @decorator_func
def my_function():
    # Function implementation
    pass

Here, the decorator_func decorates my_function. So, whenever you use my_function, Python calls decorator_func and passes my_function as an argument.

Notice that the decorator function does not use open and closing parentheses.

For clarity, the @ syntax is more like syntactic sugar. You can pass the target function directly to the decorator function as an argument:

 func = decorator_func(my_function)

Handling Function Arguments Within Decorators

To handle function arguments within decorators, use *args and **kwargs in the wrapper function. These allow the decorator to accept any number of positional and keyword arguments.

 def decorator_func(target_func):
    def wrapper_func(*args, **kwargs):
        # before calling target_func with args and kwargs
        # ...

        # Call the target function with arguments
        result = target_func(*args, **kwargs)

        # after calling target_func
        # ...
        return result

    return wrapper_func

The *args and **kwargs within wrapper_func ensure that the decorator can handle any arguments you pass to the target function.

Decorators With Arguments

Decorators can also take arguments of their own. This provides you with a more flexible way to decorate functions.

 def my_decorator(num_times):
    def wrapper(func):
        def inner(*args, **kwargs):
            for _ in range(num_times):
                func(*args, **kwargs)
        return inner
    return wrapper

@my_decorator(2)
def greet(name):
    print(f"Hello, {name}.")

The my_decorator function takes a number (2) as an argument and returns a wrapper function. That function returns an inner function that calls the decorated function (greet, in this case) a certain number of times (2). So that:

 greet("Prince")

Will print "Hello, Prince" twice.

Practical Uses of Decorators

You can use decorators for a wide range of purposes, such as logging, caching, input validation, and performance optimization.

Logging Decorator

You can use a logging decorator to log information about the execution of a function. This can help debug or monitor the behavior of specific functions.

 import logging

def my_logger(func):
    def wrapper(*args, **kwargs):
        logging.info("Entering function: %s", func.__name__)
        result = func(*args, **kwargs)
        logging.info("Exiting function: %s", func.__name__)
        return result

    return wrapper

Timing Decorator

You can use a timing decorator to measure the execution time of a function execution. It's useful for profiling or optimizing code.

 import time

def timing_decorator(target_func):
    def wrapper_func(*args, **kwargs):
        start_time = time.time()
        result = target_func(*args, **kwargs)
        end_time = time.time()
        total = end_time - start_time
        print(f"Function {target_func.__name__} took {total} seconds to run")
        return result

    return wrapper_func

Apply the decorators:

 @my_logger
@timing_decorator
def bg_task(n):
    print(f"Background Task, {n}.")
    return "Result !!!"

if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO)
    print(bg_task("CPU is Dancing"))

You'll now see debug when the function runs and a summary of how long it takes.

logging and timing decorator output
Screenshot by Princewill Inyang
 

Authorization Decorator

You can use an authorization decorator to check if a user has the necessary permissions to access a specific function. Most web frameworks use this feature to add a layer of security. A common example is Django’s @login_required decorator.

 def authorization_decorator(target_func):
    def wrapper_func(*args, **kwargs):
        if is_user_authorized():
            result = target_func(*args, **kwargs)
            return result
        else:
            raise PermissionError("Unauthorized access")

    return wrapper_func

These examples illustrate some common use cases for decorators. You can create decorators for various purposes, depending on your specific requirements.

Advanced Decorator Concepts

Decorators are easy to implement and use. But they are also powerful and can provide even more flexibility and control.

Class-Based Decorators

In addition to function-based decorators, Python also supports class-based decorators.

A class-based decorator is a class that defines a __call__ magic method, allowing you to use the class as a decorator.

 class DecoratorClass:
    def __init__(self, target_func):
        self.target_func = target_func

    def __call__(self, *args, **kwargs):
        # Decorator implementation
        print("Calling function")
        result = self.target_func(*args, **kwargs)
        print("Function called")
        return result

This example defines a decorator class. When you decorate a function with DecoratorClass, Python invokes the __call__ magic method, which applies the decorator's functionality to the target function.

Class-based decorators can maintain state across multiple calls by storing information in instance variables. This allows you more advance and flexible decorator implementations.

Nesting and Chaining Decorators

Suppose you need to combine different types of functionality into a single unit for related actions on a function. You can achieve this by applying multiple decorators to a single function.

 def decorator1(func):
    def wrapper(*args, **kwargs):
        print("Decorator1...")
        return func(*args, **kwargs)

    return wrapper

def decorator2(func):
    def wrapper(*args, **kwargs):
        print("Decorator2...")
        return func(*args, **kwargs)

    return wrapper

@decorator1
@decorator2
def my_function(n):
    print(n)

Here, both decorator1 and decorator2 decorate my_function. Whenever you use my_function, the functionality of decorator1 applies first, then that of decorator2.

A common example is when you need to apply a post-only request and login-required functionality to a Django view.

The Benefits of Decorators

Decorators offer several advantages that make them essential in many Python projects:

  • Code Reusability: Promotes code reusability by separating cross-cutting concerns from core logic.
  • DRY Principle: Adheres to the Don't Repeat Yourself principle by applying the same functionality to multiple functions without code duplication.
  • Readability and Maintainability: Enhances code readability and maintainability by keeping the main functionality focused and clear.
  • Pythonic Style: Aligns with Pythonic style by emphasizing clean, expressive, and readable code.

For these reasons, you should take advantage of decorators where possible.

Best Practices for Using Decorators

When working with decorators you should follow certain best practices:

  • Keep the decorator's functionality focused on a specific concern, such as logging or caching. This promotes modular code and makes decorators more versatile.
  • Make decorators configurable by using parameters and avoiding hard-coded values. This allows you to customize the behavior of the decorator when applying it to different functions.
  • Provide a clear and concise description of the decorator's purpose, its parameters (if any), and the expected behavior when applied to a function.
  • If the decorator has any side effects, such as modifying the function's behavior or introducing additional dependencies, clearly document these effects for future reference.
  • Write unit tests for your decorators to ensure they behave as you expect. Test different scenarios and edge cases to cover a wide range of situations.

Working With Functions in Python

Decorators are one of the core areas in which functions excel. In general Python functions are a fundamental building block for organizing and reusing code. They enable you to encapsulate functionality, pass data, and return results.

Understanding how to define and use functions is essential for effective Python programming.