Class Decorators and __call__ — When the Wrapper Needs State
~18 min · class-decorator, __call__, stateful-wrapper
Level 0Curious
0 XP0/93 lessons0/23 achievements
0/100 XP to next level100 XP to go0% complete
Two different things called "class decorator"
Confusing terminology: "class decorator" can mean (a) a decorator that's implemented as a class, or (b) a decorator that's applied to a class. We're talking about (a) here. (b) is just "a decorator that happens to be applied to a class" and follows the same rules as any other decorator.
Why a class instead of a function
A function-based decorator stores state in closure variables. A class-based decorator stores state in attributes (self.count, self.config). When the state gets complex enough that you'd want named methods to manipulate it (reset, stats, etc.), reach for a class. For simple wrappers, a function is fine.
__call__ makes an instance callable
Defining __call__ on a class makes its instances act like functions. my_instance(args) calls my_instance.__call__(args). So a class-as-decorator does its "wrapper" work in __call__ instead of in a nested function.
The shape — __init__ takes the function, __call__ does the work
The class's __init__ stores the wrapped function. __call__ takes *args, **kwargs and runs the wrapper logic, calling the stored function. Add other methods (reset, stats) as needed.
Principle: Use a function-based decorator unless you need: multiple methods on the decorator object, complex internal state with structure, or inheritance/composition between decorators. The default tool is the function form. Reach for the class form when the function form would be awkward.
Code
Class as decorator — __init__ takes the fn, __call__ wraps·python
import functools
class CountCalls:
def __init__(self, fn):
self.fn = fn
self.count = 0
functools.update_wrapper(self, fn) # like @wraps for a class
def __call__(self, *args, **kwargs):
self.count += 1
return self.fn(*args, **kwargs)
def reset(self):
self.count = 0
@CountCalls
def hello():
return "hi"
hello()
hello()
hello()
print(hello.count) # 3
hello.reset()
print(hello.count) # 0
# Methods that don't exist on plain function decorators are now available
# .reset(), .count are on the wrapper itself.
Parameterized class decorator·python
import functools
class RateLimit:
def __init__(self, max_per_minute):
self.max = max_per_minute
self.calls = []
def __call__(self, fn): # called when applying to a function
@functools.wraps(fn)
def wrapper(*args, **kwargs):
import time
now = time.time()
# Drop calls older than 60 seconds
self.calls = [t for t in self.calls if now - t < 60]
if len(self.calls) >= self.max:
raise RuntimeError("rate limited")
self.calls.append(now)
return fn(*args, **kwargs)
return wrapper
@RateLimit(max_per_minute=5)
def do_work():
return "ok"
for _ in range(5):
print(do_work()) # all 'ok'
# 6th call would raise
Composing class and function decorators·python
import functools
import time
class CountCalls:
def __init__(self, fn):
self.fn = fn
self.count = 0
functools.update_wrapper(self, fn)
def __call__(self, *args, **kwargs):
self.count += 1
return self.fn(*args, **kwargs)
def timed(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = fn(*args, **kwargs)
print(f"{fn.__name__}: {(time.perf_counter()-start)*1000:.2f}ms")
return result
return wrapper
@CountCalls
@timed # applied first — wraps the original
def compute(n):
return sum(range(n))
compute(1000)
compute(2000)
print(compute.count) # 2
When NOT to use a class — simple state·python
import functools
# Function form — perfectly fine for simple state
def counter(fn):
count = 0
@functools.wraps(fn)
def wrapper(*args, **kwargs):
nonlocal count
count += 1
wrapper.calls = count # expose on the wrapper itself
return fn(*args, **kwargs)
wrapper.calls = 0
return wrapper
@counter
def ping():
return "pong"
ping(); ping(); ping()
print(ping.calls) # 3
# Function form is shorter and clearer for this case.
# Reach for class form when you need multiple methods or richer state.
Implement a class-based decorator Memoize that caches the results of pure functions. The class should: (a) store results in a dict keyed by the call's arguments, (b) expose a cache_clear() method, (c) expose a cache_info() method returning hits, misses, and size, (d) preserve the wrapped function's metadata via functools.update_wrapper. Test by decorating a recursive Fibonacci function — show that with the decorator, even fib(50) is fast.
Progress
Progress is local-only — sign in to sync across devices.