Декораторы в Python: как работают и зачем нужны

Python Автор: Среда и версия: CPython 3.14.5

Декоратор — функция, которая принимает функцию и возвращает новую, с добавленным поведением. Строка @timer над def slow() не делает ничего волшебного: это короткая запись для slow = timer(slow). Всё, что дальше, — следствия из этой одной строчки. Код проверен на Python 3.14.5.

Что делает @ на самом деле

Начнём без сахара. Функция timer принимает другую функцию, оборачивает её в wrapper и возвращает обёртку:

import time

def timer(func):
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        print(f"{func.__name__}: {time.perf_counter() - start:.3f} c")
        return result
    return wrapper

def slow(n):
    return sum(range(n))

slow = timer(slow)     # вот здесь и происходит декорирование
slow(3_000_000)
slow: 0.024 c

Последние две строки и есть весь механизм. Имя slow теперь указывает не на исходную функцию, а на wrapper, который внутри себя помнит оригинал. Синтаксис @ заменяет только присваивание:

@timer
def slow(n):
    return sum(range(n))

Полностью эквивалентно. Никакой разницы в поведении, только меньше букв и присваивание не отъезжает вниз, под тело функции.

*args, **kwargs в обёртке нужны, чтобы она пропускала через себя любые аргументы. Напиши def wrapper(n), и декоратор станет годен ровно для функций с одним позиционным аргументом.

Декоратор срабатывает при определении, а не при вызове

Об этом спотыкаются чаще всего.

def loud(func):
    print(f"декорирую {func.__name__}")
    return func

@loud
def never_called():
    pass

print("функция так и не вызвана")
декорирую never_called
функция так и не вызвана

Тело loud отработало в момент, когда интерпретатор дошёл до def. Функцию при этом не вызывали ни разу. Отсюда практическое следствие: тяжёлая работа в самом декораторе (открыть файл, сходить в сеть, прочитать конфиг) выполнится на импорте модуля, а не тогда, когда её ждут. Тот же механизм «однажды при def» стоит и за ловушкой изменяемого default-аргумента.

Куда пропадает имя функции

Обёртка подменяет функцию целиком, вместе с её именем и документацией.

@timer
def fetch(url):
    """Скачивает страницу."""
    return url

print(fetch.__name__, fetch.__doc__)
wrapper None

Функция называется wrapper, документации нет. Ломается всё, что смотрит на метаданные: help(), автодокументация, логи с именем функции, отладчик. Лечится одной строкой, functools.wraps:

import functools

def timer(func):
    @functools.wraps(func)          # <-- копирует __name__, __doc__, __module__, __qualname__
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper
fetch Скачивает страницу.

wraps кладёт ещё и ссылку на оригинал: fetch.__wrapped__ возвращает недекорированную функцию. Это единственный способ добраться до неё в тестах, когда обёртка мешает.

Правило простое: пишешь декоратор — ставь @functools.wraps(func). Исключений на практике не бывает.

Декоратор с аргументами

Чтобы декоратор сам принимал параметры, нужен ещё один уровень вложенности. @retry(attempts=3) — это вызов, который возвращает декоратор, и уже тот применяется к функции.

def retry(attempts):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for i in range(1, attempts + 1):
                try:
                    return func(*args, **kwargs)
                except ValueError as exc:
                    print(f"попытка {i} упала: {exc}")
            raise RuntimeError(f"{func.__name__}: {attempts} попыток исчерпаны")
        return wrapper
    return decorator

calls = 0

@retry(attempts=3)
def flaky():
    global calls
    calls += 1
    if calls < 3:
        raise ValueError("сеть недоступна")
    return "ok"

print(flaky())
попытка 1 упала: сеть недоступна
попытка 2 упала: сеть недоступна
ok

Три уровня читаются так: внешний принимает настройки, средний принимает функцию, внутренний принимает аргументы вызова. Каждый возвращает следующий.

Забыть скобки при таком декораторе — классика:

@retry            # без (attempts=3)
def broken():
    return 1

broken()
TypeError: retry.<locals>.decorator() missing 1 required positional argument: 'func'

Сообщение понятное, если помнить про уровни: retry получил вместо числа саму функцию broken, вернул decorator, и теперь имя broken указывает на decorator, которому при вызове никто не передал func.

Порядок, когда декораторов несколько

Применяются снизу вверх, а исполняются сверху вниз.

def tag(name):
    def decorator(func):
        @functools.wraps(func)
        def wrapper():
            return f"<{name}>{func()}</{name}>"
        return wrapper
    return decorator

@tag("b")
@tag("i")
def text():
    return "привет"

print(text())
<b><i>привет</i></b>

Ближайший к def навешивается первым, поэтому i оказался внутри. Мнемоника: читай снизу вверх как последовательность обёртываний. Порядок важен не только для разметки. @app.route над @login_required и наоборот дают разное: в одном случае маршрут регистрируется на защищённую функцию, в другом на открытую.

Декоратор на методе

Метод — обычная функция, self просто приезжает первым позиционным аргументом. Универсальной обёртке с *args вообще ничего менять не надо, а если self нужен явно, его выносят в сигнатуру:

def log_calls(func):
    @functools.wraps(func)
    def wrapper(self, *args, **kwargs):
        print(f"{type(self).__name__}.{func.__name__}{args}")
        return func(self, *args, **kwargs)
    return wrapper

class Cart:
    def __init__(self):
        self.items = []

    @log_calls
    def add(self, name, qty=1):
        self.items.append((name, qty))
        return len(self.items)

cart = Cart()
print(cart.add("мышь", qty=2))
Cart.add('мышь',)
1

В args попал только 'мышь': qty передали по имени, и он ушёл в kwargs. Мелочь, которая портит логи, если печатать один args и считать, что там все аргументы.

Встроенные декораторы

Часть декораторов в языке уже есть, и на собеседовании спрашивают именно про них.

class Order:
    tax = 0.2

    def __init__(self, amount):
        self._amount = amount

    @property
    def total(self):
        return round(self._amount * (1 + Order.tax), 2)

    @staticmethod
    def is_valid(amount):
        return amount > 0

    @classmethod
    def free(cls):
        return cls(0)

o = Order(1000)
print(o.total, Order.is_valid(-5), Order.free().total)
1200.0 False 0.0

@property превращает метод в вычисляемый атрибут: o.total пишется без скобок. Заодно он становится доступен только на чтение, пока не объявлен сеттер:

o.total = 999
AttributeError: property 'total' of 'Order' object has no setter

@staticmethod — функция, которой не нужны ни объект, ни класс, просто лежит рядом по смыслу. @classmethod получает класс первым аргументом и обычно служит альтернативным конструктором.

Отдельно стоит functools.cache: он запоминает результат для каждого набора аргументов.

@functools.cache
def fib(n):
    return n if n < 2 else fib(n - 1) + fib(n - 2)
вариантfib(32)
голая рекурсия0.135 c
с @functools.cache0.022 мс

Разница в шесть тысяч раз, и она не про кэш как таковой. Без запоминания fib(32) пересчитывает одни и те же ветки миллионы раз; fib.cache_info() после прогона показывает hits=30, misses=33, то есть каждое значение посчитано ровно однажды.

У cache есть цена: словарь растёт без ограничений и держит ссылки на аргументы. Для чего-то долгоживущего берут @functools.lru_cache(maxsize=1024).

Декоратор классом

Декоратором может быть что угодно вызываемое, в том числе класс с __call__. Так удобнее, когда обёртке нужно хранить состояние.

class CountCalls:
    def __init__(self, func):
        functools.update_wrapper(self, func)
        self.func = func
        self.calls = 0

    def __call__(self, *args, **kwargs):
        self.calls += 1
        return self.func(*args, **kwargs)

@CountCalls
def ping():
    return "pong"

ping(); ping(); ping()
print(f"{ping.__name__} вызвана {ping.calls} раза")
ping вызвана 3 раза

Состояние лежит в атрибуте объекта, а не в замыкании, и его видно снаружи: ping.calls доступен обычным обращением. Аналог functools.wraps для класса — functools.update_wrapper.

На чём ловят на собеседовании

«Декоратор вызывается каждый раз вместе с функцией». Нет. Сам декоратор отрабатывает один раз, при определении. Каждый вызов проходит через обёртку, которую он вернул. Разницу видно в примере с loud выше.

«@functools.wraps — косметика». Пока не понадобится help(), трассировка с именем функции или подмена в тесте. Ещё это ломает интроспекцию в фреймворках: FastAPI и Pydantic читают аннотации и сигнатуру, а обёртка без wraps подсовывает им (*args, **kwargs).

«@cache потокобезопасен». Сам словарь потокобезопасен, а вот вычисление — нет. Два потока, промахнувшиеся одновременно, посчитают значение дважды: между проверкой и записью управление успевает уйти. Механика этого разобрана в статье про GIL, там же показано, почему проверка перед действием ломается независимо от сборки. Для дорогой функции ставь замок вокруг вычисления сам.

«Декоратор не может испортить функцию». Может, и молча. Забыл return func(...) внутри обёртки — функция начнёт возвращать None, не упав ни разу. Ошибка живучая: тесты на побочные эффекты её не видят.

«Замыкание в декораторе — это глобальная переменная». Каждое применение декоратора создаёт своё замыкание со своими переменными. Две функции под @retry(attempts=3) не делят между собой счётчик попыток.

Частые вопросы

Как коротко ответить, что такое декоратор

Функция, которая принимает функцию и возвращает новую с дополненным поведением, а @ — сахар для f = deco(f). Применяется в момент определения, поэтому нужен functools.wraps, иначе теряются имя и документация. Если декоратору нужны свои параметры, добавляется третий уровень вложенности.

Как декорировать функцию, не меняя её объявление

Обычным присваиванием: slow = timer(slow). Так патчат чужой код, до которого не дотянуться синтаксисом, и так же работают unittest.mock.patch и мониторинговые агенты.

Чем декоратор отличается от контекстного менеджера

Декоратор оборачивает вызов целиком, менеджер — произвольный блок кода. Когда нужны оба, contextlib.ContextDecorator даёт один класс, который работает и как with, и как @.

Можно ли декорировать класс

Да, и это частый приём: декоратор получает класс и возвращает класс. Так устроен @dataclass — он читает аннотации полей и дописывает в класс __init__, __repr__ и __eq__.

Что учить дальше

Замыкания, на которых декораторы стоят, и генераторы: вместе они закрывают половину вопросов про «как устроен Python» на техническом интервью. В пути «Python для продолжающих» на Koddo декораторы разбираются задачами с автопроверкой — сначала логирующая обёртка, потом wraps, потом декоратор с параметрами.

Начните с задачи на декоратор-счётчик вызовов: нужно сохранить отдельное состояние каждой обёртки и не изменить результат исходной функции.

Источники