Python Decorators: Enhance Your Code with Function Wrappers
Ever wished you could enhance your Python functions without modifying their core logic?
Enter Python decorators (no, not the decorator pattern) – a powerful feature that allows you to modify or extend the behaviour of functions and classes with just a simple @
symbol.
In this tutorial, we'll into decorators, how they can be useful to make your code more elegant, efficient, and maintainable.
In Python, functions are first-class objects. This means you can treat functions like any other object – assign them to variables, pass them as arguments, or return them from other functions.
Let's start with a simple example:
def product_name(name):
return f"Product Name: {name}!"
# Assigning a function to a variable
say_name = product_name
# Using the function through the variable
print(say_name("Claude Sonnet")) # Output: Product Name: Claude Sonnet!
Basic Decorator Syntax
At its core, a decorator is a function that takes another function as an argument and returns a new function that usually extends or modifies the behaviour of the original function. The @
symbol provides a clean, intuitive way to apply decorators.
Here's a simple decorator that wraps product name in an h2 heading:
def heading(func):
def wrapper(name):
result = func(name)
return f"<h2>{result}</h2>"
return wrapper
@heading
def product_name(name):
return f"Product Name: {name}!"
print(product_name('Claude Sonnet')) # Output: <h2>Product Name: Claude Sonnet!</h2>
In this example, heading
is a function that takes another function (func
) as an argument. It defines an inner function wrapper
that calls the original function,
wraps it in h2 tags, and returns it. The @heading
syntax is equivalent to product_name = heading(product_name)
.
How Decorators Work
To understand decorators better, let's break down their execution flow:
- When Python encounters a decorated function, it first executes the decorator function.
- The decorator function typically defines and returns a new function.
- This wrapper function is then bound to the original function's name.
- When the decorated function is called, it's actually the wrapper function that gets executed.
Here's a visual representation of this flow:
@decorator
def original_func():
# Implementation
↓
original_func = decorator(original_func)
↓
def wrapper():
# Optionally do some pre-processing
result = original_func()
# Optionally do some post-processing
return result
original_func = wrapper
This mechanism allows decorators to perform actions before and after the decorated function runs, modify its arguments or return value, or even prevent it from running altogether e.g. if some conditions have not been met.
Creating Your Own Decorators
Now that we understand the basics, let's create a more practical decorator. We'll implement a timing decorator that measures how long a function takes to execute:
import time
def measure_time(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:.5f} seconds to run.")
return result
return wrapper
@measure_time
def slow_function():
time.sleep(2)
print("Function execution complete.")
slow_function()
Note that this decorator uses *args
and **kwargs
to accept any number of arguments, making it more versatile.
It measures the time before and after executing the function, prints the execution time, and returns the original result.
Here is another trivial one which decorates a function returning a csv string to return a dataframe instead.
import pandas as pd
from io import StringIO
def csv_to_dataframe(func):
def wrapper(*args, **kwargs):
csv_string = func(*args, **kwargs)
if not isinstance(csv_string, str):
raise ValueError("The decorated function must return a CSV string")
# Convert CSV string to DataFrame
df = pd.read_csv(StringIO(csv_string))
return df
return wrapper
@csv_to_dataframe
def get_csv_data():
return """
name,age,city
Alice,28,New York
Bob,35,Cape Town
Charlie,42,London
"""
# Using the decorated function
result_df = get_csv_data()
print(result_df)
Decorators with Arguments
Decorators can also accept arguments, allowing for even more flexibility. Let's create a decorator that retries a function a specified number of times:
import time
import random
def retry(max_attempts=3, delay=1):
def decorator(func):
def wrapper(*args, **kwargs):
attempts = 0
while attempts < max_attempts:
try:
return func(*args, **kwargs)
except Exception as e:
print(f"Attempt {attempts + 1} failed: {str(e)}")
attempts += 1
time.sleep(delay)
raise Exception(f"Function failed after {max_attempts} attempts")
return wrapper
return decorator
@retry(max_attempts=3, delay=1)
def unreliable_function():
if random.random() < 0.7:
raise Exception("Random error occurred")
return "Success!"
print(unreliable_function())
This decorator takes arguments max_attempts
and delay
, defining how many times to retry and how long to wait between attempts.
Indeed decorators can be used to add robust error handling to your functions.
Class Decorators
Decorators are not limited to functions - they can also be applied to classes. This example demonstrates implementing the singleton pattern using a class decorator for a shared resource:
def singleton(cls):
instances = {}
def get_instance(*args, **kwargs):
if cls not in instances:
instances[cls] = cls(*args, **kwargs)
return instances[cls]
return get_instance
@singleton
class DatabaseConnection:
def __init__(self):
print("Initialising database connection")
# Creating multiple instances
db1 = DatabaseConnection()
db2 = DatabaseConnection()
print(db1 is db2) # Output: True
This decorator ensures that only one instance of the DatabaseConnection
class is ever created, which can be useful for managing resources like database connections.
Built-in Decorators
Python provides several built-in decorators that you'll often encounter:
@property
The @property
decorator allows you to define methods that can be accessed like attributes:
class BankAccount:
def __init__(self, account_number, balance):
self._account_number = account_number
self._balance = balance
@property
def account_number(self):
return self._account_number
@property
def balance(self):
return self._balance
@property
def is_premium(self):
return self._balance >= 10000
account = BankAccount("12345", 50000)
print(f"Account Number: {account.account_number}") # Output: 12345
print(f"Current Balance: £{account.balance}") # Output: £50000
print(f"Is Premium Account: {account.is_premium}") # Output: True
@classmethod and @staticmethod
These decorators are used to define methods that don't require access to instance-specific data:
class MyClass:
@classmethod
def class_method(cls):
print("This is a class method, strangely")
@staticmethod
def static_method():
print("This is a static method")
MyClass.class_method() # Output: This is a class method, strangely
MyClass.static_method() # Output: This is a static method
Some Real-world Applications
Decorators have numerous practical applications:
- Logging: Add logging to functions without cluttering their code.
- Authentication: Check user permissions before allowing access to certain functions.
- Memoisation: Cache function results to improve performance.
Here's a simple memoisation example:
def memoise(func):
cache = {}
def wrapper(*args):
if args in cache:
return cache[args]
result = func(*args)
cache[args] = result
return result
return wrapper
@memoise
def fibonacci(n):
if n < 2:
return n
return fibonacci(n-1) + fibonacci(n-2)
print(fibonacci(100)) # Calculates quickly due to memoisation rather than recomputing at each turn
Or a simple FastAPI get route:
from fastapi import FastAPI
app = FastAPI()
@app.get("/greeting")
async def greeting():
return {"message": "Hello World"}
Best Practices and Pitfalls
When working with decorators, keep these best practices in mind:
-
Use
functools.wraps
to preserve function metadata:from functools import wraps def my_decorator(func): @wraps(func) def wrapper(*args, **kwargs): return func(*args, **kwargs) return wrapper @my_decorator def do_something(): print('doing something...') # Prints correct function name print(do_something.__name__) # Output: do_something # without the @wraps line, function name is lost print(do_something.__name__) # Output: wrapper
-
Be cautious with mutable arguments in decorators or decorators that modify function signatures.
-
Avoid excessive nesting of decorators, as it can make code hard to read.
-
Remember that decorators are executed at function definition time, not at call time.