10.5 Decorators in Python

In Python, decorators are a powerful and flexible tool that allow you to modify or enhance the behavior of functions or methods. A decorator is essentially a function that takes another function as an argument, adds some functionality to it, and returns a new function. Decorators are commonly used for cross-cutting concerns like logging, timing, memoization, and authorization, without modifying the original function’s code.

In this section, we will explore how decorators work, how to create and apply them, and some common use cases.


10.5.1 What is a Decorator?

A decorator is a higher-order function that takes a function (or method) as input, extends its behavior, and returns a modified version of that function. Decorators are often applied using the @decorator_name syntax, which makes them easy to use and read.

How Decorators Work:

  1. A decorator is a function that takes another function as an argument.
  2. It defines a new function that extends the behavior of the original function.
  3. It returns the new function, which can then be used in place of the original.

Basic Example: A Simple Decorator:

def my_decorator(func):
    def wrapper():
        print("Something before the function")
        func()
        print("Something after the function")
    return wrapper

# Applying the decorator manually
def say_hello():
    print("Hello!")

decorated_say_hello = my_decorator(say_hello)
decorated_say_hello()

Output:

Something before the function
Hello!
Something after the function

In this example:

  • my_decorator() takes the say_hello() function as input and extends its behavior by printing messages before and after the original function is called.
  • The decorated function decorated_say_hello() now has the additional behavior, while still calling say_hello() internally.

10.5.2 Applying a Decorator with the @ Syntax

Python provides a convenient syntax for applying decorators using the @ symbol. Instead of manually calling the decorator, you can apply it directly to the function.

Example: Applying a Decorator with @:

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

@my_decorator  # Applying the decorator
def say_hello():
    print("Hello!")

# Call the decorated function
say_hello()

Output:

Before the function
Hello!
After the function

In this example:

  • The @my_decorator syntax applies the decorator directly to the say_hello() function, so it is automatically wrapped when called.

10.5.3 Decorators with Arguments

Sometimes, you need to create decorators that can accept arguments. In this case, you can define a decorator factory that takes arguments and returns a decorator.

Example: Decorator with Arguments:

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

@repeat(3)  # The function will be repeated 3 times
def say_hello():
    print("Hello!")

# Call the decorated function
say_hello()

Output:

Hello!
Hello!
Hello!

In this example:

  • The repeat() function is a decorator factory that accepts the number of times the function should be repeated.
  • The decorator decorator() is applied to the say_hello() function, repeating it three times when called.

10.5.4 Decorating Functions with Arguments

If the function being decorated accepts arguments, you can modify the wrapper function to accept any number of positional and keyword arguments using *args and **kwargs.

Example: Decorating Functions with Arguments:

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

@my_decorator
def add(x, y):
    return x + y

# Call the decorated function
result = add(5, 3)
print(f"Result: {result}")

Output:

Before the function
After the function
Result: 8

In this example:

  • The decorator my_decorator() is applied to the add() function, which accepts two arguments.
  • The *args and **kwargs in the wrapper allow the decorator to handle functions with any number of arguments.

10.5.5 Common Use Cases for Decorators

Decorators are commonly used for a variety of tasks in Python programming. Below are some typical use cases:

1. Logging

Decorators can be used to automatically log the execution of functions.

def log_decorator(func):
    def wrapper(*args, **kwargs):
        print(f"Calling function {func.__name__} with arguments {args}, {kwargs}")
        result = func(*args, **kwargs)
        print(f"{func.__name__} returned {result}")
        return result
    return wrapper

@log_decorator
def add(x, y):
    return x + y

add(10, 20)

Output:

Calling function add with arguments (10, 20), {}
add returned 30

2. Timing Functions

You can use decorators to measure how long a function takes to execute.

import time

def timing_decorator(func):
    def wrapper(*args, **kwargs):
        start_time = time.time()
        result = func(*args, **kwargs)
        end_time = time.time()
        print(f"{func.__name__} took {end_time - start_time:.6f} seconds")
        return result
    return wrapper

@timing_decorator
def slow_function():
    time.sleep(1)  # Simulate a slow function

slow_function()

Output:

slow_function took 1.000124 seconds

3. Memoization (Caching)

Decorators can be used for memoization, which caches the results of expensive function calls to avoid redundant computations.

def memoize_decorator(func):
    cache = {}
    def wrapper(n):
        if n not in cache:
            cache[n] = func(n)
        return cache[n]
    return wrapper

@memoize_decorator
def fibonacci(n):
    if n <= 1:
        return n
    return fibonacci(n-1) + fibonacci(n-2)

print(fibonacci(10))  # Output: 55

In this example:

  • The decorator caches the results of fibonacci() to avoid recomputing the same values, speeding up subsequent calls.

10.5.6 Stacking Multiple Decorators

You can apply multiple decorators to a single function by stacking them. Each decorator is applied in sequence, starting from the innermost (bottom) to the outermost (top).

Example: Stacking Multiple Decorators:

def decorator1(func):
    def wrapper(*args, **kwargs):
        print("Decorator 1 before")
        result = func(*args, **kwargs)
        print("Decorator 1 after")
        return result
    return wrapper

def decorator2(func):
    def wrapper(*args, **kwargs):
        print("Decorator 2 before")
        result = func(*args, **kwargs)
        print("Decorator 2 after")
        return result
    return wrapper

@decorator1
@decorator2
def say_hello():
    print("Hello!")

# Call the function with stacked decorators
say_hello()

Output:

Decorator 1 before
Decorator 2 before
Hello!
Decorator 2 after
Decorator 1 after

In this example:

  • decorator2() is applied first (closer to the function), followed by decorator1(), and they execute in sequence.

10.5.7 Preserving Function Metadata with functools.wraps

When you apply a decorator to a function, the metadata of the original function (such as its name and docstring) can be lost because the decorator replaces the original function with the wrapper function. To preserve this metadata, you can use the functools.wraps() decorator.

Example: Preserving Metadata with functools.wraps:

from functools import wraps

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

@my_decorator
def greet(name):
    """Greets the person by name."""
    print(f"Hello, {name}!")

print(greet.__name__)  # Output: greet
print(greet.__doc__)   # Output: Greets the person by name.

In this example:

  • The **`@wraps(func

)** decorator ensures that the metadata of the **greet()`** function (like its name and docstring) is preserved when it’s decorated.


10.5.8 Summary

  • Decorators: A function that wraps another function to extend or modify its behavior.
  • @decorator Syntax: A convenient way to apply a decorator to a function or method.
  • Decorators with Arguments: You can create decorators that accept arguments by defining a decorator factory.
  • Common Use Cases: Logging, timing, memoization, and access control are common applications of decorators.
  • Stacking Decorators: You can apply multiple decorators to a function by stacking them.
  • functools.wraps: A decorator used to preserve the metadata of the original function when decorating it.

Decorators are a powerful tool for creating clean, reusable, and modular code in Python, enabling you to modify or extend the behavior of functions without altering their internal logic.