Swiftorial Logo
Home
Swift Lessons
Tutorials
Learn More
Career
Resources

Python FAQ: Top Questions

10. What are Python decorators?

Python **decorators** are a powerful and elegant way to modify or enhance the behavior of functions or methods without permanently altering their source code. They are essentially **wrappers** that allow you to "decorate" a function, adding new functionality before or after the original function executes. This concept is built upon Python's ability to treat functions as first-class objects, meaning they can be passed as arguments, returned from other functions, and assigned to variables.

### How Decorators Work:

A decorator is a function that takes another function as an argument, adds some functionality, and returns a new function (or a modified version of the original). The `@` syntax is just syntactic sugar for a common pattern:


@decorator_function
def original_function():
    # ... code ...
          

is equivalent to:


def original_function():
    # ... code ...
original_function = decorator_function(original_function)
          

### Key Concepts and Benefits:

  • Functions as First-Class Objects: Python's ability to treat functions like any other object (assign them to variables, pass them as arguments, return them from other functions) is the foundation of decorators.
  • Higher-Order Functions: Decorators are a type of higher-order function—a function that takes one or more functions as arguments or returns a function as its result.
  • Code Reusability: Decorators promote the **Don't Repeat Yourself (DRY)** principle. Common functionalities (like logging, timing, authentication, caching, error handling) can be implemented once as a decorator and then applied to multiple functions without duplicating code.
  • Separation of Concerns: They help separate cross-cutting concerns (e.g., logging) from the core business logic of a function, leading to cleaner and more modular code.
  • Readability and Maintainability: Using decorators with the `@` syntax makes code more readable by clearly indicating that a function's behavior is being augmented. It also makes maintenance easier, as changes to the added functionality only need to be made in one place (the decorator definition).
  • Preserving Original Functionality: Decorators typically wrap the original function in an inner function, allowing the original function's core logic to execute while adding surrounding features.

Decorators are widely used in Python frameworks (e.g., `@app.route` in Flask, `@login_required` in Django) and in various utility libraries to add non-core functionalities.


import time
import functools

# --- Example 1: A simple timing decorator ---

# A decorator function that takes a function 'func' as an argument
def timer_decorator(func):
    # The 'wrapper' function will replace the original function.
    # It must accept any arguments (*args, **kwargs) that the original function accepts.
    @functools.wraps(func) # Use functools.wraps to preserve original function's metadata (name, docstring)
    def wrapper(*args, **kwargs):
        start_time = time.perf_counter() # Record start time
        result = func(*args, **kwargs)   # Call the original function
        end_time = time.perf_counter()   # Record end time
        run_time = end_time - start_time
        print(f"Function '{func.__name__}' took {run_time:.4f} seconds to execute.")
        return result # Return the result of the original function
    return wrapper # The decorator returns the new 'wrapper' function

# Apply the decorator using the '@' syntax
@timer_decorator
def long_running_task(n):
    """A sample function that simulates a long-running task."""
    print(f"Running long_running_task with n={n}...")
    time.sleep(n) # Simulate work
    print(f"long_running_task finished.")
    return f"Task completed in {n} seconds."

@timer_decorator
def short_task():
    """A quick task."""
    print("Running short_task...")
    return "Short task done."

# Call the decorated functions
print("\n--- Calling decorated functions ---")
long_running_task(2)
print(long_running_task(0.5)) # Also captures the return value

print("-" * 20)
short_task()
print(short_task()) # Also captures the return value
print("---------------------------------")


# --- Example 2: A logging decorator ---
def log_calls(func):
    """A decorator that logs function calls with their arguments."""
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        # Log before the function call
        print(f"LOG: Calling function '{func.__name__}' with args={args}, kwargs={kwargs}")
        result = func(*args, **kwargs)
        # Log after the function call
        print(f"LOG: Function '{func.__name__}' returned: {result}")
        return result
    return wrapper

@log_calls
def add_numbers(a, b):
    """Adds two numbers."""
    return a + b

@log_calls
def concatenate_strings(s1, s2, separator=" "):
    """Concatenates two strings with a separator."""
    return s1 + separator + s2

print("\n--- Calling functions with logging decorator ---")
add_numbers(5, 7)
print("-" * 10)
concatenate_strings("Hello", "World", separator="-")
print("-" * 10)
concatenate_strings("First", "Last") # Using default separator
print("---------------------------------------------")
          

Explanation of the Example Code:

  • **`timer_decorator`:**
    • This decorator is defined as a function `timer_decorator` that takes another function `func` (the function to be decorated) as its argument.
    • Inside `timer_decorator`, it defines an inner function called `wrapper`. This `wrapper` function is what actually replaces `func` when the decorator is applied.
    • `wrapper` captures the `start_time` before calling the `func(*args, **kwargs)` (the original function with its arguments). It then captures the `end_time` and prints the execution duration. Finally, it returns the `result` from the original function.
    • `@functools.wraps(func)` is crucial. It copies the `func`'s metadata (like `__name__` and `__doc__`) to the `wrapper` function, ensuring that introspection tools correctly identify the decorated function, not the wrapper.
    • When `@timer_decorator` is placed above `long_running_task` and `short_task`, it's equivalent to `long_running_task = timer_decorator(long_running_task)`. Now, when `long_running_task()` is called, it's actually the `wrapper` function that executes, performing the timing before and after running the original task.
  • **`log_calls`:**
    • This decorator operates similarly to `timer_decorator` but adds logging functionality.
    • The `wrapper` function prints messages before and after the original function call, showing the arguments passed and the value returned.
    • Applying `@log_calls` to `add_numbers` and `concatenate_strings` automatically adds this logging behavior to their executions without modifying their core logic.

These examples illustrate how decorators provide a clean, Pythonic, and reusable way to add cross-cutting concerns (like timing or logging) to functions without cluttering their main implementation. This leads to more modular, readable, and maintainable codebases.