C.W.K.
Stream
Lesson 04 of 05 · published

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.

External links

Exercise

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.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.