I still remember the day I first encountered Python decorators. It was 2016, and I started working on my new job as a newbie to Python programming. Like some other Python concepts like Python context managers, Python generators, etc., I also fell in love with the decorator feature. It quickly went from “weird @ syntax” to “How did I live without this?” They became a daily part of my coding toolkit in the Python environment. If you’re new to decorators, buckle up; by the end of reading this article, you can use them like a pro.
What Are Decorators in Python?
Think of decorators as fancy gift wrappers for your functions. They allow you to take an existing function and extend its behaviour without modifying its source code. It’s like adding superpowers to your functions on demand!
In Python terms, decorators are just functions that modify other functions. They’re like powerful middleware that can execute code before and after the wrapped function runs. At a high level, decorators follow the decorator design pattern: they take a function, add some behaviour, and then return it. In Python, they’re compelling because functions are first-class citizens, meaning you can pass them around and manipulate them like any other object.
Your First Decorator: Baby Steps
Let’s start with something straightforward:
def my_first_decorator(func):
def wrapper():
print("Something is happening before the function")
func()
print("Something is happening after the function")
return wrapper
@my_first_decorator
def say_hello():
print("Hello!")
# When we call say_hello()...
say_hello()
# Output:
# Something is happening before the function
# Hello!
# Something is happening after the function
Code language: PHP (php)
💡 Aha Moment: The @
syntax is just syntactic sugar! It’s the same as writing: say_hello = my_first_decorator(say_hello)
The Anatomy of a Decorator
Let’s break down the previous example and dive into step by step what’s happening:
- We define a function (
my_first_decorator
) that takes another function as an argument. - Inside, we define a new function (
wrapper
) that calls the original function and modifies its result. - We return this new
wrapper
function. - When we use the
@my_first_decorator
Syntax, Python essentially does this behind the scenes:
Making it Real: A Practical Timing Decorator
Here’s something I use all the time to optimize code:
import time
from functools import wraps
def timer(func):
@wraps(func) # This preserves the original function's metadata
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
end = time.time()
print(f"{func.__name__} took {end - start:.2f} seconds to run")
return result
return wrapper
@timer
def slow_function():
"""This is a slow function."""
time.sleep(1)
return "Done!"
print(slow_function())
# Output:
# slow_function took 1.00 seconds to run
# Done!
Code language: PHP (php)
⚠️ Common Mistake Alert: Forgetting @wraps(func)
will mess up your function’s metadata, causing headaches with testing and documentation tools.
Levelling Up – Decorators with Arguments!
Remember my Flask confusion? Here’s what threw me off – decorators that take arguments:
def repeat(times):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for _ in range(times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(times=3)
def greet(name):
print(f"Hello, {name}")
greet("Bob")
# Output:
# Hello, Bob
# Hello, Bob
# Hello, Bob
Code language: PHP (php)
Class Decorators: The Object-Oriented Approach
Here’s something I learned while building a caching system for a high-traffic API:
class Cache:
def __init__(self):
self.data = {}
def __call__(self, func):
@wraps(func)
def wrapper(*args, **kwargs):
key = str(args) + str(kwargs)
if key not in self.data:
self.data[key] = func(*args, **kwargs)
return self.data[key]
return wrapper
@Cache()
def expensive_computation(n):
print("Computing...")
return n * n
print("First try:")
print(expensive_computation(10))
print("Second attempt:")
print(expensive_computation(10))
# Output
# First try:
# Computing...
# 100
# Second attempt:
# 100
Real-World Examples
Authentication Decorator
This is similar to what we can use to implement auth gatekeeping to some functions:
def require_auth(func):
@wraps(func)
def wrapper(request, *args, **kwargs):
auth_token = request.headers.get('Authorization')
if not auth_token:
raise AuthError("No auth token provided")
if not is_valid_token(auth_token):
raise AuthError("Invalid token")
return func(request, *args, **kwargs)
return wrapper
@require_auth
def get_user_data(request):
return {"user": "data"}
Code language: JavaScript (javascript)
Retry Mechanism Decorator:
Here’s another real-world use case for retry logic. It’s often useful in scenarios like microservices where networks can often be unreliable:
def retry(max_attempts=3, delay=1):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
attempts = 0
while attempts < max_attempts:
try:
return func(*args, **kwargs)
except Exception as e:
attempts += 1
if attempts == max_attempts:
raise e
time.sleep(delay)
return None
return wrapper
return decorator
@retry(max_attempts=3, delay=2)
def unstable_network_call():
# Simulated unstable API call
pass
Code language: PHP (php)
Common Pitfalls And How to Avoid Them
Mutable Arguments in Decorators:
# DON'T DO THIS
def decorator(func):
cache = [] # This is shared between all decorated functions!
def wrapper():
return func()
return wrapper
# DO THIS
def decorator(func):
def wrapper(cache=None):
cache = cache or [] # New cache for each call
return func()
return wrapper
Code language: PHP (php)
Decorator Order Matters:
@decorator1
@decorator2
def function():
pass
# Is equivalent to:
function = decorator1(decorator2(function))
# decorator1 executes after decorator2
Code language: PHP (php)
Forgetting to Return the Wrapper:
If you forget to return wrapper
in your decorator, it’ll break, likely with a NoneType
error.
# Broken example - missing return statement
def broken_decorator(func):
def wrapper():
print("Doing something extra!")
func()
@broken_decorator
def greet():
print("Hello")
# This will raise an error when called
# greet()
Code language: PHP (php)
Best Practices
- Always use
@wraps
, to help preserve metadata - Keep decorators focused on a single responsibility
- Document what your decorators do
- Consider making decorators optional with parameters
- Test decorated functions both with and without the decorator
Debugging Tips
Print the function name and arguments in your wrapper:
def debug(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__} with args: {args}, kwargs: {kwargs}")
result = func(*args, **kwargs)
print(f"{func.__name__} returned: {result}")
return result
return wrapper
Code language: PHP (php)
Conclusion
Decorators are like secret ingredients in your Python cookbook. They allow you to write cleaner, more modular code by separating concerns. Remember, the key to mastering decorators is practice. Start small, experiment, and soon you’ll be wrapping functions like a pro!
If you enjoyed reading this, you may also enjoy learning about some python weird behaviours as well. Happy Python programming! 🐍✨
Leave a Reply