Programming with Python | Chapter 16: Decorators

Chapter Objectives

  • Understand that functions are first-class objects in Python.
  • Learn how to define inner functions (functions inside other functions).
  • Understand how functions can be passed as arguments and returned from other functions.
  • Grasp the concept of closures.
  • Learn the purpose and syntax of decorators (@decorator_name).
  • Understand how decorators work by wrapping functions.
  • Implement simple decorators to add functionality (e.g., logging, timing).
  • Use functools.wraps to preserve the original function’s metadata.

Introduction

Decorators are a powerful and expressive feature in Python that allow you to modify or enhance functions or methods in a clean and readable way. They are a form of metaprogramming (code that manipulates other code). At its core, a decorator is a function that takes another function as input, adds some functionality to it, and returns the modified function (or a new function that wraps the original). This chapter builds upon our understanding of functions, exploring concepts like functions as first-class objects and inner functions, which underpin how decorators work. We will then learn the @ syntax for applying decorators and how to write our own simple decorators.

Theory & Explanation

Functions as First-Class Objects

In Python, functions are treated as “first-class citizens”. This means they can be:

1. Assigned to variables:

Python
def greet(name):
    return f"Hello, {name}!"

say_hello = greet # Assign the function object 'greet' to 'say_hello'
print(say_hello("Alice")) # Call the function using the new variable name
# Output: Hello, Alice!

2. Passed as arguments to other functions:

Python
def apply_func(func, value):
    """Applies a function to a value."""
    return func(value)

def square(x):
    return x * x

result = apply_func(square, 5) # Pass the 'square' function as an argument
print(result) # Output: 25

3. Returned from other functions:

Python
def get_greeter(greeting_word):
    """Returns a function that greets with a specific word."""
    def greeter_func(name):
        return f"{greeting_word}, {name}!"
    return greeter_func # Return the inner function

hola_greeter = get_greeter("Hola")
hello_greeter = get_greeter("Hello")

print(hola_greeter("Miguel")) # Output: Hola, Miguel!
print(hello_greeter("Bob"))   # Output: Hello, Bob!

Inner Functions and Closures

  • Inner Functions: Functions defined inside other functions. They have access to the local variables of the outer function (this is called lexical scoping).
  • Closures: An inner function that remembers and has access to variables from the local scope of the function it was defined in, even after the outer function has finished executing. In the get_greeter example above, greeter_func is a closure because it “closes over” the greeting_word variable from its enclosing get_greeter function.
def greet(): return “Hi” say_hi assigned to def square(x): return x*x apply(func, val) passed to (square, 5)

The Decorator Concept

Imagine you want to add logging before and after several different functions execute. Instead of adding the logging code inside each function, you can use a decorator.

graph LR
    A["Original Function Definition<br><pre>def my_func():<br>  # ...</pre>"] -- @decorator --> B("Decorator Function<br><pre>decorator(func)</pre>");
    B -- Returns --> C("Wrapped Function<br><pre>wrapper(*args, **kwargs)</pre>");
    D("Function Call<br><pre>my_func()</pre>") -- Actually Calls --> C;

    style A fill:#f9f,stroke:#333,stroke-width:2px
    style B fill:#ccf,stroke:#333,stroke-width:2px
    style C fill:#9cf,stroke:#333,stroke-width:2px
    style D fill:#cfc,stroke:#333,stroke-width:2px

A decorator is essentially a function that takes a function as input and returns a new function that usually extends or modifies the behavior of the original function.

Manual Decorator Application:

Python
def logger_decorator(original_func):
    """A simple decorator that logs function calls."""
    def wrapper(*args, **kwargs): # Inner wrapper function
        print(f"Calling function: {original_func.__name__}")
        result = original_func(*args, **kwargs) # Call the original function
        print(f"Function {original_func.__name__} finished.")
        return result
    return wrapper # Return the wrapper function

def add(x, y):
    """Adds two numbers."""
    return x + y

def subtract(x, y):
    """Subtracts two numbers."""
    return x - y

# Manually decorate the functions
decorated_add = logger_decorator(add)
decorated_subtract = logger_decorator(subtract)

# Call the decorated versions
sum_result = decorated_add(10, 5)
# Output:
# Calling function: add
# Function add finished.
print(f"Sum: {sum_result}") # Output: Sum: 15

diff_result = decorated_subtract(10, 5)
# Output:
# Calling function: subtract
# Function subtract finished.
print(f"Difference: {diff_result}") # Output: Difference: 5

In this example, logger_decorator takes a function (add or subtract) and returns a new function (wrapper). The wrapper function contains the added logging logic and calls the original function in between. *args and **kwargs are used in the wrapper to accept any positional or keyword arguments the original function might take.

Decorator Syntax (@)

Python provides special syntax using the @ symbol to apply decorators more cleanly.

sequenceDiagram
    participant Caller
    participant DecoratedFunc (Wrapper)
    participant OriginalFunc

    Caller->>+DecoratedFunc (Wrapper): Call decorated_function(args)
    Note right of DecoratedFunc (Wrapper): Wrapper executes pre-call logic (e.g., logging, timing start)
    DecoratedFunc (Wrapper)->>+OriginalFunc: Call original_function(args)
    OriginalFunc-->>-DecoratedFunc (Wrapper): Return result
    Note right of DecoratedFunc (Wrapper): Wrapper executes post-call logic (e.g., logging, timing end)
    DecoratedFunc (Wrapper)-->>-Caller: Return final result

Syntax:

Python
@decorator_function
def my_function():
    # Function definition
    pass

This is exactly equivalent to writing:

Python
def my_function():
    # Function definition
    pass

my_function = decorator_function(my_function)

The @decorator_function line placed directly above a function definition automatically passes the defined function (my_function) to the decorator (decorator_function) and reassigns the name my_function to the returned wrapper function.

Using @ Syntax with the Logger Example:

Python
def logger_decorator(original_func):
    """A simple decorator that logs function calls."""
    def wrapper(*args, **kwargs):
        print(f"Calling function: {original_func.__name__}")
        result = original_func(*args, **kwargs)
        print(f"Function {original_func.__name__} finished.")
        return result
    return wrapper

@logger_decorator # Apply the decorator using @ syntax
def add(x, y):
    """Adds two numbers."""
    return x + y

@logger_decorator # Apply the same decorator
def subtract(x, y):
    """Subtracts two numbers."""
    return x - y

# Now, calling add and subtract directly uses the decorated versions
sum_result = add(20, 10)
# Output:
# Calling function: add
# Function add finished.
print(f"Sum: {sum_result}") # Output: Sum: 30

diff_result = subtract(20, 10)
# Output:
# Calling function: subtract
# Function subtract finished.
print(f"Difference: {diff_result}") # Output: Difference: 10

This @ syntax makes the code much more readable and clearly indicates that the function’s behavior is being modified.

Preserving Function Metadata (functools.wraps)

One issue with the simple decorator example above is that the decorated function loses its original metadata (like its name __name__ and docstring __doc__).

Python
print(add.__name__) # Output: wrapper (instead of 'add')
print(add.__doc__)  # Output: None (or the wrapper's docstring, if it had one)

This can cause problems for introspection tools and debugging. To fix this, Python’s functools module provides the wraps decorator, which should be used to decorate the wrapper function inside your decorator.

Python
import functools # Import the functools module

def logger_decorator_fixed(original_func):
    """A decorator that logs calls and preserves metadata."""
    @functools.wraps(original_func) # Apply wraps to the wrapper
    def wrapper(*args, **kwargs):
        """The wrapper function documentation (optional)."""
        print(f"Calling function: {original_func.__name__}")
        result = original_func(*args, **kwargs)
        print(f"Function {original_func.__name__} finished.")
        return result
    return wrapper

@logger_decorator_fixed
def multiply(x, y):
    """Multiplies two numbers."""
    return x * y

print(f"\nDecorated function name: {multiply.__name__}") # Output: multiply
print(f"Decorated function docstring: {multiply.__doc__}") # Output: Multiplies two numbers.

result = multiply(5, 4)
# Output:
# Calling function: multiply
# Function multiply finished.
print(f"Product: {result}") # Output: Product: 20

Using @functools.wraps(original_func) inside your decorator copies the relevant metadata from original_func to the wrapper function, making the decorated function behave more like the original for introspection purposes. Always use functools.wraps when writing decorators.

Decorators with Arguments

Decorators themselves can also accept arguments. This requires an extra layer of nesting: a function that accepts the decorator arguments and returns the actual decorator function.

Python
import functools

def repeat(num_times):
    """Decorator factory: returns a decorator that repeats a function call."""
    def decorator_repeat(func):
        """The actual decorator."""
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            """The final wrapper."""
            print(f"Repeating {func.__name__} {num_times} times:")
            for i in range(num_times):
                result = func(*args, **kwargs)
            return result # Return result of the last call
        return wrapper
    return decorator_repeat # Return the decorator

# Apply the decorator factory with an argument
@repeat(num_times=3)
def say_whee():
    """Prints 'Whee!'"""
    print("Whee!")

say_whee()
# Output:
# Repeating say_whee 3 times:
# Whee!
# Whee!
# Whee!

graph TD

    Outer["Outer Function get_greeter(<i>Hola</i>)"] -->|Defines & Returns| Inner("Inner Function <b>greeter_func</b>");
    Outer -- Creates Scope --> Scope{"Local Scope<br>greeting_word = <i>Hola</i>"};
    Inner -- Remembers --> Scope;
    Caller["Caller Code"] -->|Calls| GreeterVar("Variable <b>hola_greeter</b><br>Holds <b>greeter_func</b>");
    GreeterVar -->|Executes| Inner;
    Inner -->|Accesses| Scope;

    subgraph Returned["After Outer Function Returns"]
        Spacer1[" "]:::invisible
        Spacer2[" "]:::invisible
        Spacer3[" "]:::invisible
        GreeterVar
        Inner
        Scope
    end

    classDef invisible fill:#cceeff,stroke:#cceeff
    class Spacer1,Spacer2,Spacer3 invisible;

    style Outer fill:#fdf,stroke:#333
    style Inner fill:#ddf,stroke:#333
    style Scope fill:#ffd,stroke:#333,stroke-dasharray: 5 5
    style Caller fill:#eee,stroke:#333
    style GreeterVar fill:#eee,stroke:#333
    style Returned fill:#cceeff,stroke:#3399cc,stroke-width:2px,stroke-dasharray: 5 5

Code Examples

Example 1: Timing Decorator

Python
# timing_decorator.py
import time
import functools

def timer(func):
    """Decorator that prints the execution time of the wrapped function."""
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start_time = time.perf_counter() # Get high-resolution time
        result = func(*args, **kwargs)
        end_time = time.perf_counter()
        run_time = end_time - start_time
        print(f"Function '{func.__name__}' executed in {run_time:.6f} seconds")
        return result
    return wrapper

@timer
def slow_function(delay):
    """A function that simulates work by sleeping."""
    print(f"Sleeping for {delay} second(s)...")
    time.sleep(delay)
    print("Woke up!")
    return "Done"

@timer
def fast_function(n):
    """A function that does a quick calculation."""
    result = sum(i*i for i in range(n)) # Sum of squares
    return result


# --- Call the decorated functions ---
slow_result = slow_function(1.5)
print(f"Slow function returned: {slow_result}")

fast_result = fast_function(10000)
print(f"Fast function returned sum: {fast_result}")

# Example Output (times will vary):
# Sleeping for 1.5 second(s)...
# Woke up!
# Function 'slow_function' executed in 1.501234 seconds
# Slow function returned: Done
# Function 'fast_function' executed in 0.003456 seconds
# Fast function returned sum: 333283335000

Explanation:

  • The timer decorator records the time before calling the original function (func).
  • It calls the original function and stores the result.
  • It records the time after the function finishes.
  • It calculates and prints the duration.
  • It returns the original function’s result.
  • @timer is applied to both slow_function and fast_function to automatically time their execution.

Example 2: Simple Access Control Decorator

Python
# access_control.py
import functools

# Simple simulation of user roles
USER = {"name": "Alice", "role": "guest"}
# USER = {"name": "Bob", "role": "admin"}

def require_admin(func):
    """Decorator that only allows execution if user role is 'admin'."""
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        if USER.get("role") == "admin":
            print(f"Admin access granted for {func.__name__}.")
            return func(*args, **kwargs)
        else:
            print(f"Access Denied: User '{USER.get('name')}' (role: {USER.get('role')}) cannot run {func.__name__}.")
            # Optionally raise an exception instead of printing
            # raise PermissionError("Admin privileges required.")
            return None # Or return something indicating failure
    return wrapper

@require_admin
def delete_user(username):
    """Simulates deleting a user (requires admin)."""
    print(f"Deleting user: {username}... (Operation successful)")
    return True

@require_admin
def reset_password(username):
    """Simulates resetting a password (requires admin)."""
    print(f"Resetting password for: {username}... (Operation successful)")
    return True

def view_profile(username):
    """Simulates viewing a profile (no admin required)."""
    print(f"Viewing profile for: {username}")
    return {"name": username, "data": "..."}


# --- Attempt operations ---
print(f"Current user: {USER}\n")

delete_user("charlie")
reset_password("dave")
view_profile("alice") # This function is not decorated

# Try changing USER dictionary above and re-running

Explanation:

  • A simple global USER dictionary simulates the current user’s role.
  • The require_admin decorator checks the USER["role"].
  • If the role is “admin”, it calls the original function.
  • If not, it prints an access denied message and returns None (or could raise an error).
  • @require_admin is applied only to functions that need administrative privileges. view_profile can be called by anyone.

Common Mistakes or Pitfalls

  • Forgetting @functools.wraps: Leads to loss of original function metadata (__name__, __doc__), hindering debugging and introspection.
  • Incorrect Wrapper Signature: The inner wrapper function must accept arbitrary arguments (*args, **kwargs) to handle any arguments the decorated function might take. Forgetting this can lead to TypeError.
  • Not Returning from Wrapper: If the original function returns a value, the wrapper function must capture it and return it, otherwise the return value will be lost (implicitly returning None).
  • Decorator Order: When applying multiple decorators, the order matters. They are applied from bottom to top (the one closest to the def line runs first).
  • Complexity: Overusing decorators or creating overly complex decorators can sometimes make code harder to follow than alternative approaches.

Chapter Summary

Concept Syntax / Example Description
First-Class Functions my_func = greet
apply(square, 5)
return inner_func
Functions can be assigned to variables, passed as arguments to other functions, and returned from other functions.
Inner Functions def outer():
  def inner(): ...
  return inner
Functions defined inside the body of another function. They have access to the outer function’s local scope.
Closures greeter = get_greeter("Hi")
greeter("Eve") # Uses "Hi"
An inner function that remembers and has access to variables from the scope where it was created, even after the outer function has finished execution.
Decorator (Concept) decorated = logger(add) A function that takes another function as input, adds functionality (e.g., logging, timing), and returns a modified or wrapper function.
Decorator Syntax @logger_decorator
def my_function(): ...
Syntactic sugar (@) for applying a decorator. Equivalent to my_function = logger_decorator(my_function). Placed directly above the function definition.
Wrapper Function def decorator(func):
  def wrapper(*a, **kw):
    ... # Pre-action
    res = func(*a, **kw)
    ... # Post-action
    return res
  return wrapper
The inner function defined inside a decorator. It typically executes code before/after calling the original function and accepts arbitrary arguments (*args, **kwargs).
functools.wraps import functools
...
  @functools.wraps(func)
  def wrapper(...): ...
A helper decorator applied to the *wrapper* function inside your custom decorator. It copies metadata (like __name__, __doc__) from the original function to the wrapper, aiding introspection and debugging.
Decorators with Arguments @repeat(num_times=3)
def greet(): ...
Requires an extra layer: a function (decorator factory) that takes arguments and *returns* the actual decorator function.
  • Functions in Python are first-class objects: they can be assigned, passed as arguments, and returned from other functions.
  • Inner functions and closures (inner functions remembering their enclosing scope) are key concepts behind decorators.
  • A decorator is a function that takes another function, extends its behavior, and returns the modified (or a new wrapper) function.
  • The @decorator_name syntax is syntactic sugar for function = decorator_name(function).
  • Decorators commonly use an inner wrapper function that accepts *args and **kwargs to handle arbitrary arguments passed to the decorated function.
  • Use @functools.wraps(original_func) on the wrapper function to preserve the original function’s metadata (__name__, __doc__, etc.).
  • Decorators can be used for logging, timing, access control, caching, registering functions, and many other cross-cutting concerns.

Exercises & Mini Projects

Exercises

  1. Simple Debug Decorator: Write a decorator debug_args that prints the arguments (args and kwargs) passed to a function before calling it. Apply it to a simple function like add(x, y) and call the decorated function. Remember functools.wraps.
  2. Uppercase Result Decorator: Write a decorator uppercase_result that takes a function which returns a string. The decorator should call the function, convert its string result to uppercase, and return the uppercase string. Apply it to a function that returns “hello world”.
  3. Run Once Decorator: Write a decorator run_once that ensures a function can only be called successfully the first time. Subsequent calls should do nothing (or print a message). (Hint: The wrapper function will need to keep track of whether it has run before, perhaps using a variable in its closure or an attribute).
  4. Decorator with Arguments (Type Check): Create a decorator factory check_types(arg_type) that takes a type (e.g., int, str). The factory should return a decorator that checks if all positional arguments passed to the decorated function are of the specified arg_type. If not, it should raise a TypeError. Apply @check_types(int) to a function like sum_integers(*numbers).

Mini Project: Function Call Counter Decorator

Goal: Create a decorator that counts how many times a function has been called.

Steps:

  1. Define the Decorator (count_calls):
    • Import functools.
    • Define the decorator function count_calls(func).
    • Inside count_calls, define the wrapper(*args, **kwargs) function. Use @functools.wraps(func).
    • Add an attribute to the wrapper function itself to store the call count, initializing it to 0 before the wrapper is returned. For example: wrapper.call_count = 0. (Functions are objects, you can add attributes to them!).
    • Inside the wrapper:
      • Increment wrapper.call_count.
      • Print a message like f”Function ‘{func.name}’ has been called {wrapper.call_count} times.”
      • Call the original function: result = func(*args, **kwargs).
      • Return the result.
    • Return the wrapper function from count_calls.
  2. Apply the Decorator:
    • Define a simple function, e.g., say_hello().
    • Apply the @count_calls decorator to it.
  3. Test:
    • Call your decorated function (say_hello()) multiple times in a loop or sequentially.
    • Observe the printed output showing the incrementing call count.
    • (Optional) Access the count directly after the calls: print(say_hello.call_count).

Additional Sources:

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top