Previous | Tutorial index | Next
Learn how to define decorators using classes instead of functions.
Decorators are a powerful design pattern in Python that allows you to modify or extend the behavior of functions or classes without changing their source code. They are a form of metaprogramming – code that writes or modifies other code.
A function-based decorator is a function that takes another function as an argument, wraps it, and returns the wrapped function.
def logger(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
@logger
def greet(name):
return f"Hello, {name}!"
This works, but function-based decorators that need to maintain state (e.g., a counter) can become messy with nonlocal variables.
A class-based decorator offers several advantages over a function-based one:
| Feature | Function-Based Decorator | Class-Based Decorator |
|---|---|---|
| State Management | Requires nonlocal or mutable containers (e.g., lists) to maintain state. |
State is stored naturally as instance attributes (self.count, self.data). |
| Readability | Nested functions (wrapper) can become hard to read for complex logic. |
Methods (__init__, __call__) are clearly separated and easier to understand. |
| Initialization | Can take arguments, but requires nested function layers. | __init__ handles arguments cleanly; __call__ handles the wrapper logic. |
| Extensibility | Harder to add methods or sub-decorators. | Easy to add helper methods (reset(), enable(), etc.). |
| Inheritance | Not applicable. | Can be subclassed to create variants of the decorator. |
Key Insight: A class can be a decorator if it implements __call__. When you write @MyDecorator, Python calls MyDecorator(func) to create an instance, and then when the decorated function is called, Python calls the instance's __call__ method.
__init__(self, func) – The class receives the decorated function (or class) as an argument and stores it.__call__(self, *args, **kwargs) – This makes the instance callable. When the decorated function is invoked, Python executes __call__, which can add behavior before/after calling the stored function.class MyDecorator:
def __init__(self, func):
self.func = func
# Optional: initialize state
def __call__(self, *args, **kwargs):
# Before-call logic
result = self.func(*args, **kwargs)
# After-call logic
return result
class CountCalls:
def __init__(self, func):
self.func = func
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
print(f"Call {self.count} of {self.func.__name__}")
return self.func(*args, **kwargs)
@CountCalls
def say_hello():
print("Hello!")
say_hello() # Call 1 of say_hello
say_hello() # Call 2 of say_hello
What happens:
@CountCalls creates an instance of CountCalls, passing say_hello to __init__.say_hello() is called, it actually calls __call__ on the instance, which increments count and calls the original function.functools.wrapsLike function-based decorators, class-based decorators can lose the original function's metadata (name, docstring, annotations). You can fix this by using functools.wraps inside __call__, or by using functools.update_wrapper in __init__.
wraps in __call__from functools import wraps
class CountCalls:
def __init__(self, func):
self.func = func
self.count = 0
def __call__(self, *args, **kwargs):
@wraps(self.func)
def wrapper(*args, **kwargs):
self.count += 1
print(f"Call {self.count} of {self.func.__name__}")
return self.func(*args, **kwargs)
return wrapper(*args, **kwargs)
update_wrapper in __init__from functools import update_wrapper
class CountCalls:
def __init__(self, func):
self.func = func
self.count = 0
update_wrapper(self, func) # Copies metadata from func to self
def __call__(self, *args, **kwargs):
self.count += 1
print(f"Call {self.count} of {self.func.__name__}")
return self.func(*args, **kwargs)
Best Practice: Use update_wrapper in __init__ – it's cleaner and ensures that the decorator instance itself looks like the original function when inspected.
Class-based decorators can also accept arguments to customize behavior.
class Repeat:
def __init__(self, times):
self.times = times
def __call__(self, func):
self.func = func
def wrapper(*args, **kwargs):
for _ in range(self.times):
result = self.func(*args, **kwargs)
return result
return wrapper
@Repeat(times=3)
def greet(name):
print(f"Hello, {name}!")
greet("Alice") # Prints "Hello, Alice!" three times
How it works:
@Repeat(times=3) creates an instance of Repeat with times=3.Repeat(3)) is then called with greet as the argument (because @... syntax expects a callable after the @). So Repeat(3)(greet) is executed.__call__ of Repeat(times=3) receives func, stores it, and returns wrapper.Class-based decorators can also decorate instance methods. The decorator's __call__ receives self as the first argument (since the method is bound).
import time
class Timer:
def __init__(self, func):
self.func = func
def __call__(self, *args, **kwargs):
start = time.time()
result = self.func(*args, **kwargs)
end = time.time()
print(f"{self.func.__name__} took {end - start:.4f}s")
return result
class Calculator:
@Timer
def slow_square(self, n):
time.sleep(0.5)
return n ** 2
calc = Calculator()
print(calc.slow_square(10)) # Output: slow_square took 0.5001s, then 100
Important: The decorator's __call__ receives self as the first argument because calc.slow_square is a bound method, and Python automatically passes calc as the first argument. So *args includes self and any other arguments.
You can also use class-based decorators to modify classes, not just functions. In this case, __init__ receives a class, not a function.
__repr__ to a Class Automaticallyclass AddRepr:
def __init__(self, cls):
self.cls = cls
def __call__(self, *args, **kwargs):
# Create an instance
instance = self.cls(*args, **kwargs)
# Add a __repr__ method
instance.__repr__ = lambda self: f"{self.__class__.__name__}({self.__dict__})"
return instance
@AddRepr
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
p = Person("Alice", 30)
print(p) # Person({'name': 'Alice', 'age': 30})
Note: This example modifies the instance, not the class itself. A more common pattern is to modify the class before instantiation:
class AddClassRepr:
def __init__(self, cls):
self.cls = cls
def __call__(self, *args, **kwargs):
# Create an instance of the decorated class
return self.cls(*args, **kwargs)
# But we want to modify the class itself, so we use __new__ or metaclasses.
# A simpler approach: decorate the class before instantiation.
Better Example: Add a class attribute or method to the class.
class AddGreeting:
def __init__(self, cls):
self.cls = cls
def __call__(self, *args, **kwargs):
# Modify the class *before* instantiation
self.cls.greet = lambda self: f"Hello from {self.__class__.__name__}"
return self.cls(*args, **kwargs)
@AddGreeting
class Person:
def __init__(self, name):
self.name = name
p = Person("Alice")
print(p.greet()) # Hello from Person
Note: This is not a typical decorator usage; often class decorators are used to register classes in a registry, add methods, or apply class-level metadata.
@property Decorator – It's a ClassDid you know that @property is a class, not a function? The property built-in is a class that implements __init__, __call__ (as a decorator), getter, setter, and deleter.
class property:
def __init__(self, fget=None, fset=None, fdel=None, doc=None):
self.fget = fget
self.fset = fset
self.fdel = fdel
# ...
def __call__(self, func):
# Called when @property is used as a decorator
# Returns a property instance with the getter set
return property(func, self.fset, self.fdel, self.doc)
def setter(self, func):
# Returns a new property with the setter set
return property(self.fget, func, self.fdel, self.doc)
# ... etc.
This is a beautiful example of a class-based decorator with methods (setter, getter, deleter) that return new property instances.
Sometimes you want a decorator that takes parameters and can be used with or without parentheses (like @timer vs @timer(unit='ms')).
import time
class Timed:
def __init__(self, func=None, unit='s'):
self.func = func
self.unit = unit
def __call__(self, *args, **kwargs):
if self.func is None:
# Called as @Timed(unit='ms') – returns a new instance with func bound later
return Timed(unit=self.unit)
# Called as @Timed or as a normal call
start = time.time()
result = self.func(*args, **kwargs)
elapsed = time.time() - start
if self.unit == 'ms':
elapsed *= 1000
unit_label = 'ms'
else:
unit_label = 's'
print(f"{self.func.__name__} took {elapsed:.4f} {unit_label}")
return result
# Usage with and without parameters
@Timed
def slow_func():
time.sleep(0.5)
@Timed(unit='ms')
def fast_func():
time.sleep(0.01)
slow_func() # slow_func took 0.5001 s
fast_func() # fast_func took 10.0012 ms
How it works:
@Timed – Timed is called with func (the decorated function). __init__ sets self.func = func.@Timed(unit='ms') – Timed(unit='ms') creates an instance with func=None and unit='ms'. This instance is then called with the decorated function as __call__(func), which detects self.func is None and returns a new Timed instance with func set.This pattern is complex but very powerful.
| Pitfall | Explanation | How to Avoid |
|---|---|---|
Forgetting to return the wrapper from __call__ |
If __call__ doesn't return a callable, the decorated function won't work. |
Always return a callable (usually wrapper) or use the instance itself as the wrapper. |
| Not preserving metadata | The decorated function loses its name, docstring, etc. | Use functools.update_wrapper(self, func) in __init__. |
Using self incorrectly in __call__ |
__call__ must handle self correctly when decorating methods. |
Use *args, **kwargs and pass them properly. |
Decorating a method but forgetting that self is the first argument |
The decorator's __call__ receives self as the first arg. |
Ensure your wrapper handles *args correctly. |
| Class decorator modifying the class incorrectly | If you intend to modify the class, do it before instantiation, not in __call__. |
Use __new__ or modify the class in __init__ and return the class. |
Confusing @Decorator with @Decorator() |
If your decorator takes arguments, you need the parentheses. | Be explicit: use @Decorator for no arguments, @Decorator() for parameterized. |
Let's build a practical class-based decorator that retries a function on failure.
import time
from functools import update_wrapper
class Retry:
"""
Decorator that retries a function on exception.
Args:
attempts (int): Max number of attempts.
delay (float): Delay in seconds between attempts.
exceptions (tuple): Exceptions to catch and retry on.
"""
def __init__(self, func=None, attempts=3, delay=1.0, exceptions=(Exception,)):
self.func = func
self.attempts = attempts
self.delay = delay
self.exceptions = exceptions
if func is not None:
update_wrapper(self, func) # Preserve metadata
def __call__(self, *args, **kwargs):
if self.func is None:
# Called as @Retry(attempts=5) – return a new instance
return Retry(attempts=self.attempts, delay=self.delay,
exceptions=self.exceptions)
# Normal call: execute the function with retry logic
last_exception = None
for attempt in range(1, self.attempts + 1):
try:
return self.func(*args, **kwargs)
except self.exceptions as e:
last_exception = e
if attempt < self.attempts:
print(f"Attempt {attempt} failed. Retrying in {self.delay}s...")
time.sleep(self.delay)
else:
print(f"All {self.attempts} attempts failed.")
# If we get here, all attempts failed
raise last_exception
# For parameterized decorator (if used without parentheses)
# We need to handle this correctly.
# See the pattern above.
# Usage
@Retry(attempts=3, delay=0.5)
def unstable_network_call():
import random
if random.random() < 0.7:
raise ConnectionError("Network failed")
return "Success!"
# Another example without parameters
@Retry
def simple_retry():
if random.random() < 0.5:
raise ValueError("Something went wrong")
return "OK"
# Test the decorator
print(unstable_network_call()) # May retry a couple times
print(simple_retry()) # Uses default (3 attempts, 1s delay)
Observations:
attempts, delay) in instance attributes.__call__ handles both the parameterized and non-parameterized cases by checking self.func is None.update_wrapper.Answer the following questions to check your understanding.
1. Which special method must a class implement to be used as a decorator?
__init____call____decorate____new__2. When you write @MyDecorator, what does Python do?
MyDecorator() with no arguments.MyDecorator(func) and replaces func with the instance.MyDecorator.__call__ immediately.MyDecorator and calls __call__ on it.3. How do you preserve the original function's name and docstring in a class‑based decorator?
functools.update_wrapper(self, func) in __init__.self.func.__name__ manually.functools.wraps inside __call__.4. (True/False) A class‑based decorator can maintain state (e.g., a counter) across multiple calls of the decorated function.
5. Consider this code:
class Timer:
def __init__(self, func):
self.func = func
def __call__(self, *args, **kwargs):
# ...
If you decorate a method my_method with @Timer, what is passed to __call__ when my_method is called on an instance?
self) and any arguments.6. How do you create a parameterized class‑based decorator (e.g., @Retry(attempts=3))?
__init__ to accept the parameters and __call__ to accept the function.__init__ to accept the function and __call__ to accept the parameters.7. Which of the following is an advantage of class‑based decorators over function‑based decorators?
8. What does the property class use to implement @property.setter?
property objects.9. Consider this code:
class MyDec:
def __init__(self, func):
self.func = func
def __call__(self, x):
return self.func(x) * 2
@MyDec
def square(n):
return n * n
print(square(5))
What is the output?
255010TypeError10. What will happen if you forget to return a callable from __call__ in a class‑based decorator?
TypeError when the decorated function is called.None.Part A: A Timing Decorator
Create a class-based decorator Timer that:
__init__, accept an optional unit parameter ('s' for seconds, 'ms' for milliseconds, default 's').__call__, use time.time() to measure execution time and print it.@Timer and @Timer(unit='ms').Part B: A Debugging Decorator
Create a class-based decorator Debug that:
Calling add(3, 5) -> 8update_wrapper to preserve metadata.count attribute to track how many times the decorated function was called.Part C: Testing
Decorate three different functions (e.g., add, multiply, greet) with your Timer and Debug decorators, and call them to demonstrate the output.
Part A: Timer Decorator
import time
from functools import update_wrapper
class Timer:
def __init__(self, func=None, unit='s'):
self.func = func
self.unit = unit
if func is not None:
update_wrapper(self, func)
def __call__(self, *args, **kwargs):
if self.func is None:
# Called as @Timer(unit='ms')
return Timer(unit=self.unit)
start = time.time()
result = self.func(*args, **kwargs)
elapsed = time.time() - start
if self.unit == 'ms':
elapsed *= 1000
unit_label = 'ms'
else:
unit_label = 's'
print(f"{self.func.__name__} took {elapsed:.4f} {unit_label}")
return result
Part B: Debug Decorator
from functools import update_wrapper
class Debug:
def __init__(self, func):
self.func = func
self.count = 0
update_wrapper(self, func)
def __call__(self, *args, **kwargs):
self.count += 1
# Build a readable arguments string
args_repr = ', '.join(repr(a) for a in args)
kwargs_repr = ', '.join(f"{k}={v!r}" for k, v in kwargs.items())
all_args = args_repr
if kwargs_repr:
all_args += (', ' if args_repr else '') + kwargs_repr
result = self.func(*args, **kwargs)
print(f"Calling {self.func.__name__}({all_args}) -> {result!r}")
return result
Part C: Testing
@Timer
def add(a, b):
return a + b
@Timer(unit='ms')
def multiply(a, b):
return a * b
@Debug
def greet(name):
return f"Hello, {name}!"
@Debug
@Timer
def slow_add(a, b):
time.sleep(0.1)
return a + b
print(add(3, 5)) # timed in seconds
print(multiply(4, 7)) # timed in milliseconds
print(greet("Alice")) # debug output with count
print(slow_add(10, 20)) # both debug and timer (order matters)
print(f"Debug count: {greet.count}") # access count attribute
Task: Implement a class-based decorator Cache that caches the results of a function based on its arguments.
Class Cache:
Attributes:
func – the function being decorated.cache – a dictionary storing results keyed by a tuple of (args, frozenset(kwargs.items())) (or a simplified version).max_size – maximum number of entries in the cache (default 128). If exceeded, remove the oldest entry (least recently used – LRU) or simply clear the cache.__init__(self, func=None, max_size=128) – initializes the decorator. Handle both parameterized and non-parameterized usage.
__call__(self, *args, **kwargs) – if the arguments are in the cache, return the cached result; otherwise, compute the result, store it, and return it.
clear() – a helper method to clear the cache (can be accessed via the decorated function, e.g., add.cache.clear()).
stats() – returns a dictionary with 'hits', 'misses', and 'size' (number of cached entries). Track hits and misses.
Metadata Preservation: Use functools.update_wrapper to preserve the function's name, docstring, and annotations.
Cache Key Generation: For simplicity, use a tuple (args, tuple(sorted(kwargs.items()))) as the key. If kwargs are present, include them.
@Cache(max_size=2)
def slow_square(n):
import time
time.sleep(0.5)
return n * n
print(slow_square(5)) # Computes, takes ~0.5s
print(slow_square(5)) # Returns from cache, instant
print(slow_square(6)) # Computes, takes ~0.5s
print(slow_square(5)) # Returns from cache
print(slow_square.stats()) # {'hits': 2, 'misses': 2, 'size': 2}
slow_square.clear()
print(slow_square.stats()) # {'hits': 0, 'misses': 0, 'size': 0}
Part 3: Reflection Questions (these appear in the original Tutorial 9)
stats() method provide useful information for optimization?Part 1: Sample Implementation of Cache
import functools
from collections import OrderedDict
class Cache:
def __init__(self, func=None, max_size=128):
self.func = func
self.max_size = max_size
self.cache = OrderedDict() # preserves insertion order for LRU
self.hits = 0
self.misses = 0
if func is not None:
functools.update_wrapper(self, func)
def __call__(self, *args, **kwargs):
if self.func is None:
# Called as @Cache(max_size=10)
return Cache(max_size=self.max_size)
# Generate cache key
key = (args, tuple(sorted(kwargs.items())))
if key in self.cache:
self.hits += 1
# Move to end to mark as recently used (LRU)
self.cache.move_to_end(key)
return self.cache[key]
else:
self.misses += 1
result = self.func(*args, **kwargs)
# If cache is full, remove oldest (first item)
if len(self.cache) >= self.max_size:
self.cache.popitem(last=False)
self.cache[key] = result
return result
def clear(self):
self.cache.clear()
self.hits = 0
self.misses = 0
def stats(self):
return {
'hits': self.hits,
'misses': self.misses,
'size': len(self.cache)
}
Reflection Answers
Why class-based?
A class-based decorator can naturally hold the cache dictionary, hit/miss counters, and configuration (max_size) as instance attributes. This keeps all state cleanly encapsulated and easily accessible via methods like clear() and stats(). A function-based decorator would need to use nonlocal variables or mutable containers (e.g., lists) to hold such state, which is less readable and harder to extend.
Handling mutable arguments (lists)
Lists are unhashable, so they cannot be used directly as dictionary keys. In the current implementation, args is a tuple, but if you pass a list as an argument (e.g., func([1,2])), args becomes ([1,2],), and ([1,2],) is not hashable because the list inside is unhashable. To handle this, we could convert all arguments to hashable representations, e.g., by using repr() or by recursively converting lists to tuples (but this may not work for custom objects). A simpler approach is to accept the limitation and document that only hashable arguments should be used, or to use a custom key generator that serialises the arguments (e.g., using pickle).
Downside of sharing cache across multiple functions
If the same decorator instance is used for multiple functions (which doesn't happen with @Cache because each usage creates a new instance), the cache would be shared and key collisions could occur (different functions with same arguments). That's why we create a separate instance per decorated function, ensuring each has its own independent cache.
Value of stats()
stats() provides hit/miss counts and cache size, allowing developers to evaluate the effectiveness of caching. A high hit rate indicates the cache is beneficial; a low hit rate suggests that the cache size might be too small or that the function is not called with repeated arguments, so caching may not be useful.
Making the cache persistent
To persist the cache to disk, we could add save(filename) and load(filename) methods. Inside save, we would write the cache dictionary (and possibly the hit/miss counts) to a file using json or pickle. In load, we would read the file and restore the cache. The __call__ method could check if a persistent cache exists and load it on first call, but careful handling is needed to avoid stale data when the function definition changes.
Part 1: Code
Write the complete Cache class with all specified methods. Include:
@Cache and @Cache(max_size=10)).update_wrapper.Part 2: Testing Script
Write a test section that does the following:
factorial(n) that recursively computes factorial (or use a simple expensive function like a CPU-intensive loop). Decorate it with Cache.clear() and show stats after clearing.max_size and show that old entries are evicted (if you implement LRU) or cache is cleared (if you implement simple clearing). Recommendation: Implement LRU for bonus points, or at least count the size and clear when full.Part 3: Reflection Questions
Answer these in a comment block at the top of your script:
stats() method provide useful information for optimization?Bonus Challenge (Optional):
Implement LRU (Least Recently Used) eviction policy for the cache. When the cache exceeds max_size, remove the least recently used entry. You can use collections.OrderedDict to help with this. Update stats() accordingly.
Before moving to Tutorial 10 (The property() Function), ensure you can confidently say YES to the following:
Ready for the next tutorial? In Tutorial 10, you will learn about the built-in property() function – how to create managed attributes with getters, setters, and deleters using the functional approach, before moving to the decorator syntax in Tutorial 11.