Python Decorator: Explained in What Why When
Decorators are incredibly powerful in Python. They are useful for creating modular and reusable code. Plus, they're pretty cool once you get the hang of them!
In this blog post, we'll take a look at what decorators are, why you should use them, and when you should and shouldn't use them.
As always, I’ll try to explain everything in layman's terms. My goal is to get you comfortable with decorators.
TL;DR
- Think of Python decorators as little "wrappers" that you can place around a function (or another object) to modify its behavior in some way.
- You would typically use decorators for logging, authentication, or to make a function run faster by caching its result.
What are Decorators
Let’s start with a simple explanation of what decorators are in Python.
A decorator is really just a function that takes in a function as an argument. It then returns a modified version of that function.
Here’s a classic example:
def my_decorator(func):
def wrapper():
print("Before the function is called.")
func()
print("After the function is called.")
return wrapper
- In this example, the
my_decorator
function takes a function as an argument (which we'll callfunc
) and defines a new inner function calledwrapper
. - The
wrapper
function prints a message before and after calling the originalfunc
function. - The
my_decorator
function then returns thewrapper
function.
To use a decorator, we would apply it to a function using the @
symbol, e.g. @my_decorator
. Here’s an example:
def my_decorator(func):
...
@my_decorator
def greet_ben():
print("Hello, Ben!")
If we were to call greet_ben
, the output would look like this:
Before the function is called.
Hello, Ben!
After the function is called.
See how we effectively modified greet_ben
to print additional messages without changing the code of the original function itself?
Syntactic sugar
You see, the decorator syntax @my_decorator
is merely syntactic sugar. The 2 following function definitions are semantically equivalent:
def my_decorator(func):
...
# Function definition 1
def greet_ben():
...
greet_ben = my_decorator(greet_ben)
# Function definition 2
@my_decorator
def greet_ben():
...
Interesting, right?
Python decorator template
Now, hold your thought for a second, that was not how we would typically write a decorator function in Python — there are better ways.
Instead, the following template can be used as a starting point for creating custom decorators:
You can modify the pre-function and post-function execution code sections to implement the desired behavior for your specific use case.
Why do we need functools.wraps
?
Using from functools import wraps
and applying the @wraps(func)
decorator to the wrapper
function is not strictly necessary, but it provides some important benefits when creating decorators.
When you create a decorator without using wraps
, the decorated function's metadata, such as its name, docstring, and module, are replaced by the metadata of the wrapper
function. This can lead to confusion and make debugging more difficult.
Here's an example to illustrate the issue:
def decorator_function(func):
def wrapper():
func()
return wrapper
@decorator_function
def my_function():
"""This is my_function's docstring."""
pass
print(my_function.__name__) # Output: 'wrapper'
print(my_function.__doc__) # Output: None
As you can see, the name and docstring of my_function
are lost when it's decorated without using wraps
.
Now, let's use wraps
:
from functools import wraps
def decorator_function(func):
@wraps(func)
def wrapper():
func()
return wrapper
@decorator_function
def my_function():
"""This is my_function's docstring."""
pass
print(my_function.__name__) # Output: 'my_function'
print(my_function.__doc__) # Output: "This is my_function's docstring."
By using @wraps(func)
, the decorated function retains its original metadata, making it easier to understand and debug.
Overall, using functools.wraps
is highly recommended when creating decorators in Python as it helps to create more reliable, maintainable, and compatible decorators that work well with other tools and libraries.
Why Use Decorators
Understanding the “why” is kind of like adding the icing on the cake — it just makes the whole thing a lot sweeter. So why use decorators at all?
Well, decorators provide a flexible and convenient way to add extra functionality to existing functions, making them more reusable and modular.
This is useful when you want to add a feature to a function that is used in multiple places in your code, but you don't want to modify the original function itself.
Avoid cluttering
For example, you might want to add logging or error handling to a function. But, you don't want to clutter the original function with that extra code.
By using a decorator, you can add extra functionality without changing the original function, making your code cleaner and more organized.
When to Use Decorators
Now that we know why we should use decorators, let's talk about when to use them.
Generally speaking, Python decorators are most useful when you want to add extra functionality to a function without modifying its code. This might include things like:
- Logging
- Error handling
- Caching
- Authentication (see example in Flask)
- Timing a function execution (see an advanced example)
- Backoff and retry
A caching example
Here's an example of how you might implement a simple cache for a Fibonacci function:
cache = {}
def fib(n):
if n in cache:
return cache[n]
if n <= 1:
return n
result = fib(n - 1) + fib(n - 2)
cache[n] = result
return result
print(fib(100)) # 354224848179261915075
Notice that the caching code lives inside the fib
function itself.
However, with decorators, we can simply define a caching decorator and use it to add caching functionality to our Fibonacci function:
cache = {}
def cache_results(func):
def wrapper(n):
if n in cache:
return cache[n]
result = func(n)
cache[n] = result
return result
return wrapper
@cache_results
def fib(n):
if n <= 1:
return n
return fib(n - 1) + fib(n - 2)
print(fib(100)) # 354224848179261915075
- Here, we've implemented our own simple cache using a dictionary.
- We've also defined our own decorator function
cache_results
that takes a function as an argument and returns a new function that wraps the original function. - This inner function checks the cache to see if a result has already been calculated, and if so, it returns the cached result.
- Otherwise, it calculates the result and adds it to the cache before returning it.
- This way, we can apply the
cache_results
decorator to any function we want to add caching for.
While both examples work, the latter allows us to easily add caching to other functions without having to write the caching code inside each function.
This makes the caching code in our example more reusable and modular!
A better example with built-in modules!
Instead of reinventing the wheel, we can use the lru_cache
decorator from the functools
module to add caching to your fib
function:
from functools import lru_cache
@lru_cache(maxsize=None)
def fib(n):
if n <= 1:
return n
return fib(n - 1) + fib(n - 2)
print(fib(100)) # 354224848179261915075
- Using
lru_cache
decorator is really easy – all you have to do is add the decorator to your function and it automatically handles the caching for you. - So, using
lru_cache
can be a great way to optimize your code without a lot of extra effort.
Again, this is just one example of how you can use decorators to make your code more efficient and elegant.
When NOT to Use Decorators
So, when should you avoid using decorators?
Well, decorators are great for keeping your code clean and easy to read, but they can make your code harder to understand if overused or used incorrectly.
In general, it's best to avoid using decorators if they make your code more complex than it needs to be, or if you're not sure how they work.
For instance, if you're working on a very small project and you don't need to keep track of how many times your functions are called, then you probably don't need to use a decorator.
In some cases, you may find that it might be more appropriate to modify the code of the original function directly, rather than using a decorator.
It's always a good idea to keep things simple and easy to understand!
Summary
In short, Python decorators are like little "wrappers" that you can place around a function (or another object) to modify its behavior in some way.
Decorators are useful for creating modular and reusable code in Python.
You can define a decorator once and then apply it to any number of functions. This allows you to easily add the same extra functionality to multiple functions without repeating yourself.