Implementing and Understanding Python Decorators and Closures

A decorator in Python is a function designed to alter the behavior of another function or method without permanently modifying its source code. The core mechanism often relies on the concept of a closure, where an inner function retains access to variables from the scope of its outer function, even after the outer function has completed execution.

Examining Closures

A closure is created when a nested function references a variable from its enclosing scope. This allows the inner function to "remember" its originating environment.

Basic Closure Example

def message_generator(prefix):
    stored_message = [prefix]
    def add_recipient(recipient):
        stored_message.append(recipient)
        print(f"Message: {' '.join(stored_message)}")
    return add_recipient

send_message = message_generator("Hello")
send_message("Alice")  # Output: Message: Hello Alice
send_message("Bob")    # Output: Message: Hello Alice Bob

Variable Binding in Nested Scopes

Nested functions can modify objects of mutable types (like lists or dictionaries) from an outer scope. However, attempting to reassign a variable of an immutable type (like a string or integer) directly will create a new local variable unless explicitly declared otherwise with the nonlocal keyword.

Example Causing an Error

def outer():
    count = 0
    def inner():
        # This creates a new local variable 'count', shadowing the outer one.
        count += 1
        print(count)
    return inner

try_func = outer()
try_func()  # Raises UnboundLocalError

Corrected Using nonlocal

def outer():
    count = 0
    def inner():
        nonlocal count  # Explicitly refers to the outer variable
        count += 1
        print(f"Count: {count}")
    return inner

counter = outer()
counter()  # Output: Count: 1
counter()  # Output: Count: 2

A Common Pitfall with Loop Variables

When creating closures inside a loop, all inner functions may inadvertently reference the same loop variable, leading to unexpected results.

def create_callbacks():
    functions_list = []
    for num in range(3):
        def callback():
            return num * num
        functions_list.append(callback)
    return functions_list

cb1, cb2, cb3 = create_callbacks()
print(cb1(), cb2(), cb3())  # Output: 4 4 4 (Not 0, 1, 4)

All three returned functions capture the variable num. Since the loop completes before any function is called, they all reference the final value of num (which is 2), resulting in 2*2=4.

Solution: Bind the loop variable's current value to a parameter with a default argument at the time of definition, creating a new binding for each function.

def create_callbacks_fixed():
    functions_list = []
    for num in range(3):
        def callback(value=num):
            return value * value
        functions_list.append(callback)
    return functions_list

f1, f2, f3 = create_callbacks_fixed()
print(f1(), f2(), f3())  # Output: 0 1 4

Constructing Decorators

A decorator is essentially a fucntion that takes another function as input and returns a new function, typically implementde using closures.

The syntax @decorator above a function definition is equivalent to my_func = decorator(my_func).

Basic Decorator Structure

def simple_decorator(original_func):
    def wrapper_func():  # The closure that will replace the original function
        # Code to execute BEFORE the original function
        print(f"Calling '{original_func.__name__}'")
        result = original_func()  # Call the original function
        # Code to execute AFTER the original function
        print(f"'{original_func.__name__}' finished")
        return result
    return wrapper_func

@simple_decorator
def say_hello():
    print("Hello, world!")

say_hello()
# Output:
# Calling 'say_hello'
# Hello, world!
# 'say_hello' finished

Decorators with Arguments

To make a decorator that accepts its own arguments, an extra outer function layer is required.

def repeat_execution(times):
    """A decorator factory that returns a decorator."""
    def actual_decorator(func_to_wrap):
        def wrapper(*args, **kwargs):
            for i in range(times):
                print(f"Iteration {i+1}")
                func_to_wrap(*args, **kwargs)
        return wrapper
    return actual_decorator

# Usage
@repeat_execution(times=3)
def display_message(msg):
    print(f"Message: {msg}")

display_message("Test")
# Output:
# Iteration 1
# Message: Test
# Iteration 2
# Message: Test
# Iteration 3
# Message: Test

Preserving Function Metadata with functools.wraps

When a decorator returns a new wrapper function, it replaces the original function. This can obscure the original function's name, documentation, and other metadata. The functools.wraps decorator helps preserve this information.

from functools import wraps

def log_call(func):
    @wraps(func)  # Copies metadata from 'func' to 'wrapper'
    def wrapper(*args, **kwargs):
        print(f"LOG: Executing {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

@log_call
def compute_sum(a, b):
    """Returns the sum of two numbers."""
    return a + b

print(compute_sum.__name__)  # Output: compute_sum
print(compute_sum.__doc__)   # Output: Returns the sum of two numbers.
print(compute_sum(5, 3))     # Output: LOG: Executing compute_sum
                             #         8

Without @wraps(func), compute_sum.__name__ would be "wrapper" and compute_sum.__doc__ would be None.

Practical Applications

Decorators provide a clean way to add cross-cutting concerns to functions:

  • Logging: Automatically log function calls, arguments, and execution time.
  • Access Control: Check user permissions before executing a function.
  • Caching/Memoization: Store the results of expensive function calls.
  • Input Validation: Sanitize or validate arguments before the core logic runs.
  • Rate Limiting: Restrict how often a function can be called.

Tags: python decorators closures Functional Programming Metaprogramming

Posted on Tue, 11 Aug 2026 16:19:45 +0000 by ghostdog74