Python tutorials > Advanced Python Concepts > Decorators > What are decorators?

What are decorators?

Decorators are a powerful and expressive feature in Python that allows you to modify or enhance the behavior of functions or methods. They provide a way to wrap functions with additional functionality without permanently modifying their original code. In essence, a decorator is syntactic sugar that simplifies the process of applying higher-order functions.

Basic Decorator Structure

This snippet demonstrates the fundamental structure of a decorator. my_decorator is a function that takes another function (func) as an argument. Inside my_decorator, a nested function called wrapper is defined. wrapper contains the code that will be executed before and after the original function (func) is called. Finally, my_decorator returns the wrapper function.

The @my_decorator syntax is a shorthand way of applying the decorator. It's equivalent to writing say_hello = my_decorator(say_hello).

When say_hello() is called, it's actually calling the wrapper function returned by my_decorator, which then executes the "before" code, the original say_hello() function, and the "after" code.

def my_decorator(func):
    def wrapper():
        # Code to execute before the original function
        print("Before function call")
        func()
        # Code to execute after the original function
        print("After function call")
    return wrapper

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

say_hello()

Concepts Behind the Snippet

The core concept is that functions are first-class citizens in Python. This means you can treat functions like any other object: pass them as arguments, return them from other functions, and assign them to variables. Decorators leverage this to modify function behavior. The @ symbol, known as the 'at' symbol or decorator syntax, makes using decorators more readable and concise.

Decorator with Arguments

This example shows a decorator that accepts arguments. The repeat function takes num_times as an argument and returns another function, decorator_repeat, which then takes the function to be decorated. The wrapper function now uses *args and **kwargs to handle any number of positional and keyword arguments that the decorated function might accept. This makes the decorator more versatile.

When calling @repeat(num_times=3), you're essentially calling greet = repeat(num_times=3)(greet)

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 greet(name):
    print(f"Hello, {name}!")

greet("Alice")

Understanding *args and **kwargs

*args allows a function to accept any number of positional arguments. These arguments are collected into a tuple named args.

**kwargs allows a function to accept any number of keyword arguments. These arguments are collected into a dictionary named kwargs.

Using both *args and **kwargs in a decorator ensures that it can be used with functions that have different argument signatures.

Real-Life Use Case: Logging

This demonstrates a practical use case: logging function calls. The log_calls decorator logs the function name, arguments, and return value. functools.wraps is important here. It updates the wrapper function to look like the decorated function (add in this case). This preserves the original function's __name__, __doc__, etc., which is essential for introspection and debugging.

import functools

def log_calls(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print(f"Calling function: {func.__name__} with args: {args}, kwargs: {kwargs}")
        result = func(*args, **kwargs)
        print(f"Function {func.__name__} returned: {result}")
        return result
    return wrapper

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

add(5, 3)

Importance of functools.wraps

Without functools.wraps, the decorated function's metadata (like its name and docstring) would be overwritten by the wrapper function's metadata. This can make debugging and documentation generation difficult. functools.wraps copies the original function's metadata to the wrapper, making it appear as if the original function is still being called.

Best Practices

  • Use functools.wraps: Always use functools.wraps to preserve the original function's metadata.
  • Keep Decorators Simple: Decorators should ideally be focused on a single concern (e.g., logging, timing, authentication). Avoid complex logic within decorators.
  • Consider Order: The order in which you apply multiple decorators matters. The decorator closest to the function definition is applied first.
  • Handle Exceptions: Consider how exceptions raised by the decorated function should be handled within the decorator.

Interview Tip

Be prepared to explain what decorators are, how they work, and why they are useful. Be able to write a simple decorator on the spot. Understanding functools.wraps and its importance is a plus.

When to Use Them

Use decorators when you need to add functionality to a function or method without modifying its core logic. Common use cases include:

  • Logging
  • Timing execution
  • Authentication and authorization
  • Input validation
  • Caching
  • Retry mechanisms

Memory Footprint

Decorators can add a slight overhead due to the extra function call. However, this overhead is usually negligible. Overusing decorators with excessively complex logic inside them may impact performance, but well-designed decorators are generally efficient.

Alternatives

Alternatives to decorators include:

  • Monkey patching: Modifying the function directly. However, this is generally discouraged as it can make code harder to understand and maintain.
  • Inheritance: For methods within classes, inheritance can be used to override or extend behavior.
  • Manual wrapping: Explicitly creating a wrapper function and calling the original function from within it. This is less elegant than using decorators.

Pros

  • Readability: Decorators make code more readable and maintainable by separating concerns.
  • Reusability: Decorators can be reused across multiple functions.
  • Conciseness: Decorators provide a concise way to add functionality to functions.

Cons

  • Complexity: Decorators can be difficult to understand for beginners.
  • Debugging: Debugging decorated functions can be slightly more challenging due to the extra layer of indirection (but functools.wraps helps mitigate this).
  • Overuse: Overusing decorators can lead to code that is harder to understand.

FAQ

  • What is the difference between a decorator and a wrapper?

    A wrapper is a function that calls another function. A decorator is a way to apply a wrapper to a function using the @ syntax. Decorators are syntactic sugar that makes using wrappers more convenient and readable.

  • Can I apply multiple decorators to a single function?

    Yes, you can apply multiple decorators to a single function. The decorators are applied in the order they appear, from top to bottom.

  • How do I debug a decorated function?

    Use a debugger like pdb or the debugger built into your IDE. Step through the wrapper function to understand the flow of execution. Make sure you're using functools.wraps to preserve the original function's metadata, which will help with debugging.