Python Decorators
Introduction
Have you ever wanted to add functionality to a function without modifying its code? Python decorators provide an elegant solution to this problem. Decorators are a powerful feature in Python that allow you to modify or enhance functions and methods without changing their source code. They wrap a function, extending its behavior, while keeping the original function's signature intact.
In this tutorial, we'll explore Python decorators from the ground up, starting with the basic concepts and progressing to practical applications that you can use in your own projects.
Prerequisites
Before diving into decorators, you should have a good understanding of:
- Python functions
- Function arguments
- The concept of functions as first-class objects
Understanding Functions as First-Class Objects
To understand decorators, we first need to recognize that in Python, functions are first-class objects. This means that functions can:
- Be assigned to variables
- Be passed as arguments to other functions
- Be returned from other functions
Let's see this in action:
def greet(name):
return f"Hello, {name}!"
# Assigning a function to a variable
greeting_function = greet
# Using the function through the variable
result = greeting_function("Alice")
print(result) # Output: Hello, Alice!
Nested Functions
Python allows you to define functions inside other functions. These are called nested functions:
def outer_function(message):
def inner_function():
print(message)
# Call the inner function
inner_function()
outer_function("Hello from the inner function!")
# Output: Hello from the inner function!
The inner function can access variables from the outer function's scope. This concept is called a closure.
Functions Returning Functions
A function in Python can also return another function:
def multiply_by(factor):
def multiplier(number):
return number * factor
return multiplier
# Create specific multiplier functions
double = multiply_by(2)
triple = multiply_by(3)
print(double(5)) # Output: 10
print(triple(5)) # Output: 15
In this example, multiply_by
returns the multiplier
function, which remembers the factor
value from its parent function's scope.
Basic Decorator Syntax
Now that we understand functions as first-class objects, we can introduce decorators. A decorator is a function that takes another function as an argument, adds some functionality, and returns the modified function.
Here's the basic pattern:
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
def say_hello():
print("Hello!")
# Apply the decorator manually
decorated_function = my_decorator(say_hello)
decorated_function()
Output:
Something is happening before the function is called.
Hello!
Something is happening after the function is called.
The @ Syntax
Python provides a more elegant way to apply decorators using the @
symbol:
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!")
# Now when we call say_hello(), it's actually calling the decorated version
say_hello()
Output:
Something is happening before the function is called.
Hello!
Something is happening after the function is called.
Using the @
syntax is equivalent to:
say_hello = my_decorator(say_hello)
Decorating Functions with Arguments
Our previous decorator only works with functions that don't take arguments. Let's modify it to work with any function:
def my_decorator(func):
def wrapper(*args, **kwargs):
print("Something is happening before the function is called.")
result = func(*args, **kwargs)
print("Something is happening after the function is called.")
return result
return wrapper
@my_decorator
def say_name(name):
print(f"My name is {name}")
say_name("John")
Output:
Something is happening before the function is called.
My name is John
Something is happening after the function is called.
By using *args
and **kwargs
in the wrapper function, we can pass any number of positional and keyword arguments to the original function.
Preserving Function Metadata
When you decorate a function, the metadata of the original function (like its name and docstring) gets lost:
@my_decorator
def add(a, b):
"""Add two numbers and return the result."""
return a + b
print(add.__name__) # Output: wrapper
print(add.__doc__) # Output: None
To fix this issue, we can use the @functools.wraps
decorator from the standard library:
import functools
def my_decorator(func):
@functools.wraps(func) # This preserves metadata
def wrapper(*args, **kwargs):
print("Something is happening before the function is called.")
result = func(*args, **kwargs)
print("Something is happening after the function is called.")
return result
return wrapper
@my_decorator
def add(a, b):
"""Add two numbers and return the result."""
return a + b
print(add.__name__) # Output: add
print(add.__doc__) # Output: Add two numbers and return the result.
Practical Examples of Decorators
Now that we understand the basics, let's explore some practical applications of decorators.
1. Timing Functions
You can use decorators to measure the execution time of functions:
import functools
import time
def timer(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start_time = time.time()
result = func(*args, **kwargs)
end_time = time.time()
print(f"{func.__name__} took {end_time - start_time:.4f} seconds to run")
return result
return wrapper
@timer
def slow_function():
"""A function that takes some time to execute."""
time.sleep(1)
return "Function completed"
slow_function()
Output:
slow_function took 1.0014 seconds to run
2. Logging Function Calls
Decorators can help with logging function calls and arguments:
import functools
import logging
logging.basicConfig(level=logging.INFO)
def log_function_call(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
logging.info(f"Calling {func.__name__} with args: {args}, kwargs: {kwargs}")
result = func(*args, **kwargs)
logging.info(f"{func.__name__} returned: {result}")
return result
return wrapper
@log_function_call
def calculate_sum(a, b, multiply_by=1):
return (a + b) * multiply_by
calculate_sum(3, 5, multiply_by=2)
Output:
INFO:root:Calling calculate_sum with args: (3, 5), kwargs: {'multiply_by': 2}
INFO:root:calculate_sum returned: 16
3. Authorization and Authentication
Decorators are commonly used to check if a user is authorized to perform an action:
import functools
def require_auth(func):
@functools.wraps(func)
def wrapper(user, *args, **kwargs):
if not user.get('is_authenticated', False):
raise PermissionError("You must be logged in to perform this action")
return func(user, *args, **kwargs)
return wrapper
@require_auth
def view_protected_resource(user):
return "Protected content"
# Unauthorized user
try:
view_protected_resource({'name': 'Guest', 'is_authenticated': False})
except PermissionError as e:
print(e)
# Authorized user
result = view_protected_resource({'name': 'Admin', 'is_authenticated': True})
print(result)
Output:
You must be logged in to perform this action
Protected content
4. Caching Results (Memoization)
Decorators can cache the results of functions to avoid redundant calculations:
import functools
def memoize(func):
cache = {}
@functools.wraps(func)
def wrapper(*args):
if args not in cache:
cache[args] = func(*args)
print(f"Calculated result for {args}")
else:
print(f"Using cached result for {args}")
return cache[args]
return wrapper
@memoize
def fibonacci(n):
"""Calculate the Fibonacci number for n."""
if n <= 1:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
# First call calculates the value
result = fibonacci(10)
print(f"Result: {result}")
# Second call uses the cached value
result = fibonacci(10)
print(f"Result: {result}")
Output shows how recursive calls are cached:
Calculated result for (0,)
Calculated result for (1,)
Calculated result for (2,)
...
Calculated result for (10,)
Result: 55
Using cached result for (10,)
Result: 55
Decorators with Arguments
We can also create decorators that accept arguments:
import functools
def repeat(n=1):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
result = None
for _ in range(n):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(n=3)
def say_hello(name):
print(f"Hello, {name}!")
return "Done"
say_hello("Alice")
Output:
Hello, Alice!
Hello, Alice!
Hello, Alice!
In this example, repeat
is a function that returns a decorator. The decorator is configured with the value of n
.
Class Decorators
Decorators aren't limited to functions. You can also create decorators using classes:
class CountCalls:
def __init__(self, func):
functools.update_wrapper(self, func)
self.func = func
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
print(f"Call {self.count} to {self.func.__name__}")
return self.func(*args, **kwargs)
@CountCalls
def say_hi():
print("Hi!")
say_hi()
say_hi()
Output:
Call 1 to say_hi
Hi!
Call 2 to say_hi
Hi!
Chaining Multiple Decorators
You can apply multiple decorators to a single function. They are applied from innermost to outermost:
import functools
def bold(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return "<b>" + func(*args, **kwargs) + "</b>"
return wrapper
def italic(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return "<i>" + func(*args, **kwargs) + "</i>"
return wrapper
@bold
@italic
def format_text(text):
return text
print(format_text("Hello, World!"))
Output:
<b><i>Hello, World!</i></b>
The decorators are applied from bottom to top, so the text is first wrapped in <i>
tags and then in <b>
tags.
Summary
Python decorators are a powerful tool that allow you to:
- Modify or enhance functions without changing their source code
- Apply cross-cutting concerns like logging, timing, or authentication
- Create reusable function wrappers
- Implement design patterns elegantly
We've covered:
- The concept of functions as first-class objects
- The basic syntax and behavior of decorators
- Preserving function metadata with
functools.wraps
- Practical examples of decorators in real-world applications
- Creating decorators that accept arguments
- Class-based decorators
- Chaining multiple decorators
As you build more complex Python applications, decorators will become an invaluable tool in your programming toolkit.
Exercises
- Create a decorator that prints a warning when a deprecated function is called.
- Create a decorator that retries a function a specified number of times if it raises an exception.
- Write a decorator that enforces type checking for the arguments of a function.
- Create a decorator that limits how many times a function can be called.
- Implement a decorator that caches the results of a function with an expiry time.
Additional Resources
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)