Python decorators are a special and powerful feature that lets you change or add to how functions or methods work.

Even though they're really useful, decorators can be confusing, especially for people who are new to Python or don't know much about functional programming.

This article will make Python decorators easier to understand by explaining what they are, when you should use them, and showing some advanced examples.


What is a Decorator?

In Python, a decorator is a special kind of function that lets you change or improve how another function or method works.

Decorators make it easy to add features like logging, authentication, caching, and more, without changing the original code directly.

They help keep your code clean, easy to read, and reusable.

Decorators are useful when you want to add extra functionality to existing code without altering its structure.

Basic Syntax of a Decorator

To use a decorator with a function, you place the decorator function's name above the function you want to modify, with the @ symbol in front of it. 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()

In this example:

  • my_decorator is the function that acts as a decorator.
  • wrapper is an inner function that wraps around the original say_hello function.
  • The line @my_decorator is a shorthand way to write say_hello = my_decorator(say_hello).

When you call say_hello(), it doesn't directly run the say_hello function. Instead, it calls the wrapper function, which then calls say_hello inside it. The output will be:

Something is happening before the function is called.
Hello!
Something is happening after the function is called.

When to Use Decorators

Decorators are especially helpful in these situations:

  • Cross-Cutting Concerns: Tasks like logging, authentication, and timing operations often need to be done in many different functions. Instead of repeating the same code, you can put these tasks into decorators.
  • Code Reusability: With decorators, you can write code that can be used again and again for many functions without copying and pasting.
  • Separation of Concerns: Decorators help keep business logic separate from other tasks, making the code cleaner and easier to manage.
  • DRY Principle: They follow the "Don't Repeat Yourself" rule by letting you reuse the same functions in different parts of your code.

Are you tired of writing the same old Python code? Want to take your programming skills to the next level? Look no further! This book is the ultimate resource for beginners and experienced Python developers alike.

Get "Python's Magic Methods - Beyond __init__ and __str__"

Magic methods are not just syntactic sugar, they're powerful tools that can significantly improve the functionality and performance of your code. With this book, you'll learn how to use these tools correctly and unlock the full potential of Python.

Advanced Use Cases for Python Decorators

Now that we have a basic understanding of what decorators are and when to use them, let's look at some advanced ways to use them.

#1 - Changing Function Arguments and Return Values

Decorators can be used to change the inputs given to a function or the value it returns.

This is especially helpful when you need to enforce rules or change data in some way:

def enforce_types(func):
    def wrapper(*args, **kwargs):
        new_args = []
        for arg in args:
            if isinstance(arg, int):
                new_args.append(float(arg))  # Convert int to float
            else:
                new_args.append(arg)
        return func(*new_args, **kwargs)

    return wrapper


@enforce_types
def add(a, b):
    return a + b


print(add(2, 3))  # Outputs 5.0

In this example, the enforce_types decorator changes integer arguments into floats before sending them to the add function.

#2 - Saving Function Results 

Caching is a common way to make your code run faster.

It stores the results of expensive function calls so that if the same function is called again with the same arguments, it can quickly return the saved result instead of doing the calculation again:

from functools import lru_cache


@lru_cache(maxsize=32)
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)


print(fibonacci(10))  # Outputs 55

In this example, the lru_cache decorator from the functools module saves the results of the fibonacci function.

This improves performance by avoiding unnecessary calculations.

#3 - Authorization and Authentication 

Decorators are often used to enforce security checks in web applications.

Below is a simple example where we check if a user is logged in before letting them do something important:

def requires_authentication(func):
    def wrapper(user, *args, **kwargs):
        if not user.get('authenticated', False):
            raise PermissionError("User is not authenticated.")
        return func(user, *args, **kwargs)

    return wrapper


@requires_authentication
def get_secret_data(user):
    return "Secret Data"


user = {'name': 'John', 'authenticated': True}
print(get_secret_data(user))  # Outputs "Secret Data"

user = {'name': 'Jane', 'authenticated': False}
print(get_secret_data(user))  # Raises PermissionError

This requires_authentication decorator checks if the user is logged in before allowing them to access the get_secret_data function.

#4 - Retry Logic 

Sometimes, you need to try again when a function fails due to temporary issues, like a network timeout.

Decorators are a great way to include this retry logic:

import time
import random


def retry(max_retries=3, delay=1):
    def decorator(func):
        def wrapper(*args, **kwargs):
            retries = 0
            while retries < max_retries:
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    print(f"Error: {e}. Retrying in {delay} seconds...")
                    time.sleep(delay)
                    retries += 1
            return func(*args, **kwargs)

        return wrapper

    return decorator


@retry(max_retries=5, delay=2)
def unstable_operation():
    if random.choice([True, False]):
        raise ValueError("Random error!")
    return "Success!"


print(unstable_operation())


# Outputs:
# Error: Random error!. Retrying in 2 seconds...
# Error: Random error!. Retrying in 2 seconds...
# Error: Random error!. Retrying in 2 seconds...
# Success!

Here, the retry decorator will try to run the unstable_operation function up to 5 times, waiting 2 seconds between each try.

If there's an error, the function will keep trying until it either succeeds or reaches the maximum number of tries.

#5 - Timing Functions 

Decorators can be used to measure how long it takes for a function to run.

This is really useful for testing and monitoring performance:

import time


def timing(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:.4f} seconds to execute.")
        return result

    return wrapper


@timing
def slow_function():
    time.sleep(2)
    return "Finished!"


print(slow_function())


# Outputs:
# slow_function took 2.0004 seconds to execute.
# Finished!

The timing decorator calculates and prints how long the slow_function takes to run.

When slow_function is called, it sleeps for 2 seconds, and the decorator measures this time and prints it afterward.

#6 - Decorating Classes and Methods 

Decorators can also be used on class methods.

This lets you enforce certain behaviors or change methods at the class level:

Tagged in: