Object-Oriented Programming

Python Decorators

Master Python decorators - function wrappers that modify behavior without changing code, with practical patterns and examples.

Python Decorators

Decorators are a powerful Python feature that lets you modify or extend the behavior of functions and classes without changing their code. They wrap a function with another function.


Understanding Decorators

A decorator is a function that takes another function as input and returns a modified version:

python
# Without decorator syntax
def shout(func):
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        return result.upper()
    return wrapper

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

# Manually decorating
greet = shout(greet)
print(greet("Alice"))  # HELLO, ALICE

# With @ decorator syntax (same thing, cleaner)
@shout
def greet(name):
    return f"Hello, {name}"

print(greet("Alice"))  # HELLO, ALICE

Creating Decorators

Basic Decorator

python
def log_call(func):
    """Log when a function is called."""
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}({args}, {kwargs})")
        result = func(*args, **kwargs)
        print(f"{func.__name__} returned: {result}")
        return result
    return wrapper

@log_call
def add(a, b):
    return a + b

add(3, 5)
# Calling add((3, 5), {})
# add returned: 8

Preserving Function Metadata

Use functools.wraps to preserve the original function's name and docstring:

python
from functools import wraps

def timer(func):
    """Time a function's execution."""
    @wraps(func)  # Preserves func's metadata
    def wrapper(*args, **kwargs):
        import time
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__} took {elapsed:.4f}s")
        return result
    return wrapper

@timer
def slow_function():
    """A deliberately slow function."""
    import time
    time.sleep(0.5)
    return "done"

slow_function()
# slow_function took 0.5012s

# Metadata is preserved
print(slow_function.__name__)  # slow_function (not 'wrapper')
print(slow_function.__doc__)   # A deliberately slow function.

Decorators with Arguments

python
from functools import wraps

def repeat(times):
    """Decorator that repeats a function call."""
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            results = []
            for _ in range(times):
                result = func(*args, **kwargs)
                results.append(result)
            return results
        return wrapper
    return decorator

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

print(greet("Alice"))
# ['Hello, Alice!', 'Hello, Alice!', 'Hello, Alice!']

Common Decorator Patterns

Retry Decorator

python
from functools import wraps
import time

def retry(max_attempts=3, delay=1):
    """Retry a function on failure."""
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, max_attempts + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    if attempt == max_attempts:
                        raise
                    print(f"Attempt {attempt} failed: {e}. Retrying...")
                    time.sleep(delay)
        return wrapper
    return decorator

@retry(max_attempts=3, delay=0.5)
def unstable_api_call():
    import random
    if random.random() < 0.7:
        raise ConnectionError("Server unavailable")
    return "Success!"

Cache Decorator

python
from functools import wraps

def cache(func):
    """Simple caching decorator."""
    memo = {}

    @wraps(func)
    def wrapper(*args):
        if args not in memo:
            memo[args] = func(*args)
        return memo[args]
    return wrapper

@cache
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

print(fibonacci(100))  # 354224848179261915075 (instant!)

# Python has a built-in version:
from functools import lru_cache

@lru_cache(maxsize=128)
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

Validation Decorator

python
from functools import wraps

def validate_types(**expected_types):
    """Validate argument types."""
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            # Check positional args
            import inspect
            sig = inspect.signature(func)
            params = list(sig.parameters.keys())

            for i, (param, value) in enumerate(zip(params, args)):
                if param in expected_types:
                    if not isinstance(value, expected_types[param]):
                        raise TypeError(
                            f"{param} must be {expected_types[param].__name__}, "
                            f"got {type(value).__name__}"
                        )
            return func(*args, **kwargs)
        return wrapper
    return decorator

@validate_types(name=str, age=int)
def create_user(name, age):
    return {"name": name, "age": age}

print(create_user("Alice", 30))  # {'name': 'Alice', 'age': 30}
# create_user("Alice", "30")    # TypeError: age must be int, got str

Stacking Decorators

Multiple decorators can be applied to a single function:

python
from functools import wraps

def bold(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        return f"<b>{func(*args, **kwargs)}</b>"
    return wrapper

def italic(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        return f"<i>{func(*args, **kwargs)}</i>"
    return wrapper

@bold
@italic
def greet(name):
    return f"Hello, {name}"

print(greet("Alice"))  # <b><i>Hello, Alice</i></b>

# Applied bottom-up: italic first, then bold
# Equivalent to: bold(italic(greet))

Class Decorators

Decorators can also be applied to classes:

python
def singleton(cls):
    """Ensure only one instance of a class exists."""
    instances = {}

    @wraps(cls, updated=[])
    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, url):
        self.url = url
        print(f"Connecting to {url}")

# Only creates one instance
db1 = Database("localhost:5432")  # Connecting to localhost:5432
db2 = Database("other:5432")     # No output - returns existing instance
print(db1 is db2)                # True

Built-in Decorators

python
class MyClass:
    # @property - getter/setter
    @property
    def name(self):
        return self._name

    # @staticmethod - no self or cls
    @staticmethod
    def utility():
        return "I don't need an instance"

    # @classmethod - receives cls
    @classmethod
    def create(cls, data):
        return cls()

    # @abstractmethod - must be implemented by subclasses
    from abc import abstractmethod

    # @functools.lru_cache - memoization
    from functools import lru_cache

    @lru_cache(maxsize=None)
    def expensive_computation(self, n):
        return sum(range(n))

Summary

  • Decorators wrap functions to modify their behavior
  • Use @decorator syntax above the function definition
  • Always use @functools.wraps to preserve function metadata
  • Decorators with arguments need an extra nesting level
  • Common patterns: logging, timing, caching, retry, validation
  • Stack decorators by applying multiple @ (bottom-up order)
  • Built-in decorators: @property, @staticmethod, @classmethod, @lru_cache

Next, we'll learn about Python file handling.