No history yet

Decorators and Closures

Functions That Remember

In Python, functions aren't just blocks of code; they are first-class objects. You can pass them as arguments, return them from other functions, and assign them to variables. This flexibility enables a powerful concept called a , which is a function that remembers the environment in which it was created.

Imagine you need a function that can generate custom greetings. Instead of writing a new function for every possible greeting, you can create a function factory.

def make_greeter(greeting):
    # This is the outer function.
    # The 'greeting' variable is part of its local scope.

    def greet(name):
        # This is the inner function, the closure.
        # It 'closes over' the 'greeting' variable.
        return f"{greeting}, {name}!"

    return greet

# Create two different greeter functions
greet_hello = make_greeter("Hello")
greet_hola = make_greeter("Hola")

print(greet_hello("Alice"))
print(greet_hola("Bob"))

Output: Hello, Alice! Hola, Bob!

Even though the make_greeter function has finished running, the returned greet function still remembers the value of greeting from when it was created. greet_hello remembers "Hello," and greet_hola remembers "Hola." This ability to retain state is the foundation for decorators.

Introducing Decorators

A decorator is essentially a function that takes another function as an argument, adds some functionality to it, and returns a new, modified function. It's a clean way to extend the behaviour of functions without permanently modifying their code. This is made possible by —functions that operate on other functions.

Let's build a simple decorator that logs when a function is called.

def log_call(func):
    def wrapper(*args, **kwargs):
        print(f"Calling function: {func.__name__}")
        result = func(*args, **kwargs)
        print(f"Finished function: {func.__name__}")
        return result
    return wrapper

def say_whee():
    print("Whee!")

# The traditional way to apply the decorator
say_whee_logged = log_call(say_whee)
say_whee_logged()

This works, but Python provides a much cleaner way to apply decorators using the @ symbol. This is often called syntactic sugar because it makes the code sweeter to read.

@log_call
def say_whee():
    print("Whee!")

say_whee()

Output: Calling function: wrapper Whee! Finished function: wrapper

The @log_call syntax is exactly equivalent to say_whee = log_call(say_whee). Notice one problem, though: the function name is reported as wrapper, not say_whee. The original function's metadata has been lost. We'll fix that shortly.

Passing Arguments and Preserving Identity

Our log_call decorator works for a function with no arguments, but what about others? The *args and **kwargs syntax in the wrapper function allows it to accept any number of positional and keyword arguments and pass them along to the original function.

To solve the metadata problem, we use another decorator: functools.wraps. It copies metadata like the function name (__name__) and docstring (__doc__) from the original function to the wrapper function.

import functools

def timer(func):
    """A decorator to time a function's execution."""
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        import time
        start_time = time.perf_counter()
        value = func(*args, **kwargs)
        end_time = time.perf_counter()
        run_time = end_time - start_time
        print(f"Finished {func.__name__!r} in {run_time:.4f} secs")
        return value
    return wrapper

@timer
def waste_some_time(num_times):
    """This function wastes some time."""
    for _ in range(num_times):
        sum(i**2 for i in range(10000))

waste_some_time(10)

print(waste_some_time.__name__)
print(waste_some_time.__doc__)

With functools.wraps, the decorated function now correctly reports its own name and docstring, making your code easier to debug and understand.

Output: Finished 'waste_some_time' in 0.0312 secs waste_some_time This function wastes some time.

Decorators That Accept Arguments

Sometimes you need a decorator that can be customised. For instance, what if you wanted a decorator to repeat a function a specific number of times? This requires an extra layer of nesting—a function that creates and returns a decorator.

import functools

def repeat(num_times):
    # 1. This is the decorator factory. It takes the argument.
    def decorator_repeat(func):
        # 2. This is the actual decorator.
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            # 3. This is the wrapper for the original function.
            for _ in range(num_times):
                value = func(*args, **kwargs)
            return value
        return wrapper
    return decorator_repeat

@repeat(num_times=3)
def greet(name):
    print(f"Hello, {name}!")

greet("World")

Here’s how it works:

  1. Python sees @repeat(num_times=3) and calls repeat(3).
  2. repeat returns the decorator_repeat function.
  3. Python then applies this returned decorator to greet, so greet becomes decorator_repeat(greet).

Output: Hello, World! Hello, World! Hello, World!

Closures and decorators are advanced but essential tools in Python. They allow for elegant solutions to common problems like logging, timing, and caching, leading to more modular, readable, and maintainable code.

Quiz Questions 1/6

What is a closure in Python?

Quiz Questions 2/6

What is the primary role of @functools.wraps when creating a decorator?