Learning Platform
Глоссарий Troubleshooting
Урок 07.03 · 22 мин
Продвинутый
Class-based decorator__init____call__update_wrapperRegistry patternCountCallsPitfall 4

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.

В этом уроке:

  1. Pattern: __init__(self, fn) сохраняет fn; __call__(self, *a, **kw) вызывает.
  2. Trade-off vs closure: когда class-based лучше, когда closure-based.
  3. Pitfall 4 — class-based decorator теряет __name__ функции; workaround functools.update_wrapper(self, fn) в __init__.
  4. Recipe 1: CountCalls — counter pattern (empirically verified hi.calls = 2).
  5. Recipe 2: Registry — pattern для collect’а decorated функций (event handlers, plugins).
  6. 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

Что произошло:

  1. @MyDecorator def add(...)add = MyDecorator(add). MyDecorator.__init__ вызывается с оригинальной add. Instance создан, в self.fn сохранён оригинал.
  2. Имя add теперь связано с instance MyDecorator, а не с функцией.
  3. add(2, 3) — вызывает instance как функцию; CPython смотрит tp_call slot (см. 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.cslot_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). Так в чём же разница?

AspectClass-based (CountCalls)Closure-based (count_calls)
State storageself.calls — instance attrwrapper.calls — function attr
Inspectabledir(hi) показывает методы класса + statehi.__closure__ показывает cells (less readable)
Multiple instancesCls(fn) явно создаёт instanceКаждый dec(fn) создаёт новый wrapper
SubclassingМожно — class Counted(CountCalls): passНельзя (functions не subclass’ятся)
Type hintdecorated: CountCalls clear-typeddecorated: 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).

TIP

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:

  1. registry.register — это method, not class — действует как декоратор без instantiation per-call. Сам метод возвращает fn без обёртки, просто как side effect добавляет в list.
  2. State в outer instanceregistry.functions, не каждой decorated-function отдельно.
  3. 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).

WARNING

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
MyDecorator.__init__(fn)
instance
hi(2, 3)

Жизненный цикл:

  1. Декорирование: @MyDecorator def hihi = MyDecorator(hi). __init__ runs.
  2. Instance создан: имя hi связано с instance.
  3. Вызов: hi(2, 3)tp_call slot вызывает __call__(self, 2, 3). __call__ через self.fn обращается к оригиналу.

@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

CriterionClass-basedClosure-based
State complexityMultiple attrs, methodsSimple counter / dict
SubclassingNativeНе работает
Introspectiondir(instance)inspect.getclosurevars
__name__ etc.Нужен update_wrapper(self, fn)@wraps(fn)
BoilerplateMore — class, __init__, __call__Less — функция в функции
FamiliarityOOP-mindsetFunctional-mindset
Production examplesFlask Class-Based Views, Django Mixins, pytest fixturesfunctools.lru_cache, @dataclass, @property (descriptors!)

Default pick: closure-based для simple wrappers (timing, retry, logging); class-based когда state сложнее или нужно subclass’ить.


Pitfall checklist

#TrapFix
1Class-based instance не имеет __name__ (Pitfall 4)update_wrapper(self, fn) в __init__
2Параметризованный class-based — __init__ принимает params, __call__(fn) возвращает wrapperГибридный pattern (или two stages)
3self.calls shared across all calls — ОК, это и есть state(это feature, not bug)
4Вызов Cls(fn1)(fn2)__init__(fn1), __call__(fn2) — confusing APIДокументируйте: что __init__, что __call__
5Subclass должен переопределить __call__, не __init__OOP basics — поведение в __call__

Ключевые выводы

  1. Class-based decorator pattern: __init__(self, fn) сохраняет fn; __call__(self, *a, **kw) вызывает. Instance — wrapper, callable благодаря tp_call slot (M04 урок 02).
  2. Pitfall 4: instance не имеет __name__ функции; обязательно functools.update_wrapper(self, fn) в __init__.
  3. Recipe CountCalls: counter в self.calls — empirically hi.calls = 2 после двух вызовов. Verified Python 3.12.7.
  4. Recipe Registry: method-as-decorator (@registry.register) для collect functions — backbone Flask/FastAPI routes, pytest fixtures.
  5. Recipe rate limiter: гибридный pattern — class для config (min_interval_seconds), closure для wrapper’а.
  6. 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.

Проверьте понимание

Результат: 0 из 0
Прикладной
Вопрос 1 из 4. Что вернёт `hi.calls` после следующего кода? ```python 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() ```

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5