0%

48- Deep Dive into Decorators

Master advanced decorator patterns. Decorators with arguments, class decorators, nested decorators, and real-world applications. Take your decorator skills to the next level.

You understand basic decorators. You can write a simple decorator that wraps a function, adds logging, and returns a result. But decorators can do much more. What if you need to pass arguments to your decorator? What if you want to decorate classes, not just functions? What if you need to stack multiple decorators? What if you want to create decorators that can be applied with or without parentheses? This lesson dives deep into advanced decorator patterns. You will learn decorator factories (decorators with arguments), class decorators, decorators that work on methods, and practical patterns used in real-world code. You will also learn about functools.partial, functools.singledispatch, and other advanced tools that complement decorators.

🕯️ Magic Note

A decorator with arguments is actually a decorator factory. It takes parameters and returns a decorator. This two-level nesting confuses many programmers, but once you understand it, you unlock the full power of Python’s decorator system.

Decorator Factories (Decorators with Arguments)
When you need to pass arguments to a decorator, you need an extra level of nesting. The outermost function takes the decorator arguments and returns the actual decorator.

Python

from functools import wraps

import time

def retry(max_attempts=3, delay=1.0, exceptions=(Exception,)):

“””Decorator that retries a function on specified exceptions.”””

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}/{max_attempts} failed: {e}”)

if attempt < max_attempts:

time.sleep(delay)

raise last_exception

return wrapper

return decorator

@retry(max_attempts=5, delay=0.5, exceptions=(ConnectionError, TimeoutError))

def fetch_data(url):

import random

if random.random() < 0.7:

raise ConnectionError(“Network issue”)

return f”Data from {url}”

print(fetch_data(“https://api.example.com”))

🕯️ Magic Note

The @retry(…) syntax calls the retry function, which returns the real decorator, which is then applied to fetch_data. This is equivalent to: fetch_data = retry(…)(fetch_data).

Flexible Decorators (With or Without Arguments)
Sometimes you want a decorator that can be used both with and without parentheses. This requires detecting whether the argument is a function or parameters.

Python

from functools import wraps

import time

def timer(func=None, *, prefix=””):

“””Decorator that works with or without arguments.”””

def decorator(f):

@wraps(f)

def wrapper(*args, **kwargs):

start = time.perf_counter()

result = f(*args, **kwargs)

end = time.perf_counter()

message = f”{prefix}{f.__name__} took {end – start:.4f}s”

print(message)

return result

return wrapper

if func is None:

# Decorator was called with arguments: @timer(prefix=”>>> “)

return decorator

else:

# Decorator was called without arguments: @timer

return decorator(func)

# Both of these work:

@timer

def fast_function():

return sum(range(1000000))

@timer(prefix=”⏱️ “)

def slow_function():

time.sleep(0.5)

return “Done”

fast_function()

slow_function()

💡 The * (keyword-only argument) forces prefix to be passed as a keyword argument. This avoids confusion with the func parameter.
Class Decorators
Decorators can be applied to classes. They modify or enhance class behavior. This is a powerful pattern for mixins, validation, and registration.

Python

from functools import wraps

def add_logging(cls):

“””Add logging to all methods of a class.”””

for name, method in cls.__dict__.items():

if callable(method) and not name.startswith(“_”):

@wraps(method)

def logged_method(*args, method=method, name=name, **kwargs):

print(f”[LOG] Calling {cls.__name__}.{name}”)

result = method(*args, **kwargs)

print(f”[LOG] {cls.__name__}.{name} returned {result}”)

return result

setattr(cls, name, logged_method)

return cls

@add_logging

class Calculator:

def add(self, a, b):

return a + b

def multiply(self, a, b):

return a * b

calc = Calculator()

calc.add(3, 5)

calc.multiply(4, 6)

🕯️ Magic Note

Class decorators are executed after the class is created but before it is bound to its name. This makes them perfect for modifying the class in place, adding methods, or registering the class with a registry.

Singleton Decorator
A classic pattern: ensure a class has only one instance.

Python

from functools import wraps

def singleton(cls):

“””Ensure only one instance of a class exists.”””

instances = {}

@wraps(cls)

def get_instance(*args, **kwargs):

if cls not in instances:

instances[cls] = cls(*args, **kwargs)

return instances[cls]

return get_instance

@singleton

class DatabaseConnection:

def __init__(self, url):

self.url = url

print(f”Creating connection to {url}”)

db1 = DatabaseConnection(“postgres://localhost”)

db2 = DatabaseConnection(“postgres://localhost”)

print(db1 is db2) # True (same instance)

Decorators for Method Overloading (single dispatch)
The functools.singledispatch decorator allows you to create generic functions with different implementations for different types.

Python

from functools import singledispatch

@singledispatch

def process(value):

raise NotImplementedError(f”No implementation for {type(value)}”)

@process.register(int)

def _(value):

return f”Processing integer: {value * 2}”

@process.register(str)

def _(value):

return f”Processing string: {value.upper()}”

@process.register(list)

def _(value):

return f”Processing list: {sum(value)}”

print(process(10)) # Processing integer: 20

print(process(“hello”)) # Processing string: HELLO

print(process([1, 2, 3])) # Processing list: 6

🕯️ Magic Note

This is Python’s version of method overloading. The default implementation is called if no registered implementation matches the type. This pattern is widely used in libraries like pydantic and dataclasses.

Property Decorator Deep Dive
The @property decorator creates a getter. You can also add setter and deleter using @attr.setter and @attr.deleter.

Python

class Temperature:

def __init__(self, celsius=0):

self._celsius = celsius

@property

def celsius(self):

“””Get temperature in Celsius.”””

return self._celsius

@celsius.setter

def celsius(self, value):

“””Set temperature in Celsius.”””

if value < -273.15:

raise ValueError(“Temperature below absolute zero”)

self._celsius = value

@celsius.deleter

def celsius(self):

print(“Deleting temperature”)

del self._celsius

@property

def fahrenheit(self):

“””Get temperature in Fahrenheit (read-only).”””

return self._celsius * 9/5 + 32

temp = Temperature(25)

print(temp.celsius) # 25 (getter)

temp.celsius = 30 # (setter)

print(temp.fahrenheit) # 86.0 (computed property)

del temp.celsius # (deleter)

Decorator Stacking Order Matters
When you apply multiple decorators, they are applied from bottom to top (nearest the function first). Understanding this order is crucial.

Python

from functools import wraps

def decorator_a(func):

@wraps(func)

def wrapper(*args, **kwargs):

print(“A before”)

result = func(*args, **kwargs)

print(“A after”)

return result

return wrapper

def decorator_b(func):

@wraps(func)

def wrapper(*args, **kwargs):

print(“B before”)

result = func(*args, **kwargs)

print(“B after”)

return result

return wrapper

# Order: A wraps B wraps function

@decorator_a # Applied second (outermost)

@decorator_b # Applied first (innermost)

def greet():

print(“Hello!”)

greet()

# Output:

# A before

# B before

# Hello!

# B after

# A after

💡 Think of stacking decorators like nested function calls: decorator_a(decorator_b(greet)). The inner decorator runs first, then the outer one wraps around it.
Decorator for Type Checking
A practical decorator that validates argument types at runtime.

Python

from functools import wraps

from typing import get_type_hints

def type_check(func):

@wraps(func)

def wrapper(*args, **kwargs):

hints = get_type_hints(func)

# Check positional arguments

arg_names = list(hints.keys())[:-1] if hints.get(“return”) else list(hints.keys())

for arg, name in zip(args, arg_names):

expected = hints.get(name)

if expected and not isinstance(arg, expected):

raise TypeError(f”Argument ‘{name}’ should be {expected.__name__}, got {type(arg).__name__}”)

# Check keyword arguments

for name, arg in kwargs.items():

expected = hints.get(name)

if expected and not isinstance(arg, expected):

raise TypeError(f”Argument ‘{name}’ should be {expected.__name__}, got {type(arg).__name__}”)

return func(*args, **kwargs)

return wrapper

@type_check

def add(a: int, b: int) -> int:

return a + b

print(add(5, 3)) # 8

# print(add(“5”, 3)) # TypeError: Argument ‘a’ should be int, got str

Decorator for Lazy Properties
A property that is computed once and then cached. Useful for expensive computations.

Python

from functools import wraps

def lazy_property(func):

“””Property that computes once and caches the result.”””

attr_name = f”_lazy_{func.__name__}”

@property

@wraps(func)

def wrapper(self):

if not hasattr(self, attr_name):

setattr(self, attr_name, func(self))

return getattr(self, attr_name)

return wrapper

class DataAnalyzer:

def __init__(self, data):

self.data = data

@lazy_property

def mean(self):

print(“Computing mean (expensive operation)…”)

return sum(self.data) / len(self.data)

@lazy_property

def median(self):

print(“Computing median (expensive operation)…”)

sorted_data = sorted(self.data)

n = len(sorted_data)

mid = n // 2

if n % 2:

return sorted_data[mid]

return (sorted_data[mid – 1] + sorted_data[mid]) / 2

analyzer = DataAnalyzer([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])

print(analyzer.mean) # Computing mean… 5.5

print(analyzer.mean) # 5.5 (cached, no computation)

🕯️ Magic Note

This pattern is similar to @property but with caching. It is useful for expensive computations that should only happen once. Python 3.8+ has @functools.cached_property for exactly this purpose.

Method Decorators (self-aware)
Decorators on methods need to handle the self parameter correctly.

Python

from functools import wraps

def bound_method(func):

“””Decorator that knows about self.”””

@wraps(func)

def wrapper(self, *args, **kwargs):

print(f”Calling {func.__name__} on {self}”)

return func(self, *args, **kwargs)

return wrapper

class Person:

def __init__(self, name):

self.name = name

@bound_method

def greet(self):

return f”Hello, I am {self.name}”

p = Person(“Ali”)

print(p.greet())

Common Mistakes with Advanced Decorators
  • Forgetting the extra nesting level for decorators with arguments
  • Modifying *args or **kwargs without understanding the impact
  • Not handling self in method decorators
  • Confusing the order of stacked decorators
  • Creating decorators that are too complex (often a sign of needing a different pattern)
  • Overusing decorators when a simple function would suffice
Check Your Understanding
  • Write a decorator that limits the number of times a function can be called.
  • How do you write a decorator that can be used both with and without arguments?
  • What is the difference between a class decorator and a function decorator?
  • What does functools.singledispatch do?
  • Write a decorator that caches the result of a function (memoization).
  • Explain the order of execution for stacked decorators with an example.

⚡ Whisper

Decorators are a journey. You start with simple wrappers. Then you discover you need arguments. Then you realize decorators can decorate classes. Then you learn about singledispatch and cached_property. Each step reveals new power. The syntax is small: @decorator above a function. But the implications are large. A decorator can add logging, timing, retries, caching, validation, permissions, or any cross-cutting concern. It keeps your core functions clean and focused. The decorator handles the rest. This is separation of concerns. This is elegance. Do not overuse decorators. Not everything needs to be a decorator. But when you see repeated patterns across functions, reach for a decorator. Start simple. Test your decorators. Use @wraps always. And remember: a decorator is just a function that returns a function. That is the whole magic. The rest is practice.

Related posts