Декоратор с параметрами — это фабрика декораторов: внешний вызов принимает настройки, средний — функцию, внутренний заменяет вызов. Всегда ставьте functools.wraps, для async-функций делайте async-обёртку с await, а вместо самописного кэша берите functools.cache или lru_cache.
Замыкание, простой декоратор и зачем functools.wraps
Функции в Python — обычные объекты: их можно передать аргументом и вернуть из другой функции. Внутренняя функция, которая использует переменные внешней, — замыкание. Здесь count_calls возвращает wrapper, а счётчик calls живёт в его замыкании: area.__code__.co_freevars печатает ('calls', 'func'). Справочник языка описывает декоратор так же: выражение вычисляется при определении функции, и имя связывается с результатом вызова.
Теперь о том, что ломается. После декорирования area.__name__ равно wrapper, docstring пропал (None), а inspect.signature показывает (*args, **kwargs): пострадают логи, help(), автодокументация и фреймворки, которые смотрят на сигнатуру. Добавьте @functools.wraps(func) над def wrapper — и тот же print выведет area Площадь прямоугольника. (width: float, height: float = 1.0) -> float, а у обёртки появится __wrapped__ со ссылкой на исходную функцию.
import inspect
def count_calls(func):
calls = 0 # живёт в замыкании wrapper
def wrapper(*args, **kwargs):
nonlocal calls
calls += 1
print(f"{func.__name__}: вызов №{calls}")
return func(*args, **kwargs)
return wrapper
@count_calls # то же, что area = count_calls(area)
def area(width: float, height: float = 1.0) -> float:
"""Площадь прямоугольника."""
return width * height
print(area(2, 3), area(5))
print(area.__code__.co_freevars)
print(area.__name__, area.__doc__, inspect.signature(area))Декоратор с параметрами: три уровня вложенности
Когда декоратору нужны настройки, @retry(times=3) сначала вызывает retry с аргументами, и только результат этого вызова применяется к функции. Отсюда три уровня: retry принимает параметры и возвращает decorator, decorator принимает функцию и возвращает wrapper, wrapper выполняет вызов с повторами. Пауза между попытками передаётся параметром sleep: по умолчанию это time.sleep, а в примере — pauses.append, чтобы видеть задержки без ожидания.
Вывод: «попытка 1: ConnectionError('timeout #1')», «попытка 2: ConnectionError('timeout #2')», затем 200 OK 3 [0.1, 0.2] — третий вызов успешен, паузы растут экспоненциально. Если функция бросит ValueError, которого нет в exceptions, он вылетит с первой попытки без повторов. А если забыть скобки и написать @retry, функция станет аргументом times, и вызов fetch() упадёт с TypeError: retry.<locals>.decorator() missing 1 required positional argument: 'func'.
import functools
import time
def retry(times=3, exceptions=(Exception,), delay=0.1, sleep=time.sleep):
def decorator(func): # уровень 2: получает функцию
@functools.wraps(func)
def wrapper(*args, **kwargs): # уровень 3: заменяет вызов
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except exceptions as exc:
print(f"попытка {attempt}: {exc!r}")
if attempt == times:
raise
sleep(delay * 2 ** (attempt - 1))
return wrapper
return decorator # уровень 1 вернул декоратор
pauses, calls = [], 0
@retry(times=3, exceptions=(ConnectionError,), sleep=pauses.append)
def fetch():
global calls
calls += 1
if calls < 3:
raise ConnectionError(f"timeout #{calls}")
return "200 OK"
print(fetch(), calls, pauses)Один декоратор и со скобками, и без
Ошибку с забытыми скобками можно убрать, если декоратор понимает оба вызова. Приём: первый параметр func=None, остальные — только именованные. @timed передаёт функцию сразу, а @timed(clock=…) вызывает timed без функции, и тогда мы возвращаем functools.partial с запомненными настройками. Звёздочка в сигнатуре заставляет передавать настройки по имени: по позиционному аргументу декоратор не смог бы отличить часы от декорируемой функции.
Часы внедрены параметром clock. Фейковые часы itertools.count(0, 0.25).__next__ при каждом вызове возвращают значение на 0,25 больше, поэтому каждый вызов build_report логирует ровно build_report: 0.250 s на любой машине. Последняя строка, ping True, подтверждает, что вариант без скобок тоже обернул функцию. Тот же приём делает тестируемыми ретраи и таймауты: всё, что зависит от времени, передавайте в декоратор, а не берите из глобального модуля.
import functools
import itertools
import time
def timed(func=None, *, clock=time.perf_counter, log=print):
if func is None: # вызвали как @timed(...)
return functools.partial(timed, clock=clock, log=log)
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = clock()
try:
return func(*args, **kwargs)
finally:
log(f"{func.__name__}: {clock() - start:.3f} s")
return wrapper
fake_clock = itertools.count(0, 0.25).__next__ # каждый вызов: +0.25 «секунды»
@timed(clock=fake_clock)
def build_report(rows):
return sum(rows)
@timed # без скобок тоже работает
def ping():
return "pong"
print(build_report([1, 2, 3]))
print(build_report([4]))
print(ping.__name__, ping is not ping.__wrapped__)Декораторы методов и классов
Функция-декоратор работает с методами без изменений: self просто приходит первым в *args. С классом-декоратором сложнее. CountCalls хранит состояние в атрибутах, и это удобно, но экземпляр класса — не функция и сам к объекту не привязывается. Без метода __get__ вызов Greeter().hello("Оля") падает с TypeError: Greeter.hello() missing 1 required positional argument: 'name' — строка «Оля» заняла место self.
__get__ превращает декоратор в дескриптор и возвращает types.MethodType, то есть привязанный метод, — так работают обычные функции, как объясняет руководство по дескрипторам. Декоратор класса — другое: register получает сам класс, регистрирует и возвращает его. Вывод: hi, Оля hi, Иван, затем 2 ['greeter']. Знакомый всем пример декоратора класса из стандартной библиотеки — @dataclass. Для кэша методов FAQ Python советует cached_property или lru_cache и предупреждает, что lru_cache держит ссылку на экземпляр.
import functools
import types
class CountCalls: # класс-декоратор
def __init__(self, func):
functools.update_wrapper(self, func)
self.func, self.calls = func, 0
def __call__(self, *args, **kwargs):
self.calls += 1
return self.func(*args, **kwargs)
def __get__(self, obj, objtype=None): # без него self не привяжется
return self if obj is None else types.MethodType(self, obj)
REGISTRY = {}
def register(cls): # декоратор класса
REGISTRY[cls.__name__.lower()] = cls
return cls
@register
class Greeter:
@CountCalls
def hello(self, name):
return f"hi, {name}"
print(Greeter().hello("Оля"), Greeter().hello("Иван"))
print(Greeter.hello.calls, list(REGISTRY))| Что | Принимает | Возвращает | Пример |
|---|---|---|---|
| Декоратор метода | функцию в теле класса | обёртку; self в *args | @count_calls над def |
| Класс-декоратор | функцию в __init__ | экземпляр с __call__ | CountCalls |
| Декоратор класса | класс | обычно тот же класс | @dataclass, register |
Декоратор для async-функций
Если обернуть async-функцию обычным wrapper, декоратор отработает не тогда, когда вы ждёте. Вызов async-функции только создаёт объект корутины, а тело выполнится позже, при await. Поэтому naive печатает start load и done load ещё до loading b: замер времени или лог «готово» в такой обёртке ничего не измеряют, а исключения из тела пролетят мимо try/except в обёртке. Ретрай из предыдущего раздела, навешенный на async-функцию, по той же причине не повторит ни одной попытки: ошибка возникнет уже после того, как обёртка вернула корутину.
Правильная обёртка сама объявлена через async def и делает await func(...): для log_calls порядок вывода — start load, loading a, done load. Последняя строка, True False, показывает ещё одно отличие: inspect.iscoroutinefunction распознаёт async-обёртку, но не синхронную, а некоторые фреймворки по этой проверке решают, как вызывать обработчик. Универсальный декоратор выбирает ветку при декорировании через inspect.iscoroutinefunction(func).
import asyncio
import functools
import inspect
def log_calls(func):
@functools.wraps(func)
async def wrapper(*args, **kwargs):
print("start", func.__name__)
result = await func(*args, **kwargs) # ждём реальное выполнение
print("done", func.__name__)
return result
return wrapper
def naive(func): # синхронная обёртка для async
@functools.wraps(func)
def wrapper(*args, **kwargs):
print("start", func.__name__)
coro = func(*args, **kwargs) # тело ещё не выполнялось
print("done", func.__name__)
return coro
return wrapper
async def load(key):
print("loading", key)
return key.upper()
async def main():
print(await log_calls(load)("a"))
print(await naive(load)("b"))
print(inspect.iscoroutinefunction(log_calls(load)), inspect.iscoroutinefunction(naive(load)))
asyncio.run(main())Кэш: самописный декоратор или functools
Мемоизация — популярное упражнение на декораторы, но в рабочем коде лучше functools. @cache — неограниченный кэш, по документации то же, что lru_cache(maxsize=None), только легче. @lru_cache(maxsize=2) хранит два последних значения: на последовательности 1, 2, 1, 3, 2 функция вычисляет 1, 2, 3 и снова 2, потому что при добавлении тройки вытеснилась давно не использованная двойка. cache_info() это подтверждает: CacheInfo(hits=1, misses=4, maxsize=2, currsize=2).
Аргументы любого такого кэша должны быть хешируемыми: самописный memoize на списке падает с TypeError: unhashable type: 'list' в Python 3.12, а 3.14 пишет подробнее — cannot use 'tuple' as a dict key (unhashable type: 'list'). У lru_cache есть то, что самописный вариант обычно упускает: потокобезопасная структура кэша, cache_clear() и доступ к исходной функции через __wrapped__. И оговорка из документации: вызовы f(a=1, b=2) и f(b=2, a=1) могут занять в кэше две отдельные записи.
import functools
def memoize(func): # самодельный кэш
store = {}
@functools.wraps(func)
def wrapper(*args):
if args not in store:
store[args] = func(*args)
return store[args]
return wrapper
@functools.lru_cache(maxsize=2)
def square(n):
print("compute", n)
return n * n
for n in (1, 2, 1, 3, 2):
square(n)
print(square.cache_info())
@memoize
def slow_len(items):
return len(items)
try:
slow_len([1, 2])
except TypeError as exc:
print("TypeError:", exc)| Вариант | Размер | Статистика и сброс | Когда брать |
|---|---|---|---|
| Свой memoize | без лимита | нет | учебная задача |
| functools.cache | без лимита | cache_info, cache_clear | мало разных входов |
| functools.lru_cache(n) | n последних | cache_info, cache_clear | много разных входов |
Вопросы для самопроверки
Перед собеседованием проверьте, что отвечаете на эти вопросы без подсказки. Если где-то не уверены, вернитесь к разделу и перепишите пример по памяти, а не перечитывайте его. Если на собеседовании попросят написать декоратор с нуля, пригодится моторная память, поэтому тренируйте именно набор кода: три уровня вложенности, wraps и возврат результата. Сборник вопросов по Python и разбор asyncio помогут связать декораторы с соседними темами.
Упражнение на закрепление: напишите декоратор rate_limit(calls, per, clock=time.monotonic), который бросает исключение при превышении лимита, и протестируйте его фейковыми часами, как timed. RecallDeck поможет вернуться к этим вопросам через несколько дней: повторение с интервалами держит синтаксис трёх уровней в памяти надёжнее одного чтения.
- Во что разворачивается @retry(times=3) над def fetch и в какой момент вызывается retry?
- Что сломается без functools.wraps и как это увидеть?
- Почему класс-декоратор без __get__ ломает методы?
- Почему синхронная обёртка не измеряет время async-функции?
- Чем functools.cache отличается от lru_cache(maxsize=128)?
Коротко
Частые вопросы
Зачем нужны декораторы, если можно вызвать функцию внутри другой?
Декоратор делает то же самое, но один раз и в месте определения: логику повторов или логирования не нужно копировать в каждый вызов, а читатель видит её прямо над def. Минус — лишний уровень косвенности при отладке, поэтому декораторы оставляют для сквозных задач.
В каком порядке применяются несколько декораторов?
Снизу вверх: @a над @b над def f даёт f = a(b(f)), справочник Python показывает это на примере @f1(arg) и @f2. При вызове первой срабатывает внешняя обёртка a, поэтому логирование над кэшем видит каждый вызов, а под кэшем — только промахи.
Как обратиться к аргументам функции по имени внутри обёртки?
Параметры декоратора фиксируются один раз при определении, а аргументы вызова приходят в обёртку через *args и **kwargs. Чтобы разобрать их по именам, используйте inspect.signature(func).bind(*args, **kwargs): это работает и с позиционными, и с именованными аргументами.
Как протестировать функцию без её декоратора?
Если обёртка сделана через functools.wraps, исходная функция доступна как __wrapped__; документация lru_cache предлагает так обходить кэш. В юнит-тестах это позволяет проверить чистую логику отдельно от повторов, кэша и логирования.
Источники
Источники и редакционная политика
Сведения проверены 7 ноября 2026 г. Ссылки рядом с разделами указывают источники фактов и технических объяснений. Выводы, учебные сценарии и рекомендации по подготовке — редакционная работа RecallDeck.
От чтения к воспроизведению
Отрепетируйте полный цикл интервью.
RecallDeck возвращает сложные темы по расписанию и помогает удерживать в памяти язык, SQL, архитектуру и поведенческие истории.