Decorator Class in Python: Complete Callable Object and Function Enhancement Implementation Guide

Decorator class in python

Decorator class in python

I spent a long time treating decorators as purely a function-based tool, until I hit a project where I needed a decorator to track state across many different decorated functions — counting calls, caching results per-function, and managing configuration that changed at runtime. Function closures started getting messy fast. That’s when I switched to class-based decorators, and honestly, it changed how I write reusable code in Python.

This guide covers everything I’ve learned about building decorators as classes: how they work internally, when to prefer them over function decorators, and the patterns I actually use in production code.

What Makes a Class a Decorator

In Python, anything callable can be a decorator — a function, a method, or an instance of a class that implements __call__. When you write:

@MyDecorator
def my_func():
    pass

Python is really doing this:

def my_func():
    pass
my_func = MyDecorator(my_func)

So MyDecorator.__init__ receives the function being decorated, and my_func afterward refers to the instance of MyDecorator, not the original function anymore. When you later call my_func(), Python calls instance.__call__().

A Basic Class-Based Decorator

Here’s the simplest version I start with when teaching this concept:

import functools

class CallCounter:
    def __init__(self, func):
        functools.update_wrapper(self, func)
        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)

@CallCounter
def say_hello(name):
    print(f"Hello, {name}!")

say_hello("Ali")
say_hello("Sara")
print(f"Total calls: {say_hello.count}")

Output:

Call #1 to say_hello
Hello, Ali!
Call #2 to say_hello
Hello, Sara!
Total calls: 2

Notice functools.update_wrapper(self, func) — this is the class-based equivalent of functools.wraps, and it copies over __name__, __doc__, and __module__ so the decorated object still looks like the original function during introspection.

Why State Management Is Easier With Classes

With a function-based decorator, tracking state means relying on closures or mutable default arguments — both of which get awkward once you need more than one or two pieces of state. A class gives you self, which is just cleaner for anything beyond a trivial counter.

Here’s a caching example that stores results per-instance:

class MemoizeDecorator:
    def __init__(self, func):
        functools.update_wrapper(self, func)
        self.func = func
        self.cache = {}

    def __call__(self, *args):
        if args not in self.cache:
            self.cache[args] = self.func(*args)
            print(f"Computed and cached result for {args}")
        else:
            print(f"Returned cached result for {args}")
        return self.cache[args]

@MemoizeDecorator
def slow_square(n):
    import time
    time.sleep(1)
    return n * n

print(slow_square(4))
print(slow_square(4))
print(slow_square(5))

Output:

Computed and cached result for (4,)
16
Returned cached result for (4,)
16
Computed and cached result for (5,)
25

I like this pattern because self.cache is scoped naturally to the decorated function, and I can add methods later (like clear_cache()) without restructuring anything.

Class-Based Decorators That Accept Arguments

Just like function decorators, class decorators can accept their own configuration arguments — but this time it happens in __init__, and the function being decorated is received by __call__:

class Throttle:
    def __init__(self, max_calls):
        self.max_calls = max_calls
        self.calls = 0

    def __call__(self, func):
        functools.update_wrapper(self, func)

        def wrapper(*args, **kwargs):
            if self.calls >= self.max_calls:
                raise RuntimeError("Call limit exceeded")
            self.calls += 1
            return func(*args, **kwargs)
        return wrapper

@Throttle(max_calls=2)
def limited_action():
    print("Action performed")

limited_action()
limited_action()
try:
    limited_action()
except RuntimeError as e:
    print(f"Error: {e}")

Output:

Action performed
Action performed
Error: Call limit exceeded

This distinction trips people up: when the class is the decorator directly (no arguments), __init__ gets the function. When the class is instantiated first with arguments and then used as a decorator, __init__ gets the config and __call__ gets the function.

Internal Working: Instances as Callables

The mechanism that makes all of this possible is the __call__ dunder method. Any object with __call__ defined satisfies Python’s notion of “callable,” which is checked by the callable() builtin and used implicitly whenever you write obj(...). Internally, obj() translates to type(obj).__call__(obj, ...) — Python looks up __call__ on the type, not the instance, which matters if you ever try to assign __call__ dynamically to an instance (it won’t work; it must be defined on the class).

This is also why class-based decorators integrate cleanly with the rest of Python’s object model — you can add other methods, properties, and even make the decorator itself support the descriptor protocol if you need it to work properly on class methods.

Handling Instance Methods

A common pitfall: applying a class-based decorator directly to methods can break self binding because the decorator’s __call__ replaces the bound-method behavior. The fix, in most real code I write, is to implement __get__ so the decorator behaves as a proper descriptor:

import functools

class LoggedMethod:
    def __init__(self, func):
        functools.update_wrapper(self, func)
        self.func = func

    def __call__(self, *args, **kwargs):
        print(f"Calling {self.func.__name__}")
        return self.func(*args, **kwargs)

    def __get__(self, instance, owner):
        if instance is None:
            return self
        return functools.partial(self.__call__, instance)

class Robot:
    @LoggedMethod
    def move(self, direction):
        print(f"Moving {direction}")

r = Robot()
r.move("north")

Output:

Calling move
Moving north

Without __get__, calling r.move("north") would fail because self (the Robot instance) wouldn’t be passed correctly — this was one of the trickiest bugs I debugged early in my Python career, and understanding descriptors fixed it permanently.

Performance and Memory Considerations

Class-based decorators create one instance per decorated function, and that instance persists for the lifetime of the program (typically for as long as the module is loaded). This is roughly the same memory footprint as a closure-based decorator, since both store references to the original function and any extra state. The difference is negligible in practice — a few extra bytes for the object header — but it’s worth knowing that each decorated function now has its own object identity, which is genuinely useful for debugging (type(my_func) will show CallCounter, for instance, immediately signaling it’s decorated).

Real-World Use Cases

Best Practices and Common Mistakes

Troubleshooting Tips

If you see TypeError: move() missing 1 required positional argument: 'self', you’re likely missing the __get__ descriptor implementation on a method decorator.

If help(my_func) shows the wrong docstring, double check functools.update_wrapper was called.

If your decorator “loses” state between calls unexpectedly, verify you’re not accidentally re-instantiating the class (for example, by re-importing the module in a way that resets it).

FAQs

Can a class-based decorator be stacked with function-based decorators? Yes, freely — Python doesn’t care whether a decorator is a function or a callable object, only that it’s callable.

Does a class decorator need an __init__? If it decorates the function directly (@MyClass), yes — __init__ receives the function. If it’s called with arguments first (@MyClass(x=1)), __init__ receives the arguments and __call__ receives the function.

Is functools.update_wrapper different from functools.wraps? functools.wraps is a decorator built on top of functools.update_wrapper; the latter is the direct function call used for object instances.

Are class-based decorators slower than function-based ones? The difference is negligible for nearly all use cases; both involve one extra callable layer.

Summary

Class-based decorators shine when you need to manage state, expose extra methods, or build richer, more introspectable enhancements around functions. The core idea is simple: decoration is just calling MyClass(func), and later calls to the decorated name invoke __call__ on that instance. Once I understood __call__ and the descriptor protocol together, class-based decorators became one of my favorite tools for building clean, reusable, stateful wrappers.

References

Exit mobile version