Python Decorators: Intermediate Concepts
Decorators are a powerful and versatile feature in Python that allow you to modify or enhance the behavior of functions or methods without actually changing their code. They are built upon the concept of first-class functions (functions can be passed as arguments, returned from other functions, and assigned to variables).
1. What are Decorators?
At their core, a decorator is a callable (usually a function) that takes another function as input, adds some functionality to it, and returns a modified function. Think of them as "wrappers" around functions.
Why use decorators?
- Code Reusability: Avoid repeating the same code (like logging, timing, authentication) across multiple functions.
- Readability: Keep your core function logic clean and focused by separating concerns like logging or authorization.
- Maintainability: Changes to the added functionality (e.g., logging format) only need to be made in one place – the decorator.
2. Basic Decorator Syntax
The @ symbol is syntactic sugar for applying a decorator. Here's a simple example:
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!")
say_hello()
Output:
Something is happening before the function is called.
Hello!
Something is happening after the function is called.
Explanation:
my_decoratoris the decorator function. It takesfunc(the function to be decorated) as an argument.wrapperis an inner function defined withinmy_decorator. This is where the added functionality goes.wrappercalls the original functionfunc().my_decoratorreturns thewrapperfunction.@my_decoratorabovesay_hellois equivalent tosay_hello = my_decorator(say_hello). It replaces the originalsay_hellowith thewrapperfunction.
3. Decorators with Arguments
Often, you'll want to pass arguments to your decorator to customize its behavior. This requires an extra layer of nesting.
def repeat(num_times):
def decorator_repeat(func):
def wrapper(*args, **kwargs):
for _ in range(num_times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator_repeat
@repeat(num_times=3)
def greet(name):
print(f"Hello, {name}!")
greet("Alice")
Output:
Hello, Alice!
Hello, Alice!
Hello, Alice!
Explanation:
repeat(num_times)is the outer function. It takes the arguments for the decorator (in this case,num_times).decorator_repeat(func)is the actual decorator function. It takes the function to be decorated (func).wrapper(*args, **kwargs)is the inner function that adds the functionality.*argsand**kwargsallow the wrapper to accept any number of positional and keyword arguments, and pass them on to the original function.- The
repeatfunction returnsdecorator_repeat.
4. Using functools.wraps
When you decorate a function, you lose some of its original metadata (like its __name__ and __doc__). functools.wraps helps preserve this metadata.
import functools
def my_decorator(func):
@functools.wraps(func) # Preserves func's metadata
def wrapper(*args, **kwargs):
"""Wrapper function documentation."""
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(x, y):
"""Adds two numbers."""
return x + y
print(add.__name__) # Output: add (instead of wrapper)
print(add.__doc__) # Output: Adds two numbers. (instead of Wrapper function documentation.)
Explanation:
functools.wraps(func)is a decorator itself. It updates thewrapperfunction to look like the originalfuncin terms of metadata. This is important for debugging, introspection, and documentation.
5. Class-Based Decorators
Decorators can also be implemented using classes. This is useful when you need to maintain state within the decorator.
class CountCalls:
def __init__(self, func):
self.func = func
self.call_count = 0
def __call__(self, *args, **kwargs):
self.call_count += 1
print(f"Function '{self.func.__name__}' called {self.call_count} times.")
return self.func(*args, **kwargs)
@CountCalls
def say_hi(name):
print(f"Hi, {name}!")
say_hi("Bob")
say_hi("Charlie")
say_hi("David")
Output:
Function 'say_hi' called 1 times.
Hi, Bob!
Function 'say_hi' called 2 times.
Hi, Charlie!
Function 'say_hi' called 3 times.
Hi, David!
Explanation:
CountCallsis a class that takes the function to be decorated in its__init__method.- The
__call__method makes the class instance callable (like a function). This is where the added functionality goes. - When
say_hiis called, the__call__method of theCountCallsinstance is executed, which increments thecall_countand then calls the originalsay_hifunction.
6. Common Use Cases
- Logging: Log function calls, arguments, and return values.
- Timing: Measure the execution time of functions.
- Authentication/Authorization: Check if a user is authorized to access a function.
- Caching: Store the results of expensive function calls to avoid recomputation.
- Input Validation: Validate function arguments before execution.
- Retry Logic: Automatically retry a function if it fails.
Decorators are a powerful tool for writing clean, reusable, and maintainable Python code. Understanding them is a key step towards becoming a more proficient Python programmer. Practice using them in different scenarios to solidify your understanding.