Перейти к содержанию
Языки

Декораторы в Python с параметрами: от замыкания до декоратора класса

Декоратор — функция, которая принимает функцию и возвращает замену; запись @decorator над def — синтаксический сахар для f = decorator(f). Зачем это нужно: повторы запросов, логирование, замер времени, кэш, регистрация обработчиков — всё, что хочется добавить к функции, не трогая её тело. Ниже путь от замыкания до декоратора с параметрами, методов, классов и async-функций. Время и паузы подменены фейковыми часами, поэтому вывод детерминирован; код проверен на Python 3.12 и 3.14.

Автор: Опубликовано

8 мин чтенияРедакционный разборОбновлено
  • Python
  • декораторы
  • functools
  • замыкания
  • собеседование
Главная мысль

Декоратор с параметрами — это фабрика декораторов: внешний вызов принимает настройки, средний — функцию, внутренний заменяет вызов. Всегда ставьте 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)
Свой кэш или functools
ВариантРазмерСтатистика и сбросКогда брать
Свой 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, архитектуру и поведенческие истории.

Начать подготовку

Продолжить подготовку