Class-based декораторы: __init__ + __call__
В уроках 01-02 wrapper был функцией (def wrapper(...)). Но wrapper может быть и instance класса — для этого класс должен реализовать __call__ (см. M04 урок 02 — tp_call slot для callable). Class-based decorator — alternative pattern с instance state: счётчик вызовов, registry decorated-функций, кеш — всё хранится как self.attr, а не в closure cells.
В этом уроке:
- Pattern:
__init__(self, fn)сохраняетfn;__call__(self, *a, **kw)вызывает. - Trade-off vs closure: когда class-based лучше, когда closure-based.
- Pitfall 4 — class-based decorator теряет
__name__функции; workaroundfunctools.update_wrapper(self, fn)в__init__. - Recipe 1:
CountCalls— counter pattern (empirically verifiedhi.calls = 2). - Recipe 2:
Registry— pattern для collect’а decorated функций (event handlers, plugins). - Recipe 3: rate limiter — instance state для maintain’а N calls per second.
Это pragmatic урок: 50% — рецепты production-применения, 50% — internals (__call__ slot mechanics).
Pattern: __init__ + __call__
Минимальный class-based decorator:
class MyDecorator:
def __init__(self, fn):
self.fn = fn
def __call__(self, *args, **kwargs):
print(f'calling {self.fn.__name__}')
return self.fn(*args, **kwargs)
@MyDecorator
def add(a, b):
return a + b
print(add) # <__main__.MyDecorator object at 0x...> ← instance, not function!
print(type(add)) # <class '__main__.MyDecorator'>
print(add(2, 3))
# calling add
# 5
Что произошло:
@MyDecorator def add(...)≡add = MyDecorator(add).MyDecorator.__init__вызывается с оригинальнойadd. Instance создан, вself.fnсохранён оригинал.- Имя
addтеперь связано с instanceMyDecorator, а не с функцией. add(2, 3)— вызывает instance как функцию; CPython смотритtp_callslot (см. M04 урок 02), находит__call__, вызывает его.__call__черезself.fnобращается к оригиналу.
type(add) — MyDecorator, не function. Это критическое отличие от closure-based wrapper.
__call__ под капотом: tp_call slot
В M04 урок 02 мы видели dunder→tp_slot mapping. __call__ мапится в tp_call slot:
# М04 урок 02 baseline:
# - __call__ → tp_call
# - __init__ → tp_init
# - __hash__ → tp_hash
# - __eq__ → tp_richcompare
# Когда CPython видит add(2, 3):
# 1. type(add) = MyDecorator
# 2. lookup tp_call в MyDecorator's PyTypeObject → slot_tp_call
# 3. slot_tp_call вызывает __call__(self=add_instance, args=(2, 3))
# 4. __call__ runs, делает self.fn(*args)
Cite: Objects/typeobject.c — slot_tp_call reads tp_dict.__call__. Это тот же mechanism, что для regular functions (function objects сами имеют tp_call, ссылающийся на function_call).
# Empirical demonstration:
print(MyDecorator.__call__) # <function MyDecorator.__call__ at 0x...>
print(callable(add)) # True — instance callable, потому что __call__ есть
Recipe 1: CountCalls (empirically verified)
Это canonical class-based decorator example. Counter хранится как self.calls, не как closure cell:
class CountCalls:
def __init__(self, fn):
self.fn = fn
self.calls = 0
def __call__(self, *args, **kwargs):
self.calls += 1
return self.fn(*args, **kwargs)
@CountCalls
def hi():
return 'hi'
hi()
hi()
print(hi.calls) # 2 ← empirical, verified Python 3.12.7
[VERIFIED: Python 3.12.7 локально 2026-04-28] — точно hi.calls == 2 после двух вызовов.
Almost-equivalent closure-based version:
def count_calls(fn):
def wrapper(*args, **kwargs):
wrapper.calls += 1
return fn(*args, **kwargs)
wrapper.calls = 0
return wrapper
@count_calls
def hi():
return 'hi'
hi(); hi()
print(hi.calls) # 2
Closure-version тоже работает: wrapper.calls — атрибут на функции (functions allow arbitrary attrs). Так в чём же разница?
| Aspect | Class-based (CountCalls) | Closure-based (count_calls) |
|---|---|---|
| State storage | self.calls — instance attr | wrapper.calls — function attr |
| Inspectable | dir(hi) показывает методы класса + state | hi.__closure__ показывает cells (less readable) |
| Multiple instances | Cls(fn) явно создаёт instance | Каждый dec(fn) создаёт новый wrapper |
| Subclassing | Можно — class Counted(CountCalls): pass | Нельзя (functions не subclass’ятся) |
| Type hint | decorated: CountCalls clear-typed | decorated: Callable[..., Any] opaque |
Pitfall 4 (lost __name__) | YES — нужен update_wrapper(self, fn) | Можно через @wraps(fn) |
Когда class-based:
- Состояние сложное (несколько counters, dict-кэш, queue) — instance attrs читаемее, чем
wrapper.foo = ...patches. - Нужен subclass — например,
class TimingCount(CountCalls):adds timing. - Нужны методы decorated-объекта (e.g.,
hi.reset_counter()).
Когда closure-based:
- Простое состояние или его нет — closure compact и идиоматичен.
- Стандартный
@functools.wrapsсразу даёт__name__/__doc__.
Pitfall 4: class-based decorator теряет __name__
Empirical demonstration:
class CountCalls:
def __init__(self, fn):
self.fn = fn
self.calls = 0
def __call__(self, *args, **kwargs):
self.calls += 1
return self.fn(*args, **kwargs)
@CountCalls
def hi():
"""Greet briefly."""
return 'hi'
print(hi.__name__) # AttributeError: 'CountCalls' object has no attribute '__name__'
# ↑ instance не имеет атрибута __name__ — это атрибут functions, не classes
Pitfall: hi — instance CountCalls, у которого нет __name__. Любая introspection-tool (pytest, Sphinx, inspect.signature) сломается.
Workaround: functools.update_wrapper(self, fn) в __init__ — копирует metadata из fn в self:
from functools import update_wrapper
class CountCalls:
def __init__(self, fn):
self.fn = fn
self.calls = 0
update_wrapper(self, fn) # ← копируем __name__, __doc__, __module__ из fn в self
def __call__(self, *args, **kwargs):
self.calls += 1
return self.fn(*args, **kwargs)
@CountCalls
def hi():
"""Greet briefly."""
return 'hi'
print(hi.__name__) # 'hi' ← OK!
print(hi.__doc__) # 'Greet briefly.' ← OK!
print(hi.__wrapped__) # <function hi at 0x...> ← оригинал доступен
hi(); hi()
print(hi.calls) # 2
update_wrapper(self, fn) использует тот же WRAPPER_ASSIGNMENTS list (__module__, __name__, __qualname__, __annotations__, __doc__) — копирует их на instance. Cite Lib/functools.py — функция update_wrapper (см. урок 02 для source code).
Rule: каждый class-based decorator должен начинаться с update_wrapper(self, fn) в __init__. Это equivalent @wraps(fn) для closure-based, но не работает как декоратор-syntax (потому что self — instance, не function — wraps ожидает function как target).
Recipe 2: Registry — collect decorated functions
Pattern для собрать список functions, помеченных декоратором — handlers, plugins, routes, validators. Это backbone Flask @app.route('/'), FastAPI @app.get('/'), pytest fixture registries.
from functools import update_wrapper
class Registry:
def __init__(self):
self.functions = []
def register(self, fn):
"""Decorator: добавляет fn в registry, возвращает fn без изменений."""
self.functions.append(fn)
return fn
def call_all(self, *args, **kwargs):
"""Вызывает каждую registered функцию, возвращает list результатов."""
return [fn(*args, **kwargs) for fn in self.functions]
# Используется так:
registry = Registry()
@registry.register
def double(x):
return x * 2
@registry.register
def square(x):
return x ** 2
@registry.register
def negate(x):
return -x
print(registry.functions)
# [<function double>, <function square>, <function negate>]
print(registry.call_all(5))
# [10, 25, -5]
Что нового vs CountCalls:
registry.register— это method, not class — действует как декоратор без instantiation per-call. Сам метод возвращаетfnбез обёртки, просто как side effect добавляет в list.- State в outer instance —
registry.functions, не каждой decorated-function отдельно. fnостаётся functions, не instance — никаких Pitfall 4 issues,double.__name__ == 'double'.
Production examples:
# Flask-like route registry:
class Router:
def __init__(self):
self.routes = {}
def route(self, path):
def decorator(fn):
self.routes[path] = fn
return fn
return decorator
app = Router()
@app.route('/users')
def get_users():
return {'users': []}
@app.route('/orders')
def get_orders():
return {'orders': []}
print(app.routes)
# {'/users': <function get_users>, '/orders': <function get_orders>}
app.route(path) — параметризованный декоратор, method на instance. Это тот же pattern, что Flask/FastAPI.
Recipe 3: rate limiter с instance state
Класс хранит last_called: float, проверяет, прошло ли достаточно времени:
import time
from functools import update_wrapper
class RateLimit:
"""Декоратор: вызов fn не чаще раз в `min_interval_seconds`."""
def __init__(self, min_interval_seconds):
self.min_interval_seconds = min_interval_seconds
def __call__(self, fn):
last_called = [0.0] # mutable list для closure (не self — фабрика-уровень)
def wrapper(*args, **kwargs):
now = time.perf_counter()
elapsed = now - last_called[0]
if elapsed < self.min_interval_seconds:
wait = self.min_interval_seconds - elapsed
print(f'rate limit: would wait {wait:.3f}s')
# В production — time.sleep(wait); здесь skipping для Pyodide-friendly demo
last_called[0] = time.perf_counter()
return fn(*args, **kwargs)
update_wrapper(wrapper, fn)
return wrapper
@RateLimit(min_interval_seconds=0.5)
def expensive_api_call():
return 'api result'
print(expensive_api_call())
print(expensive_api_call())
# rate limit: would wait 0.499s (примерно)
# api result
# api result
Здесь RateLimit — параметризованный class-based decorator: RateLimit(0.5) создаёт instance; __call__(fn) принимает функцию и возвращает wrapper. Note: __call__ тут принимает fn, а не *args — это двухступенчатый pattern (analog three-nested defs, см. урок 02).
Pitfall: в этом recipe instance __call__(fn) возвращает closure-based wrapper, не self. Это гибридный подход — class для хранения настройки (min_interval_seconds), closure для wrapper’а. Альтернатива — pure class-based, где __call__ обрабатывает реальный вызов; но тогда нужен __init__(fn, min_interval_seconds) и параметры передаются после конструктора. Для параметризованных декораторов гибрид обычно чище.
Diagram: class-based decorator lifecycle
Жизненный цикл:
- Декорирование:
@MyDecorator def hi≡hi = MyDecorator(hi).__init__runs. - Instance создан: имя
hiсвязано с instance. - Вызов:
hi(2, 3)—tp_callslot вызывает__call__(self, 2, 3).__call__черезself.fnобращается к оригиналу.
Cross-link M03 урок 04: lru_cache pattern — instance-like state
@functools.lru_cache(maxsize=128) (M03 урок 04) хранит cache в dict — это та же идея, что class-based decorator: instance state, доступная через атрибут (fn.cache_info(), fn.cache_clear()). Под капотом lru_cache — С-acceleration, but pure-Python fallback в Lib/functools.py использует closure с captured dict, не class.
from functools import lru_cache
@lru_cache(maxsize=128)
def fib(n):
return n if n < 2 else fib(n-1) + fib(n-2)
fib(10)
print(fib.cache_info()) # CacheInfo(hits=8, misses=11, maxsize=128, currsize=11)
print(fib.cache_clear()) # None — clears cache
fib — wrapper (closure-based в pure-Python implementation), но имеет методы (cache_info, cache_clear) — атрибуты function. Class-based вариант мог бы быть LRUCache(maxsize=128) с self.cache dict и self.hits, self.misses counter — те же features, более inspectable. Это design trade-off.
Trade-off matrix: class-based vs closure-based
| Criterion | Class-based | Closure-based |
|---|---|---|
| State complexity | Multiple attrs, methods | Simple counter / dict |
| Subclassing | Native | Не работает |
| Introspection | dir(instance) | inspect.getclosurevars |
__name__ etc. | Нужен update_wrapper(self, fn) | @wraps(fn) |
| Boilerplate | More — class, __init__, __call__ | Less — функция в функции |
| Familiarity | OOP-mindset | Functional-mindset |
| Production examples | Flask Class-Based Views, Django Mixins, pytest fixtures | functools.lru_cache, @dataclass, @property (descriptors!) |
Default pick: closure-based для simple wrappers (timing, retry, logging); class-based когда state сложнее или нужно subclass’ить.
Pitfall checklist
| # | Trap | Fix |
|---|---|---|
| 1 | Class-based instance не имеет __name__ (Pitfall 4) | update_wrapper(self, fn) в __init__ |
| 2 | Параметризованный class-based — __init__ принимает params, __call__(fn) возвращает wrapper | Гибридный pattern (или two stages) |
| 3 | self.calls shared across all calls — ОК, это и есть state | (это feature, not bug) |
| 4 | Вызов Cls(fn1)(fn2) — __init__(fn1), __call__(fn2) — confusing API | Документируйте: что __init__, что __call__ |
| 5 | Subclass должен переопределить __call__, не __init__ | OOP basics — поведение в __call__ |
Ключевые выводы
- Class-based decorator pattern:
__init__(self, fn)сохраняет fn;__call__(self, *a, **kw)вызывает. Instance — wrapper, callable благодаряtp_callslot (M04 урок 02). - Pitfall 4: instance не имеет
__name__функции; обязательноfunctools.update_wrapper(self, fn)в__init__. - Recipe
CountCalls: counter вself.calls— empiricallyhi.calls = 2после двух вызовов. Verified Python 3.12.7. - Recipe
Registry: method-as-decorator (@registry.register) для collect functions — backbone Flask/FastAPI routes, pytest fixtures. - Recipe rate limiter: гибридный pattern — class для config (
min_interval_seconds), closure для wrapper’а. - Trade-off vs closure: class-based лучше для сложного state, subclassing, методов на decorated-объекте. Closure-based — для simple wrappers, идиоматичнее.
В уроке 04 — переход к context managers: __enter__ / __exit__ protocol для resource lifecycle. Это другой dunder pattern (не __call__), но та же идея — class-based encapsulation.