Decorators and Advanced Usage

advanced
Published

December 26, 2024

Understanding the Basics

At its core, a decorator is a function that takes another function as input and returns a modified version of that function. This modification can involve adding functionality before, after, or around the original function’s execution.

Let’s illustrate with a simple example:

def my_decorator(func):
    def wrapper():
        print("Before function execution")
        func()
        print("After function execution")
    return wrapper

@my_decorator
def say_hello():
    print("Hello!")

say_hello()

Output:

Before function execution
Hello!
After function execution

Here, my_decorator is our decorator. It wraps say_hello, adding print statements before and after its execution. The @ syntax is syntactic sugar for say_hello = my_decorator(say_hello).

Decorators with Arguments

Things get more interesting when the function being decorated accepts arguments. We need to ensure the wrapper function handles these arguments correctly:

def my_decorator(func):
    def wrapper(*args, **kwargs):
        print("Before function execution")
        result = func(*args, **kwargs)
        print("After function execution")
        return result
    return wrapper

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

greet("World")

Output:

Before function execution
Hello, World!
After function execution

The *args and **kwargs allow the wrapper to accept any number of positional and keyword arguments, passing them transparently to the decorated function.

Decorators with Parameters

We can also create decorators that take their own parameters:

def repeat(num_times):
    def decorator_repeat(func):
        def wrapper(*args, **kwargs):
            for _ in range(num_times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator_repeat

@repeat(num_times=3)
def print_message(message):
    print(message)

print_message("Hello!")

Output:

Hello!
Hello!
Hello!

This example shows a decorator factory (repeat) that creates a decorator based on the provided num_times parameter.

Class-Based Decorators

Decorators can also be implemented using classes:

class CountCalls:
    def __init__(self, func):
        self.func = func
        self.num_calls = 0

    def __call__(self, *args, **kwargs):
        self.num_calls += 1
        print(f"Call {self.num_calls} to {self.func.__name__}")
        return self.func(*args, **kwargs)

@CountCalls
def say_hello_again():
    print("Hello again!")

say_hello_again()
say_hello_again()

Output:

Call 1 to say_hello_again
Hello again!
Call 2 to say_hello_again
Hello again!

The __call__ method allows the instance of the CountCalls class to behave like a function.

Nested Decorators

You can even apply multiple decorators to a single function:

def bold_decorator(func):
    def wrapper(*args, **kwargs):
        return f"<b>{func(*args, **kwargs)}</b>"
    return wrapper

def italic_decorator(func):
    def wrapper(*args, **kwargs):
        return f"<i>{func(*args, **kwargs)}</i>"
    return wrapper

@bold_decorator
@italic_decorator
def get_message():
    return "Hello, world!"

print(get_message())

Output:

<b><i>Hello, world!</i></b>

The decorators are applied in the order they are listed, from the bottom up.

Using functools.wraps

When creating decorators, it’s crucial to preserve metadata of the original function using functools.wraps. This maintains the function’s name, docstring, and other attributes.

import functools

def my_decorator(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print("Before function execution")
        result = func(*args, **kwargs)
        print("After function execution")
        return result
    return wrapper

@my_decorator
def improved_greet(name):
    """Greets the person passed in as a parameter."""
    print(f"Hello, {name}!")

print(improved_greet.__name__) # Output: improved_greet
print(improved_greet.__doc__) # Output: Greets the person passed in as a parameter.

Without functools.wraps, improved_greet.__name__ would be wrapper, losing the original function’s identity.