Python - Decorators

9.
What is the purpose of the functools.wraps decorator in Python?

In Python, the functools.wraps decorator is used to preserve the original function's metadata, such as its name, docstring, and other attributes, when creating a decorator. It helps in maintaining clarity and consistency in the code, especially when multiple decorators are applied to a function.

Here's an example to illustrate the purpose of functools.wraps:

import functools

# Decorator without functools.wraps
def decorator_without_wraps(func):
    def wrapper():
        """Wrapper function without wraps."""
        print(f"Calling {func.__name__}")
        func()
    return wrapper

# Decorator with functools.wraps
def decorator_with_wraps(func):
    @functools.wraps(func)
    def wrapper():
        """Wrapper function with wraps."""
        print(f"Calling {func.__name__}")
        func()
    return wrapper

# Applying decorators
@decorator_without_wraps
def function1():
    """Original function without wraps."""
    print("Function 1")

@decorator_with_wraps
def function2():
    """Original function with wraps."""
    print("Function 2")

# Output metadata
output_metadata1 = f"Function 1: {function1.__name__}, Docstring: {function1.__doc__}"
output_metadata2 = f"Function 2: {function2.__name__}, Docstring: {function2.__doc__}"

In this example:

  • decorator_without_wraps is a decorator without using functools.wraps.
  • decorator_with_wraps is a decorator using functools.wraps to preserve the original function's metadata.
  • @decorator_without_wraps and @decorator_with_wraps are applied to function1 and function2 respectively.

When you run this program, you will get the following output:

Calling wrapper
Function 1: wrapper, Docstring: Wrapper function without wraps.
Calling function2
Function 2: function2, Docstring: Original function with wraps.

Without functools.wraps, the metadata of the original function is not preserved, resulting in the decorator's wrapper function being displayed in the output. Using functools.wraps ensures that the original function's metadata is maintained, improving the clarity of the code.


10.
Explain the potential use cases for decorators in Python.

Decorators in Python offer a powerful and flexible way to modify or extend the behavior of functions or methods. They are versatile and can be used in various scenarios to enhance code readability, maintainability, and reusability.

Here are some potential use cases for decorators:

  1. Logging: Adding logging statements before and after function calls.

    def log_function_call(func):
        def wrapper(*args, **kwargs):
            print(f"Calling {func.__name__} with arguments {args} and keyword arguments {kwargs}")
            result = func(*args, **kwargs)
            print(f"{func.__name__} returned {result}")
            return result
        return wrapper
    
    @log_function_call
    def add(a, b):
        return a + b
    
    # Using the decorated function
    result = add(3, 5)
    

    Output:

    Calling add with arguments (3, 5) and keyword arguments {}
    add returned 8
    
  2. Timing: Measuring the execution time of functions.

    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:.2f} seconds to execute.")
            return result
        return wrapper
    
    @timing
    def slow_function():
        time.sleep(2)
        print("Function executed.")
    
    # Using the decorated function
    slow_function()
    

    Output:

    Function executed.
    slow_function took 2.00 seconds to execute.
    
  3. Authorization: Checking user permissions before allowing function execution.

    def check_permission(func):
        def wrapper(user, *args, **kwargs):
            if user.is_admin:
                result = func(user, *args, **kwargs)
            else:
                result = "Permission denied."
            return result
        return wrapper
    
    @check_permission
    def admin_only_function(user):
        return f"Welcome, {user.username}! Admin privileges granted."
    
    # Using the decorated function
    user1 = {'username': 'Admin', 'is_admin': True}
    result = admin_only_function(user1)
    

    Output:

    Welcome, Admin! Admin privileges granted.
    

11.
How do decorators enhance code readability and reusability?

Decorators enhance code readability and reusability by promoting a modular and clean code structure. They allow developers to separate concerns, improve the organization of code, and encapsulate common functionalities. Let's explore these benefits with an example:

# Decorator for logging
def log_function_call(func):
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__} with arguments {args} and keyword arguments {kwargs}")
        result = func(*args, **kwargs)
        print(f"{func.__name__} returned {result}")
        return result
    return wrapper

# Decorator for timing
def timing(func):
    import time
    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:.2f} seconds to execute.")
        return result
    return wrapper

# Original function
@log_function_call
@timing
def add(a, b):
    return a + b

# Using the decorated function
result = add(3, 5)

In this example:

  • Readability: Decorators allow developers to express the intent and behavior of a function clearly without cluttering its implementation. In this case, the @log_function_call and @timing decorators provide logging and timing functionality respectively, making the code self-explanatory.

  • Reusability: Decorators encapsulate common functionalities, making them reusable across different functions. For example, the log_function_call and timing decorators can be applied to various functions without duplicating the logging and timing code in each function.

Output:

Calling add with arguments (3, 5) and keyword arguments {}
add took 0.00 seconds to execute.
add returned 8

By using decorators, the original function add remains focused on its core logic, while the decorators handle additional concerns. This separation of concerns improves code readability and makes it easier to maintain and reuse functionalities across different parts of the codebase.


12.
Discuss the difference between class-based and function-based decorators.

In Python, decorators can be implemented using either functions or classes. Both approaches achieve the same goal of extending or modifying the behavior of functions, but they differ in their syntax and the way they handle the wrapping of functions.

Here's an example to illustrate the difference between class-based and function-based decorators:

# Function-based decorator
def function_decorator(func):
    def wrapper(*args, **kwargs):
        print("Function Decorator: Before function is called.")
        result = func(*args, **kwargs)
        print("Function Decorator: After function is called.")
        return result
    return wrapper

# Class-based decorator
class ClassDecorator:
    def __init__(self, func):
        self.func = func

    def __call__(self, *args, **kwargs):
        print("Class Decorator: Before function is called.")
        result = self.func(*args, **kwargs)
        print("Class Decorator: After function is called.")
        return result

# Applying function-based decorator
@function_decorator
def function_example():
    print("Inside the original function.")

# Applying class-based decorator
@ClassDecorator
def class_example():
    print("Inside the original function.")

# Using decorated functions
function_example()
class_example()

In this example:

  • Function-based Decorator: The function_decorator is a function that takes a function as an argument, wraps it, and returns the wrapper function. The decorator is applied using the @ syntax.

  • Class-based Decorator: The ClassDecorator is a class with a __init__ method to initialize the instance with the original function, and a __call__ method to define the behavior of the wrapper function. The decorator is applied using the @ syntax as well.

Output:

Function Decorator: Before function is called.
Inside the original function.
Function Decorator: After function is called.
Class Decorator: Before function is called.
Inside the original function.
Class Decorator: After function is called.

Both approaches achieve the same result, but class-based decorators offer additional opportunities for encapsulation and maintaining state across multiple calls since they can have instance variables. Function-based decorators are simpler and more concise, making them a common choice for many use cases.