Function Decorators

advanced
Published

November 25, 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 even around the original function’s execution.

Let’s start with a simple example:

def my_decorator(func):
    def wrapper():
        print("Something is happening before the function is called.")
        func()
        print("Something is happening after the function is called.")
    return wrapper

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

say_hello()

This code defines a decorator my_decorator. The @my_decorator syntax above say_hello() is syntactic sugar; it’s equivalent to say_hello = my_decorator(say_hello). The output demonstrates that the wrapper function executes code before and after the original say_hello() function.

Decorators with Arguments

Decorators can also handle functions that accept arguments. To achieve this, the wrapper function needs to accept the same arguments as the original function and pass them along:

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("World")

Here, repeat is a decorator factory – it returns a decorator. The *args and **kwargs allow the wrapper to handle any number of positional and keyword arguments passed to the decorated function.

Decorators with Return Values

Modifying the return value of the decorated function is straightforward:

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

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

print(get_message()) # Output: <b>Hello, world!</b>

This example shows how to wrap the return value of the function with HTML bold tags.

Practical Applications

Decorators are invaluable for various tasks, including:

  • Logging: Record function calls and their arguments.
  • Timing: Measure the execution time of a function.
  • Authentication: Control access to functions based on user permissions.
  • Caching: Store the results of expensive function calls to improve performance.
  • Input validation: Sanitize or validate input before passing it to the function.

Let’s illustrate logging with a decorator:

import functools

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


@log_calls
def add(a, b):
    return a + b

add(5, 3)

Note the use of functools.wraps. This is crucial for preserving the original function’s metadata (like name and docstring) after decoration. Without it, the decorated function would lose its original identity.