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 call func) and defines a new inner function called wrapper.
  • The wrapper function prints a message before and after calling the original func function.
  • The my_decorator function then returns the wrapper 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:

from functools import wraps

def decorator_function(func):
    """
    A generic decorator function template.
    
    :param func: The function to be decorated.
    :return: The decorated function.
    """
    @wraps(func)
    def wrapper(*args, **kwargs):
        """
        The wrapper function that will be executed when the decorated function is called.
        
        :param *args: Positional arguments passed to the decorated function.
        :param **kwargs: Keyword arguments passed to the decorated function.
        :return: The result of the decorated function.
        """
        # Add any pre-function execution code here.
        # For example, logging, timing, or input validation.

        result = func(*args, **kwargs)  # Call the decorated function.

        # Add any post-function execution code here.
        # For example, logging, timing, or output validation.

        return result

    return wrapper

Check out this GitHub Gist for more Python decorator templates

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:

  1. Logging
  2. Error handling
  3. Caching
  4. Authentication (see example in Flask)
  5. Timing a function execution (see an advanced example)
  6. 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.

Hosted on Digital Ocean.