Skip to content

Decorators: Decorators are like developers crown

Blog Image 1

Introduction to Decorators

Have you ever wanted to add extra behavior to a function without actually modifying its code? That's what decorators in Python allow you to do. Decorators act like wrappers that modify the behavior of the function they wrap. To put it simply, consider this example:

def simple_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

@simple_decorator
def say_hello():
    print("Hello!")

if __name__ == '__main__':
    say_hello()

# Output:
# Something is happening before the function is called.
# Hello!
# Something is happening after the function is called.

The @simple_decorator is altering the behavior of say_hello() by adding print statements before and after its execution.

What Can Decorators Do For You?

Decorators serve as versatile tools, capable of logging, enforcing access control, caching, and more. They enable you to segregate responsibilities in your codebase. Essentially, they can add features to your functions or classes without altering their structure, making your code more modular and easier to manage.

Logging Function Calls

Imagine you're deep into debugging, and you want to understand which functions are being called, along with the arguments they receive. Here's where a logging decorator can come in handy:

import functools

def log_function_calls(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__} with arguments: {args}, {kwargs}")
        result = func(*args, **kwargs)
        print(f"{func.__name__} returned: {result}")
        return result
    return wrapper

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

This logs function names, arguments, and return values, saving you from littering your codebase with numerous print statements.

Debugging and Timing

You might sometimes wonder why a particular piece of code is taking too long to execute. Rather than manually calculating the execution time, why not use a decorator to do that?

import functools
import time

def debug_with_timing():
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            start_time = time.time()
            result = func(*args, **kwargs)
            end_time = time.time()
            elapsed_time = end_time - start_time
            print(f"Function {func.__name__} took {elapsed_time:.6f} seconds to execute.")
            return result
        return wrapper
    return decorator

@debug_with_timing
def long_running_function():
    time.sleep(2)
    return "Done"

This decorator measures the time taken by a function to execute and logs it, helping you identify bottlenecks.

Marking Functions as Deprecated

So, you've found a more efficient way of doing something and no longer want anyone to use the old method. How do you warn them? The answer is a decorator that marks the old function as deprecated.

import functools
import warnings

def deprecated(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        warnings.warn(f"Function {func.__name__} is deprecated.", 
                      category=DeprecationWarning, 
                      stacklevel=2)
        return func(*args, **kwargs)
    return wrapper

@deprecated
def old_function():
    return "This function is outdated."

Using this decorator, a deprecation warning is issued whenever the function is called.

Implementing Retries

We all know that certain operations, especially the ones involving networks or databases, can fail sporadically. How about a decorator that allows your function to retry a few times before giving up?

import functools
import time

def retry_on_failure(max_retries=3, delay=1):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for _ in range(max_retries):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    print(f"Failed with error: {e}. Retrying in {delay} seconds...")
                    time.sleep(delay)
            raise Exception(f"Function {func.__name__} failed after {max_retries} retries.")
        return wrapper
    return decorator

@retry_on_failure()
def api_request(url):
    # Code for making an API request goes here
    ...

This decorator will attempt to re-run the function up to a specified number of times if it encounters an error, making your system more resilient to transient failures.

Memoization

Have a function that gets called multiple times with the same arguments? You might be wasting computation cycles recalculating the same values again and again. Here's where memoization comes in handy:

import functools

def memoize(func):
    cache = {}
    @functools.wraps(func)
    def wrapper(*args):
        if args not in cache:
            cache[args] = func(*args)
        return cache[args]
    return wrapper

@memoize
def fibonacci(n):
    if n <= 1:
        return n
    return fibonacci(n-1) + fibonacci(n-2)

This decorator caches the return value of functions, so if you call it again with the same arguments, the cached value is returned instead of re-running the function.

Enforcing Access Control

Security is a big deal. We often need to restrict who can do what in our applications. This decorator helps enforce such access controls:

import functools

def is_authenticated():
    # Authentication check
    ...

def requires_authentication(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        if is_authenticated():
            return func(*args, **kwargs)
        else:
            raise PermissionError("Authentication required.")
    return wrapper

@requires_authentication
def sensitive_operation():
    # Code for sensitive operation goes here
    ...

This decorator checks for authentication and only proceeds with function execution if the user is authenticated, safeguarding sensitive operations in your system.

Conclusion

Decorators offer a powerful, flexible way to modify the behavior of your functions or methods. They can be your best friend for debugging, performance tuning, access control, and many other tasks. By using them effectively, you're well on your way to writing cleaner, more efficient, and more maintainable Python code.

Hope you found this enlightening! Happy Coding!


Last update: December 12, 2023