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:
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:
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:
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” thegreeting_word
variable from its enclosingget_greeter
function.
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:
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:
@decorator_function
def my_function():
# Function definition
pass
This is exactly equivalent to writing:
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:
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__
).
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.
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.
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
# 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 bothslow_function
andfast_function
to automatically time their execution.
Example 2: Simple Access Control Decorator
# 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 theUSER["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 toTypeError
. - 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 returningNone
). - 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 |
Functions can be assigned to variables, passed as arguments to other functions, and returned from other functions. |
Inner Functions | def outer(): |
Functions defined inside the body of another function. They have access to the outer function’s local scope. |
Closures | greeter = get_greeter("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 |
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): |
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 |
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) |
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 forfunction = 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
- Simple Debug Decorator: Write a decorator
debug_args
that prints the arguments (args
andkwargs
) passed to a function before calling it. Apply it to a simple function likeadd(x, y)
and call the decorated function. Rememberfunctools.wraps
. - 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”. - 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). - 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 specifiedarg_type
. If not, it should raise aTypeError
. Apply@check_types(int)
to a function likesum_integers(*numbers)
.
Mini Project: Function Call Counter Decorator
Goal: Create a decorator that counts how many times a function has been called.
Steps:
- Define the Decorator (
count_calls
):- Import
functools
. - Define the decorator function
count_calls(func)
. - Inside
count_calls
, define thewrapper(*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
.
- Increment
- Return the
wrapper
function fromcount_calls
.
- Import
- Apply the Decorator:
- Define a simple function, e.g.,
say_hello()
. - Apply the
@count_calls
decorator to it.
- Define a simple function, e.g.,
- 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)
.
- Call your decorated function (
Additional Sources: