Python - Decorators

13.
Explain the significance of decorators in the context of memoization.

Memoization is a technique used to optimize the performance of functions by caching their results and returning the cached result when the same inputs occur again. Decorators provide an elegant way to implement memoization, improving the efficiency of functions with repetitive or expensive computations.

Here's an example to illustrate the significance of decorators in memoization:

# Memoization decorator
def memoize(func):
    cache = {}

    def wrapper(*args):
        if args not in cache:
            print(f"Computing result for {func.__name__}{args} and caching it.")
            cache[args] = func(*args)
        else:
            print(f"Using cached result for {func.__name__}{args}.")
        return cache[args]

    return wrapper

# Function to be memoized
@memoize
def fibonacci(n):
    if n <= 1:
        return n
    else:
        return fibonacci(n - 1) + fibonacci(n - 2)

# Using the memoized function
result1 = fibonacci(5)
result2 = fibonacci(8)
result3 = fibonacci(5)

In this example:

  • Memoization Decorator: The memoize decorator is a higher-order function that maintains a cache dictionary to store the results of function calls. The wrapper function checks if the result for a given set of arguments is already in the cache. If it is, the cached result is returned; otherwise, the original function is called, and the result is stored in the cache.

  • Fibonacci Function: The fibonacci function is decorated with @memoize. As a result, subsequent calls to the fibonacci function with the same arguments benefit from the memoization mechanism.

Output:

Computing result for fibonacci(5) and caching it.
Computing result for fibonacci(4) and caching it.
Computing result for fibonacci(3) and caching it.
Computing result for fibonacci(2) and caching it.
Computing result for fibonacci(1) and caching it.
Computing result for fibonacci(0) and caching it.
Using cached result for fibonacci(1).
Using cached result for fibonacci(2).
Using cached result for fibonacci(3).
Using cached result for fibonacci(4).
Using cached result for fibonacci(5).
Computing result for fibonacci(8) and caching it.
Computing result for fibonacci(7) and caching it.
Computing result for fibonacci(6) and caching it.
Using cached result for fibonacci(5).
Using cached result for fibonacci(4).
Using cached result for fibonacci(3).
Using cached result for fibonacci(2).
Using cached result for fibonacci(1).

Memoization using decorators significantly improves the performance of recursive or computationally expensive functions by avoiding redundant computations. It demonstrates how decorators can be powerful tools for enhancing the efficiency of functions without modifying their original code.


14.
What is the role of decorators in enforcing access control in Python?

Decorators in Python can be utilized to enforce access control by restricting or allowing access to certain functions or methods based on specified conditions. This is achieved by creating access control decorators that check user permissions or other conditions before allowing the execution of the function.

Here's an example to illustrate the role of decorators in enforcing access control:

# Access control decorator
def access_control(required_permission):
    def decorator(func):
        def wrapper(user, *args, **kwargs):
            if user['permission'] >= required_permission:
                print(f"Access granted. Calling {func.__name__}.")
                result = func(*args, **kwargs)
            else:
                print("Access denied. Insufficient permission.")
                result = None
            return result
        return wrapper
    return decorator

# Function with access control
@access_control(required_permission=2)
def restricted_function():
    print("Executing restricted function.")

# User data
user1 = {'username': 'Admin', 'permission': 3}
user2 = {'username': 'User', 'permission': 1}

# Using the decorated function
result1 = restricted_function(user1)
result2 = restricted_function(user2)

In this example:

  • Access Control Decorator: The access_control decorator is a higher-order function that takes a required permission level as an argument. The decorator function checks if the user's permission level is greater than or equal to the required permission level. If it is, the original function is called; otherwise, access is denied.

  • Restricted Function: The restricted_function is decorated with @access_control with a required permission level of 2. This means only users with a permission level of 2 or higher can execute this function.

Output:

Access granted. Calling restricted_function.
Executing restricted function.
Access denied. Insufficient permission.

The access control decorator provides a way to modularize access control logic and apply it consistently across different functions or methods. It demonstrates how decorators can be employed to enhance security and enforce restrictions in a Python codebase.


15.
How do you create a decorator that logs information about function calls?

A logging decorator can be created to log information about function calls, such as the function name, arguments, and timestamp. This helps in monitoring and debugging the execution flow of the program.

import functools
import datetime

# Logging decorator
def log_function_call(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        argument_str = ', '.join([repr(arg) for arg in args])
        keyword_argument_str = ', '.join([f"{key}={repr(value)}" for key, value in kwargs.items()])
        
        print(f"[{timestamp}] Calling {func.__name__} with arguments: {argument_str}, keyword arguments: {keyword_argument_str}")
        
        result = func(*args, **kwargs)
        
        print(f"[{timestamp}] {func.__name__} returned: {repr(result)}")
        
        return result

    return wrapper

# Function with logging
@log_function_call
def add(a, b):
    return a + b

@log_function_call
def greet(name):
    return f"Hello, {name}!"

# Using the decorated functions
result1 = add(3, 5)
result2 = greet("Alice")

In this example:

  • Logging Decorator: The log_function_call decorator is a higher-order function that wraps the original function. It logs information about the function call, including the timestamp, function name, arguments, and keyword arguments, before and after the function is executed.

  • Decorated Functions: The @log_function_call decorator is applied to the add and greet functions, enabling logging for these functions.

Output:

[2022-02-10 15:30:00] Calling add with arguments: 3, 5, keyword arguments: 
[2022-02-10 15:30:00] add returned: 8
[2022-02-10 15:30:00] Calling greet with arguments: 'Alice', keyword arguments: 
[2022-02-10 15:30:00] greet returned: 'Hello, Alice!'

The logging decorator provides valuable information about when functions are called, what arguments they receive, and what they return. This can be particularly useful for debugging and understanding the flow of a program.


16.
Discuss the use of decorators in measuring the execution time of functions.

Decorators can be employed to measure the execution time of functions, providing insights into their performance. This is useful for profiling and optimizing code to enhance its efficiency.

import functools
import time

# Timing decorator
def timing_decorator(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start_time = time.time()
        result = func(*args, **kwargs)
        end_time = time.time()
        execution_time = end_time - start_time

        print(f"{func.__name__} took {execution_time:.6f} seconds to execute.")
        
        return result

    return wrapper

# Function with timing
@timing_decorator
def slow_function():
    time.sleep(2)
    print("Function executed.")

@timing_decorator
def fast_function():
    print("Function executed quickly.")

# Using the decorated functions
slow_function()
fast_function()

In this example:

  • Timing Decorator: The timing_decorator is a higher-order function that wraps the original function. It records the start time, executes the function, calculates the execution time, and prints the result.

  • Decorated Functions: The @timing_decorator decorator is applied to both slow_function and fast_function. The timing information is displayed for each function call.

Output:

Function executed.
slow_function took 2.000000 seconds to execute.
Function executed quickly.
fast_function took 0.000000 seconds to execute.

The timing decorator provides a straightforward way to measure the execution time of functions. This information is valuable for identifying bottlenecks and optimizing code for improved performance.