# Python Decorators: A Comprehensive Guide

## Core Concepts of Decorators

### What is a Decorator?

* A decorator is a function that takes another function as an argument, adds some functionality (via a wrapper), and returns a new function that behaves like the original but with enhancements.
    
* **Syntax**: Use the @decorator\_name annotation right above a function definition to apply it. This is syntactic sugar for function = decorator\_name(function).
    
* **Key Components**:
    
    * **Outer Function**: Accepts the target function (e.g., func).
        
    * **Wrapper Function**: Nested inside the outer function; handles the actual logic (e.g., pre/post-execution actions) and calls the original function with *args and* \*kwargs for flexibility.
        
    * **Return**: The wrapper is returned, replacing the original function.
        
* **Why Use Decorators?**
    
    * Reusability: Apply the same enhancement (e.g., login check) to multiple functions.
        
    * Clean Code: Avoid repetitive boilerplate in function bodies.
        
    * Common in Frameworks: Like Django's @login\_required for route protection.
        

### Basic Structure

Python

```python
def my_decorator(func):
    def wrapper(*args, **kwargs):  # Handles any arguments
        # Pre-execution logic (e.g., print start time)
        result = func(*args, **kwargs)  # Execute original function
        # Post-execution logic (e.g., print end time)
        return result  # Return original result
    return wrapper

@my_decorator
def my_function(x):
    return x * 2

# Usage: my_function(5)  # Enhanced automatically
```

### Handling Arguments

* Use *args for positional arguments and* \*kwargs for keyword arguments to make decorators universal.
    
* Access function metadata: func.\_\_name\_\_ for the name, func.\_\_doc\_\_ for documentation.
    

### Preserving Function Metadata

To keep the original function's name and docstring intact (useful for introspection), use functools.wraps:

Python

```python
from functools import wraps

def my_decorator(func):
    @wraps(func)  # Preserves metadata
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper
```

## Problem 1: Timing Function Execution

**Goal**: Create a decorator that measures and prints the time a function takes to execute. This is useful for performance profiling.

### Explanation

* Import time module.
    
* In the wrapper: Record start time before execution, end time after, and compute the difference.
    
* Print the function name and elapsed time using func.\_\_name\_\_.
    

### Code Example

Python

```python
import time

def timer(func):
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        end = time.time()
        print(f"{func.__name__} ran in {end - start:.4f} secs")  # Formatted for readability
        return result
    return wrapper

@timer
def example_function(n):
    time.sleep(n)  # Simulate work

# Usage
example_function(2)  # Output: example_function ran in 2.0012 secs
```

### Key Takeaways

* **Pros**: Non-intrusive; works on any function.
    
* **Edge Cases**: Handles async functions poorly (use asyncio for those).
    
* **Extensions**: Log to file instead of printing, or return time as a value.
    

## Problem 2: Debugging Function Calls

**Goal**: Build a decorator that prints the function name and argument values every time the function is called. Ideal for troubleshooting.

### Explanation

* In the wrapper: Convert \*args to a comma-separated string using list comprehension and str().
    
* For \*\*kwargs: Iterate over items and format as key:value.
    
* Print a formatted message before executing the function.
    

### Code Example

Python

```python
def debug(func):
    def wrapper(*args, **kwargs):
        args_value = ', '.join(str(arg) for arg in args)
        kwargs_value = ', '.join(f"{k}: {v}" for k, v in kwargs.items())
        print(f"Calling '{func.__name__}' with args [{args_value}] and kwargs [{kwargs_value}]")
        return func(*args, **kwargs)
    return wrapper

@debug
def hello():
    print("Hello")

@debug
def greet(name, greeting="Hello"):
    print(f"{greeting}, {name}!")

# Usage
hello()  # Output: Calling 'hello' with args [] and kwargs []
         #         Hello

greet("Alice", greeting="Hi")  # Output: Calling 'greet' with args [Alice] and kwargs [greeting: Hi]
                               #         Hi, Alice!
```

### Key Takeaways

* **Pros**: Great for logging inputs without modifying function code.
    
* **Edge Cases**: Handle empty args/kwargs gracefully (e.g., empty strings).
    
* **Extensions**: Add timestamps or log levels (e.g., integrate with logging module).
    

## Problem 3: Caching Return Values

**Goal**: Implement a decorator that stores function results in a cache (dictionary). On repeated calls with identical arguments, return the cached value instead of re-executing. Useful for expensive computations (e.g., API calls).

### Explanation

* Use a dictionary (cache\_values) to store results, keyed by tuple of \*args (tuples are hashable).
    
* In the wrapper: Check if args are in cache; if yes, return cached result. Otherwise, execute, cache, and return.
    
* Note: Ignores \*\*kwargs for simplicity; extend with args + tuple(sorted(kwargs.items())) for full support.
    

### Code Example

Python

```python
import time

def cache(func):
    cache_values = {}  # Function-local cache
    def wrapper(*args):
        if args in cache_values:
            print(f"Cache hit for {args}")  # Optional debug
            return cache_values[args]
        result = func(*args)
        cache_values[args] = result
        print(f"Cache miss; computed {result} for {args}")  # Optional debug
        return result
    return wrapper

@cache
def long_running_function(a, b):
    time.sleep(3)  # Simulate expensive operation
    return a + b

# Usage
print(long_running_function(2, 3))  # Waits 3s: Cache miss; computed 5 for (2, 3) \n 5
print(long_running_function(2, 3))  # Instant: Cache hit for (2, 3) \n 5
print(long_running_function(4, 1))  # Waits 3s: Cache miss; computed 5 for (4, 1) \n 5
```

### Key Takeaways

* **Pros**: Dramatically improves performance for idempotent functions.
    
* **Edge Cases**: Cache grows indefinitely (use LRU cache via functools.lru\_cache for limits). Not thread-safe without locks.
    
* **Extensions**: Add expiration (TTL) or persist to file/database.
    

## Comparison of Examples

| Decorator | Purpose | Key Wrapper Logic | Use Case Example |
| --- | --- | --- | --- |
| **Timer** | Measure execution time | start/end = time.time(); print diff | Profiling slow functions |
| **Debug** | Log calls & args | Join args/kwargs as strings; print | Debugging inputs/outputs |
| **Cache** | Memoize results | Dict check/insert on args; return cached | Avoiding recomputation |
