#1 Data Analytics Program in India
₹2,499₹1,499Enroll Now
9 min read
•Question 33 of 41hard

Advanced Decorator Patterns

Complex decorator implementations.

What You'll Learn

  • Class-based decorators for stateful behavior
  • Decorators with optional arguments
  • Stacking and order of decorators
  • Decorating methods and classes
  • The importance of functools.wraps

Why functools.wraps Matters

Without @wraps, decorated functions lose their identity:

code.pyPython
from functools import wraps

def bad_decorator(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

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

@bad_decorator
def bad_example():
    """This is a docstring."""
    pass

@good_decorator
def good_example():
    """This is a docstring."""
    pass

print(bad_example.__name__)   # "wrapper" - WRONG
print(bad_example.__doc__)    # None - WRONG

print(good_example.__name__)  # "good_example" - CORRECT
print(good_example.__doc__)   # "This is a docstring." - CORRECT

Class-Based Decorators

Use classes when you need to maintain state:

code.pyPython
from functools import update_wrapper

class CountCalls:
    def __init__(self, func):
        update_wrapper(self, func)  # Like @wraps for classes
        self.func = func
        self.count = 0

    def __call__(self, *args, **kwargs):
        self.count += 1
        print(f"Call #{self.count} to {self.func.__name__}")
        return self.func(*args, **kwargs)

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

say_hello()  # Call #1 to say_hello
say_hello()  # Call #2 to say_hello
print(say_hello.count)  # 2

# Reset if needed
say_hello.count = 0

Decorators with Optional Arguments

Create decorators that work with or without parentheses:

code.pyPython
from functools import wraps

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

    # Called without arguments: @repeat
    if _func is not None:
        return decorator(_func)

    # Called with arguments: @repeat(times=3)
    return decorator

@repeat  # Works! (times defaults to 2)
def say_hi():
    print("Hi")

@repeat(times=3)  # Also works!
def say_hello():
    print("Hello")

say_hi()     # Prints "Hi" twice
say_hello()  # Prints "Hello" three times

Stacking Decorators

Decorators apply bottom-up, execute top-down:

code.pyPython
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

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

@bold       # Applied last, executes first
@italic     # Applied second
@underline  # Applied first, executes last
def greet(name):
    return f"Hello, {name}"

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

# Equivalent to:
# greet = bold(italic(underline(greet)))

Decorating Methods

Handle self properly for instance methods:

code.pyPython
from functools import wraps
import time

def timing(func):
    @wraps(func)
    def wrapper(*args, **kwargs):  # Works for functions and methods
        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

def log_method(func):
    @wraps(func)
    def wrapper(self, *args, **kwargs):
        print(f"{self.__class__.__name__}.{func.__name__}({args}, {kwargs})")
        return func(self, *args, **kwargs)
    return wrapper

class Calculator:
    @timing
    @log_method
    def add(self, a, b):
        return a + b

calc = Calculator()
calc.add(2, 3)
# Calculator.add((2, 3), {})
# add took 0.0001s

Memoization with Decorator

code.pyPython
from functools import wraps

def memoize(func):
    cache = {}

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

    # Expose cache for inspection and clearing
    wrapper.cache = cache
    wrapper.cache_clear = lambda: cache.clear()
    wrapper.cache_info = lambda: f"Size: {len(cache)}"

    return wrapper

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

print(fibonacci(100))  # Instant!
print(fibonacci.cache_info())  # Size: 101

# Note: Use functools.lru_cache in production
from functools import lru_cache

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

Class Decorators

Decorators can modify or wrap entire classes:

code.pyPython
def singleton(cls):
    """Make a class a singleton."""
    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):
        print("Initializing database")

db1 = Database()  # Initializing database
db2 = Database()  # (nothing - returns cached)
print(db1 is db2)  # True

# Add methods to class
def add_repr(cls):
    def __repr__(self):
        attrs = ', '.join(f'{k}={v!r}' for k, v in vars(self).items())
        return f"{cls.__name__}({attrs})"
    cls.__repr__ = __repr__
    return cls

@add_repr
class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age

print(Person("Alice", 30))  # Person(name='Alice', age=30)

Decorators with Arguments (Full Pattern)

code.pyPython
from functools import wraps

def retry(max_attempts=3, exceptions=(Exception,)):
    """Retry decorator with configurable attempts."""
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            last_exception = None
            for attempt in range(1, max_attempts + 1):
                try:
                    return func(*args, **kwargs)
                except exceptions as e:
                    last_exception = e
                    print(f"Attempt {attempt} failed: {e}")
            raise last_exception
        return wrapper
    return decorator

@retry(max_attempts=3, exceptions=(ConnectionError, TimeoutError))
def fetch_data():
    # Might fail sometimes
    import random
    if random.random() < 0.7:
        raise ConnectionError("Network error")
    return "Data"

Interview Tip

When asked about advanced decorators:

  1. Always use @wraps to preserve function metadata
  2. Use class-based decorators for stateful behavior
  3. Decorators with optional args: check if _func is None
  4. Stack order: bottom decorator applies first
  5. For memoization, prefer functools.lru_cache