Москва: проверенные б/у ноутбуки с гарантией и проверкой перед оплатой
+7 906 722-94-45

Главная → Статьи → Декораторы Python: практика и ловушки

Декораторы в Python: functools.wraps, параметры и типовые ошибки

Декораторы без магии: как устроены, functools.wraps и метаданные, декораторы с параметрами и классы-декораторы, кеш, ретраи и отладка типичных поломок.

Прямой ответ

Декоратор — функция, принимающая функцию и возвращающую функцию (обычно обёртку с добавленным поведением); @deco def f() — синтаксический сахар f = deco(f) — подстановка на месте определения. Первое правило: @functools.wraps(f) на обёртке обязателен — без него обёртка теряет имя/докстринг/модуль оригинала (f.__name__ == 'wrapper' — сломанные логи, отладчик, документация, роутеры фреймворков, полагающиеся на метаданные); второе: обёртка принимает *args, **kwargs и возвращает результат вызова (return f(...) — забытый return превращает функцию в None-возврат — классический баг самописных декораторов). Типовые применения, которые стоит уметь писать самому: логирование/тайминг вызовов, ретраи с backoff для сетевых операций (httpx-практика), кеш (functools.lru_cache/cache — stdlib-эталон — кеш-паттерны), регистрация обработчиков (реестр по ключу), проверка прав/таймауты. Продвинутые формы: декоратор с параметрами — трёхэтажная функция (фабрика → декоратор → обёртка) или класс с __call__; порядок стека декораторов (нижний применяется первым — @a @b f = a(b(f)) — порядок меняет поведение: кеш под ретраем и ретрай под кешем — разные семантики). И граница уместности: декоратор — для сквозного поведения вокруг вызова; бизнес-логика внутри обёртки — сигнал, что нужна обычная функция/сервис, а не «ещё один декоратор».

Механика: wraps, порядок, методы

Разбор по слоям: обёртка замыкает оригинал (живёт, пока жива обёртка); @wraps копирует __name__/__doc__/__dict__/__wrapped__ (последнее — доступ к оригиналу через f.__wrapped__ — отладка и тесты «под декоратором»); сигнатура обёртки (*args, **kwargs) универсальна, но скрывает реальную сигнатуру от IDE/типизатора — для библиотечного кода ParamSpec/TypeVar сохраняют типы сквозь декоратор (mypy-практика); functools.wraps + inspect.signature (следует __wrapped__) восстанавливают интроспекцию. Методы и классы: декоратор на методе работает так же (self приходит первым аргументом сквозь kwargs); property/classmethod/staticmethod — сами дескрипторы-декораторы (дескрипторы); кеш на методе через lru_cache — держит self в ключе (утечка экземпляров — память — кеш на функции от данных или явный кеш-объект). Порядок стека: @retry @cache def f — retry снаружи: ретраит вызов кеша (повтор — из кеша, смысла мало); @cache @retry — кеш снаружи: кеширует результат успешного ретраируемого вызова (обычно задуманное) — порядок читается снизу вверх и проектируется.

Декораторы с параметрами и классы

Фабрика: def retry(times=3, delay=0.5): def deco(f): @wraps(f) def wrapper(*a, **kw): ...; return wrapper; return deco — @retry(times=5) вызывает фабрику, возвращающую декоратор (три уровня вложенности — максимум читаемости; глубже — класс или functools.partial). Класс-декоратор: __init__ сохраняет параметры, __call__ — обёртка (состояние между вызовами — счётчики/флаги живут в self; на методах — осторожно: экземпляр класса-декоратора общий для всех вызовов — состояние гонок в конкурентном коде — локальное состояние в wrapper-локали). Встроенные/библиотечные образцы для подражания: functools.lru_cache (параметризуемый), property, pytest.mark.* (маркеры-метаданные — не обёртки, а атрибуты для коллекции — pytest-плагины), Flask/FastAPI-роутеры (регистрация + возврат оригинала). Отладка декорированного: f.__wrapped__ — оригинал; стектрейсы показывают обёртку (без wraps — ещё и безымянную); breakpoint внутри wrapper — стандартный путь понять «что съел мой декоратор».

Практика: ретрай, кеш, регистрация — образцы

Ретрай: def retry(times, exc=(ConnectionError,), delay=0.5): ... for attempt in range(times): try: return f(*a, **kw); except exc: if attempt == times-1: raise; time.sleep(delay * 2**attempt + random.random()*0.1) — ретраятся только указанные исключения (дисциплина исключений: ретраить 5xx/сеть, не 4xx), экспоненциальный backoff с джиттером (очередная дисциплина), последний провал пробрасывается с цепочкой; готовые библиотеки (tenacity) — когда нужны сложные политики. Кеш: stdlib @cache/@lru_cache(maxsize=...) покрывает детерминированные чистые функции (аргументы hashable — списки не кешируются; TTL нужен — внешние решения/свой декоратор с меткой времени — паттерны); cache_clear() — сброс в тестах между кейсами (забытый — «тесты влияют друг на друга» через кеш). Регистрация: HANDLERS = {}; def register(name): def deco(f): HANDLERS[name] = f; return f; return deco — фабрика возвращает оригинал (без обёртки) — плагин-реестры без метаклассов (реестр-паттерн). Таймаут (синхронный код): сигнал SIGALRM (только main thread, только Unix) или concurrent.futures с wait(timeout — поток не убивается, но вызов разблокирован) — честный таймаут — на уровне IO-клиента (httpx-timeout), декоратор — грубая страховка.

Практический сценарий: три декоратора в платёжном модуле

Модуль интеграции: внешние вызовы ненадёжны, часть данных справочная, все операции хочется видеть в логах. Набор: (1) @log_call — вход/выход/длительность структурно (маскировка полей-секретов в аргументах — список имён в параметре декоратора — секреты не должны оседать в логах — структура логов); (2) @retry(times=3, exc=(TransportError,)) — только транспортные, backoff, проброс последнего с from; (3) @lru_cache(maxsize=256) на справочнике курсов валют (детерминирован в рамках минуты — TTL-обёртка поверх lru: ключ (код_валюты, минута) — автоинвалидация без cron). Порядок стека: @log_call @retry @ttl_cache def get_rate(...) — лог видит каждую попытку (внутри retry? нет — log снаружи retry: одна запись на операцию с итогом; осознанный выбор — лог попыток внутри retry-обёртки отдельно уровнем debug). Находки тестов: обёртка без wraps сломала FastAPI-эндпоинт, задекорированный тем же log_call (сигнатура скрылась — валидация параметров отвалилась — ParamSpec-вариант починил типы и runtime-интроспекцию); lru_cache на методе с self дал рост памяти на стейдже — заменён на кеш функции от кода валюты; retry ретраил DeclinedError (бизнес-отказ) — сужение exc-списка. Итог: три сквозных поведения переиспользуются в восьми функциях модуля — 40 строк декораторов вместо копипасты try/except/логов в каждом вызове.

FAQ

Почему мой декоратор «ломает» функцию во фреймворке? Почти всегда — потеря метаданных/сигнатуры: @wraps восстанавливает имя/док, ParamSpec — типы, inspect.signature по __wrapped__ — сигнатуру; фреймворки (FastAPI/Flask/pytest) интроспектируют функцию — обёртка без wraps для них «wrapper(*args, **kwargs)» без параметров.

Декоратор или контекстный менеджер? Декоратор — поведение на всём вызове функции (и не требует действий от вызывающего); контекстный менеджер — явная область в коде вызывающего (with-практика); «логировать вызов функции» — декоратор, «открыть/закрыть ресурс вокруг произвольного блока» — with; комбинация (декоратор, внутри использующий with) — норма.

Можно ли снять декоратор с функции? Да при наличии wraps: f.__wrapped__ — оригинальная функция (тесты/отладка вызывают её напрямую); без wraps — оригинал недоступен (ещё один аргумент за wraps); «условный декоратор» в рантайме — f = deco(f) if condition else f — валидный паттерн (декораторы в if — не синтаксис @, но та же механика).

Как тестировать декораторы? Юниты на обёртку: проверка поведения (ретрай вызывает N раз на моке — мок-паттерны), сохранение результата/исключений, метаданных (f.__name__), и «прозрачности» (аргументы доходят без изменений — параметризация); декорированные интеграции тестируются отдельно — декоратор как единица + композиция как интеграция.

Источники: документация functools, PEP 318, Python glossary — ссылки в шапке.