Decorators

Master the art of using decorators to modify and enhance Python functions with clean, reusable code.

45 minutes Intermediate Functions

Basic Decorators

Understanding Function Decorators

Definition

A decorator is a function that takes another function as an argument and returns a modified version of that function.

Explanation

Decorators allow you to modify the behavior of a function without changing its source code, following the principle of separation of concerns.

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!")

# This is equivalent to:
# say_hello = my_decorator(say_hello)

say_hello()
# Output:
# Something is happening before the function is called.
# Hello!
# Something is happening after the function is called.

Decorators with Arguments

Passing Parameters to Decorators

Definition

Decorators can accept arguments that modify their behavior, allowing for more flexible and reusable decorator functions.

Explanation

When a decorator needs to accept arguments, it requires an additional level of function nesting to handle the parameters.

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

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

greet("Alice")
# Output:
# Hello, Alice!
# Hello, Alice!
# Hello, Alice!

def validate_type(expected_type):
    def decorator(func):
        def wrapper(*args, **kwargs):
            for arg in args:
                if not isinstance(arg, expected_type):
                    raise TypeError(f"Expected {expected_type.__name__}, got {type(arg).__name__}")
            return func(*args, **kwargs)
        return wrapper
    return decorator

@validate_type(int)
def add(a, b):
    return a + b

Class Decorators

Decorating Classes

Definition

Class decorators are decorators that modify or enhance the behavior of entire classes rather than individual methods.

Explanation

Class decorators can add new methods, modify existing ones, or add class-level attributes to the decorated class.

def singleton(cls):
    instances = {}
    def get_instance(*args, **kwargs):
        if cls not in instances:
            instances[cls] = cls(*args, **kwargs)
        return instances[cls]
    return get_instance

@singleton
class Database:
    def __init__(self):
        print("Initializing database connection...")
    
    def query(self, sql):
        print(f"Executing query: {sql}")

# Both db1 and db2 will be the same instance
db1 = Database()
db2 = Database()
print(db1 is db2)  # Output: True

def add_methods(cls):
    def new_method(self):
        return "This is a new method"
    cls.new_method = new_method
    return cls

@add_methods
class MyClass:
    pass

obj = MyClass()
print(obj.new_method())  # Output: This is a new method

Method Decorators

Decorating Class Methods

Definition

Method decorators are decorators that modify the behavior of class methods, including instance methods, class methods, and static methods.

Explanation

Method decorators can be used to add functionality to class methods, such as access control, logging, or validation.

def log_method_call(func):
    def wrapper(self, *args, **kwargs):
        print(f"Calling {func.__name__} with args: {args}, kwargs: {kwargs}")
        result = func(self, *args, **kwargs)
        print(f"{func.__name__} returned: {result}")
        return result
    return wrapper

class Calculator:
    @log_method_call
    def add(self, x, y):
        return x + y
    
    @log_method_call
    def multiply(self, x, y):
        return x * y

calc = Calculator()
calc.add(5, 3)      # Output: Calling add with args: (5, 3), kwargs: {}
                    # Output: add returned: 8
calc.multiply(4, 2) # Output: Calling multiply with args: (4, 2), kwargs: {}
                    # Output: multiply returned: 8

Property Decorators

Using @property

Definition

The @property decorator allows you to define methods that can be accessed like attributes, providing a way to implement getters, setters, and deleters.

Explanation

Property decorators enable you to add validation, computation, or other logic to attribute access while maintaining a clean interface.

class Person:
    def __init__(self, name):
        self._name = name
    
    @property
    def name(self):
        return self._name
    
    @name.setter
    def name(self, value):
        if not isinstance(value, str):
            raise TypeError("Name must be a string")
        self._name = value
    
    @name.deleter
    def name(self):
        del self._name

person = Person("Alice")
print(person.name)  # Output: Alice

person.name = "Bob"
print(person.name)  # Output: Bob

# This will raise a TypeError
# person.name = 123

del person.name
# This will raise an AttributeError
# print(person.name)

Built-in Decorators

Python's Standard Decorators

Definition

Python provides several built-in decorators that serve common purposes, such as @staticmethod, @classmethod, and @functools.wraps.

Explanation

These decorators are part of Python's standard library and provide functionality that is commonly needed in Python programs.

from functools import wraps

def my_decorator(func):
    @wraps(func)  # Preserves the metadata of the original function
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

class Math:
    @staticmethod
    def add(x, y):
        return x + y
    
    @classmethod
    def from_string(cls, string):
        x, y = map(int, string.split(','))
        return cls(x, y)
    
    def __init__(self, x, y):
        self.x = x
        self.y = y

# Using static method
print(Math.add(5, 3))  # Output: 8

# Using class method
math_obj = Math.from_string("10,20")
print(math_obj.x, math_obj.y)  # Output: 10 20

Decorator Chaining

Using Multiple Decorators

Definition

Decorator chaining allows you to apply multiple decorators to a single function, with each decorator adding its own functionality.

Explanation

When multiple decorators are applied, they are executed from bottom to top, with the innermost decorator being applied first.

def log_calls(func):
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

def validate_args(func):
    def wrapper(*args, **kwargs):
        if not all(isinstance(arg, int) for arg in args):
            raise TypeError("All arguments must be integers")
        return func(*args, **kwargs)
    return wrapper

@log_calls
@validate_args
def add(a, b):
    return a + b

# This is equivalent to:
# add = log_calls(validate_args(add))

print(add(5, 3))  # Output: Calling add
                  # Output: 8

# This will raise a TypeError
# print(add("5", 3))

Decorator Factories

Creating Decorators Dynamically

Definition

A decorator factory is a function that returns a decorator, allowing for more flexible and reusable decorator creation.

Explanation

Decorator factories enable you to create decorators with different behaviors based on parameters passed to the factory function.

def retry(max_attempts=3, delay=1):
    def decorator(func):
        def wrapper(*args, **kwargs):
            import time
            attempts = 0
            while attempts < max_attempts:
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    attempts += 1
                    if attempts == max_attempts:
                        raise e
                    print(f"Attempt {attempts} failed, retrying...")
                    time.sleep(delay)
        return wrapper
    return decorator

@retry(max_attempts=3, delay=2)
def unreliable_function():
    import random
    if random.random() < 0.7:
        raise ValueError("Random error")
    return "Success!"

# This will retry up to 3 times with a 2-second delay between attempts
print(unreliable_function())

Context Manager Decorators

Using @contextmanager

Definition

The @contextmanager decorator from the contextlib module allows you to create context managers using generator functions.

Explanation

Context manager decorators simplify the creation of context managers by handling the setup and teardown logic in a single function.

from contextlib import contextmanager

@contextmanager
def temporary_file():
    import tempfile
    import os
    
    # Setup
    temp = tempfile.NamedTemporaryFile(delete=False)
    try:
        yield temp
    finally:
        # Teardown
        temp.close()
        os.unlink(temp.name)

# Using the context manager
with temporary_file() as f:
    f.write(b"Hello, World!")
    f.flush()
    print(f"File written to: {f.name}")
# File is automatically deleted after the with block

Decorator Best Practices

Guidelines for Using Decorators

Definition

Best practices for writing and using decorators effectively in Python code.

Explanation

Following these guidelines helps ensure your decorators are maintainable, reusable, and don't introduce unexpected behavior.

from functools import wraps

# Good: Preserving function metadata
def good_decorator(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

# Bad: Not preserving metadata
def bad_decorator(func):
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

# Good: Using decorator factories for flexibility
def retry(max_attempts=3):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            # Implementation
            pass
        return wrapper
    return decorator

# Good: Documenting decorator behavior
def documented_decorator(func):
    """Decorator that logs function calls and their results."""
    @wraps(func)
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        result = func(*args, **kwargs)
        print(f"{func.__name__} returned {result}")
        return result
    return wrapper
Concept 1 of 10