Module: Intermediate Concepts

Decorators

Python Decorators: Intermediate Concepts

Decorators are a powerful and versatile feature in Python that allow you to modify or enhance the behavior of functions or methods without actually changing their code. They are built upon the concept of first-class functions (functions can be passed as arguments, returned from other functions, and assigned to variables).

1. What are Decorators?

At their core, a decorator is a callable (usually a function) that takes another function as input, adds some functionality to it, and returns a modified function. Think of them as "wrappers" around functions.

Why use decorators?

  • Code Reusability: Avoid repeating the same code (like logging, timing, authentication) across multiple functions.
  • Readability: Keep your core function logic clean and focused by separating concerns like logging or authorization.
  • Maintainability: Changes to the added functionality (e.g., logging format) only need to be made in one place – the decorator.

2. Basic Decorator Syntax

The @ symbol is syntactic sugar for applying a decorator. Here's a simple example:

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

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

say_hello()

Output:

Something is happening before the function is called.
Hello!
Something is happening after the function is called.

Explanation:

  1. my_decorator is the decorator function. It takes func (the function to be decorated) as an argument.
  2. wrapper is an inner function defined within my_decorator. This is where the added functionality goes.
  3. wrapper calls the original function func().
  4. my_decorator returns the wrapper function.
  5. @my_decorator above say_hello is equivalent to say_hello = my_decorator(say_hello). It replaces the original say_hello with the wrapper function.

3. Decorators with Arguments

Often, you'll want to pass arguments to your decorator to customize its behavior. This requires an extra layer of nesting.

def repeat(num_times):
  def decorator_repeat(func):
    def wrapper(*args, **kwargs):
      for _ in range(num_times):
        result = func(*args, **kwargs)
      return result
    return wrapper
  return decorator_repeat

@repeat(num_times=3)
def greet(name):
  print(f"Hello, {name}!")

greet("Alice")

Output:

Hello, Alice!
Hello, Alice!
Hello, Alice!

Explanation:

  1. repeat(num_times) is the outer function. It takes the arguments for the decorator (in this case, num_times).
  2. decorator_repeat(func) is the actual decorator function. It takes the function to be decorated (func).
  3. wrapper(*args, **kwargs) is the inner function that adds the functionality. *args and **kwargs allow the wrapper to accept any number of positional and keyword arguments, and pass them on to the original function.
  4. The repeat function returns decorator_repeat.

4. Using functools.wraps

When you decorate a function, you lose some of its original metadata (like its __name__ and __doc__). functools.wraps helps preserve this metadata.

import functools

def my_decorator(func):
  @functools.wraps(func)  # Preserves func's metadata
  def wrapper(*args, **kwargs):
    """Wrapper function documentation."""
    print("Something is happening before the function is called.")
    result = func(*args, **kwargs)
    print("Something is happening after the function is called.")
    return result
  return wrapper

@my_decorator
def add(x, y):
  """Adds two numbers."""
  return x + y

print(add.__name__)  # Output: add (instead of wrapper)
print(add.__doc__)   # Output: Adds two numbers. (instead of Wrapper function documentation.)

Explanation:

  • functools.wraps(func) is a decorator itself. It updates the wrapper function to look like the original func in terms of metadata. This is important for debugging, introspection, and documentation.

5. Class-Based Decorators

Decorators can also be implemented using classes. This is useful when you need to maintain state within the decorator.

class CountCalls:
  def __init__(self, func):
    self.func = func
    self.call_count = 0

  def __call__(self, *args, **kwargs):
    self.call_count += 1
    print(f"Function '{self.func.__name__}' called {self.call_count} times.")
    return self.func(*args, **kwargs)

@CountCalls
def say_hi(name):
  print(f"Hi, {name}!")

say_hi("Bob")
say_hi("Charlie")
say_hi("David")

Output:

Function 'say_hi' called 1 times.
Hi, Bob!
Function 'say_hi' called 2 times.
Hi, Charlie!
Function 'say_hi' called 3 times.
Hi, David!

Explanation:

  1. CountCalls is a class that takes the function to be decorated in its __init__ method.
  2. The __call__ method makes the class instance callable (like a function). This is where the added functionality goes.
  3. When say_hi is called, the __call__ method of the CountCalls instance is executed, which increments the call_count and then calls the original say_hi function.

6. Common Use Cases

  • Logging: Log function calls, arguments, and return values.
  • Timing: Measure the execution time of functions.
  • Authentication/Authorization: Check if a user is authorized to access a function.
  • Caching: Store the results of expensive function calls to avoid recomputation.
  • Input Validation: Validate function arguments before execution.
  • Retry Logic: Automatically retry a function if it fails.

Decorators are a powerful tool for writing clean, reusable, and maintainable Python code. Understanding them is a key step towards becoming a more proficient Python programmer. Practice using them in different scenarios to solidify your understanding.