Декораторы Python: как работают, как написать свой и какие есть встроенные

Разработчик за ноутбуком разбирает код с декораторами Python Изучение

Разработчик за ноутбуком разбирает код с декораторами Python

Декоратором в Python называют функцию, которая принимает другую функцию и возвращает новую, обычно обёртку вокруг исходной. Запись @имя над определением функции подменяет её результатом декоратора. Так можно расширять поведение функции: добавить логирование, замер времени или кэш без изменения исходного кода.

Все примеры ниже прогнаны в Python 3.14.3 как один файл: блоки идут подряд, и последний пользуется декораторами из предыдущих. Чтобы повторить, скопируйте их по порядку в один .py-файл и запустите. То, что печатает программа, скопировано из консоли под каждым блоком.

Зачем нужны декораторы

Допустим, замер времени нужен в десяти функциях проекта. Без декоратора в каждую придётся вписать одни и те же три строчки: запомнить время до вызова, после вызова и напечатать разницу. Когда формат вывода понадобится изменить, правка пойдёт по десяти местам, и одно из них легко пропустить.

Декоратор позволяет держать такую логику в одном месте и подключать её одной строчкой над нужной функцией. В случае с замером времени это @timer.

Что такое декоратор и что делает символ @

Руки оборачивают коробку бумагой как метафора функции-обёртки

Основа механизма такая: в Python функция ведёт себя как любой другой объект. Её можно положить в переменную, передать в качестве аргумента и вернуть из другой функции. Именно на этом держатся декораторы. Декоратор получает функцию func, определяет внутри себя функцию wrapper и возвращает её. Имя исходной функции после этого указывает уже на wrapper, а сама func остаётся доступна ему через замыкание. Закрепить синтаксис функций перед декораторами помогут задачи по Python для начинающих, а запускать примеры удобно в VS Code с настроенным Python.

Символ @ называют синтаксическим сахаром: две записи ниже делают одно и то же.

@log_calls
def say_hello(): ...

# то же самое без @
def say_hello(): ...
say_hello = log_calls(say_hello)

@ только сокращает запись. Разница одна: при записи через @ исходная функция ни на миг не попадает в имя say_hello. Вот простейший пример декоратора целиком:

def log_calls(func):
    print(f"Декоратор получил функцию {func.__name__}")

    def wrapper():
        print(f"Вызов {func.__name__}")
        func()
        print(f"Готово: {func.__name__}")

    return wrapper


@log_calls
def say_hello():
    """Печатает приветствие."""
    print("Привет, мир!")


print("Определения закончились")
say_hello()
print(say_hello.__name__, say_hello.__doc__)

Вывод:

Декоратор получил функцию say_hello
Определения закончились
Вызов say_hello
Привет, мир!
Готово: say_hello
wrapper None

Обратите внимание: «Декоратор получил функцию» напечаталось раньше, чем «Определения закончились». Декоратор срабатывает, когда выполняется инструкция def; для функции на уровне модуля это происходит один раз, когда модуль выполняется или импортируется. При каждом вызове say_hello() работает уже wrapper: печатает сообщение до вызова, вызывает исходную функцию и печатает сообщение после.

В последней строчке функция уже называется wrapper, а вместо её docstring напечатано None.

Как написать свой декоратор для функции с аргументами

У декоратора log_calls функция wrapper не принимает аргументов и ничего не возвращает. Если применить его к функции с параметрами, вызов упадёт с TypeError, а результат исходной функции потеряется. Если хотим, чтобы декоратор подходил к любой функции, обёртка должна принимать *args и **kwargs и передавать их дальше без изменений. Рабочий шаблон такой: wrapper(*args, **kwargs) принимает любые позиционные и именованные аргументы, передаёт их в func, сохраняет result и возвращает его.

Читайте также:  Калькулятор на Python: от консольной версии до окна на tkinter

Секундомер рядом с клавиатурой как символ замера времени выполнения функции

Декоратор timer измеряет время выполнения функции. Для замера нужен import time, для wraps импорт из functools.

import time
from functools import wraps


def timer(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        end = time.perf_counter()
        print(f"{func.__name__} работала {end - start:.1f} с")
        return result

    return wrapper


@timer
def slow_sum(numbers, delay=0.2):
    """Складывает числа с задержкой."""
    time.sleep(delay)
    return sum(numbers)


total = slow_sum([4, 8, 15], delay=0.3)
print("Сумма:", total)
print(slow_sum.__name__, "|", slow_sum.__doc__)
print(slow_sum.__wrapped__([16, 23, 42], delay=0))

Результат:

slow_sum работала 0.3 с
Сумма: 27
slow_sum | Складывает числа с задержкой.
81

Модуль time здесь нужен дважды: time.perf_counter() берёт отметку времени перед вызовом и после, а time.sleep() имитирует медленную работу. Разница печатается с точностью до десятых секунды. Если запустите код у себя, время может немного отличаться. Список [4, 8, 15] попал в функцию через *args, аргумент delay=0.3 через **kwargs, а сумма 27 вернулась через return result. Забыть этот return легко, и тогда декорированная функция молча вернёт None вместо числа.

functools.wraps: зачем он нужен

Сравните строку wrapper None из первого примера и slow_sum | Складывает числа с задержкой. из второго. Без wraps функция назвалась wrapper и потеряла docstring. С @wraps(func) имя slow_sum и описание «Складывает числа с задержкой.» остались на месте.

wraps копирует обёртке метаданные исходной функции: __name__, __qualname__, __doc__, __module__, __annotations__, __type_params__, а ещё дополняет её __dict__ атрибутами оригинала. Кроме того, он добавляет атрибут __wrapped__ со ссылкой на исходную функцию. Через него в примере вызвана slow_sum в обход таймера, поэтому сообщения «работала» во второй раз нет. Сумма 81 напечаталась без паузы, потому что передан delay=0.

Без wraps декорированные функции в help() и в логах, которые берут __name__, выглядят одинаково: везде wrapper. Ставьте @wraps(func) в каждый свой декоратор, даже в учебный. Нужно всего лишь добавить импорт from functools import wraps и одну строчку над def wrapper.

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

Иногда декоратору нужна настройка: сколько раз повторить вызов, какой уровень лога писать, в какой тег обернуть ответ. Тогда пишут ещё один уровень вложенности. Внешняя функция принимает параметры и возвращает настоящий декоратор, а тот уже принимает func.

Три вложенные коробки как схема декоратора с параметрами

Декоратор tag(name) из следующего блока оборачивает текст, который вернула функция, в HTML-тег:

from functools import wraps


def tag(name):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            result = func(*args, **kwargs)
            return f"<{name}>{result}</{name}>"

        return wrapper

    return decorator


@tag("b")
@tag("i")
def greet(name):
    return f"Привет, {name}"


print(greet("Оля"))


def plain_greet(name):
    return f"Привет, {name}"


manual = tag("b")(tag("i")(plain_greet))
print(manual("Ира"))

Вывод:

<b><i>Привет, Оля</i></b>
<b><i>Привет, Ира</i></b>

Запись @tag("b") выполняется в два шага. Сначала вызов tag("b") возвращает функцию decorator, у которой в замыкании сохранилось name = "b". Затем decorator применяется к функции, как обычный декоратор без параметров. Сам wrapper(*args, **kwargs) устроен так же, как в timer, и вызывает func с теми же аргументами. Отсюда три уровня вложенности: tag, decorator и wrapper.

Несколько декораторов: в каком порядке они работают

Если декораторов над одной функцией больше одного, они применяются снизу вверх. В примере выше @tag("i") стоит ближе к def, поэтому он оборачивает greet первым, а @tag("b") оборачивает уже результат. Ручная запись tag("b")(tag("i")(plain_greet)) даёт тот же вывод, так удобно проверить порядок, если сомневаетесь.

При вызове порядок обратный: первой срабатывает внешняя обёртка b, она вызывает внутреннюю i, та вызывает саму функцию. Поэтому <i> оказался внутри <b>.

Встроенные декораторы: @property, @staticmethod, @classmethod, lru_cache и cache

Готовые декораторы доступны без установки: одни встроены в язык и используются в классах, другие лежат в модуле functools стандартной библиотеки. В блоке ниже заодно есть декоратор класса register: он принимает класс, записывает его в словарь и возвращает тот же класс.

Читайте также:  Задачи по Python для начинающих: 10 задач с решениями и разбором

Схема класса из прямоугольников и стрелок на рабочем столе программиста

registry = {}


def register(cls):
    registry[cls.__name__] = cls
    return cls


@register
class Temperature:
    def __init__(self, celsius):
        self.celsius = celsius

    @property
    def celsius(self):
        return self._celsius

    @celsius.setter
    def celsius(self, value):
        if value < -273.15:
            raise ValueError(f"{value} ниже абсолютного нуля")
        self._celsius = value

    @property
    def fahrenheit(self):
        return self._celsius * 9 / 5 + 32

    @staticmethod
    def is_freezing(celsius):
        return celsius <= 0

    @classmethod
    def from_fahrenheit(cls, fahrenheit):
        return cls((fahrenheit - 32) * 5 / 9)


room = Temperature(22)
print(room.celsius, room.fahrenheit)
street = Temperature.from_fahrenheit(14)
print(street.celsius, Temperature.is_freezing(street.celsius))
try:
    room.celsius = -300
except ValueError as error:
    print("Ошибка:", error)
print(registry)


from functools import cache, lru_cache


@lru_cache(maxsize=None)
def fibonacci(number):
    if number < 2:
        return number
    return fibonacci(number - 1) + fibonacci(number - 2)


print(fibonacci(80))
print(fibonacci.cache_info())


@cache
def count_words(sentence):
    return len(sentence.split())


print(count_words("кэш хранит результат вызова"))

В консоли:

22 71.6
-10.0 True
Ошибка: -300 ниже абсолютного нуля
{'Temperature': <class '__main__.Temperature'>}
23416728348467685
CacheInfo(hits=78, misses=81, maxsize=None, currsize=81)
4

Метод celsius определён дважды с одним именем: сначала геттер, затем сеттер. @property делает его атрибутом: его читают без скобок, room.celsius. Пара @celsius.setter перехватывает присваивание, поэтому значение −300 не прошло проверку и вызвало ValueError. __init__ тоже пишет через сеттер, поэтому и через конструктор объект ниже абсолютного нуля не создать: Temperature(-300) вызывает тот же ValueError. fahrenheit задан только через @property без сеттера: его можно прочитать, но не присвоить.

@staticmethod убирает неявный первый аргумент. is_freezing не получает ни self, ни cls, это обычная функция, которая лежит в пространстве имён класса.

@classmethod передаёт первым аргументом класс, поэтому он удобен для создания альтернативных конструкторов. Таков from_fahrenheit: он пересчитывает 14 °F в −10 °C и вызывает cls(...). У наследника Temperature этот метод создаст объект наследника. Цепочку @classmethod поверх @property разрешили в Python 3.9, объявили устаревшей в 3.11 и убрали в 3.13.

@lru_cache(maxsize=None) запоминает результаты по аргументам. Рекурсивный fibonacci(80) без кэша считал бы одни и те же числа заново на каждой ветке, а с ним cache_info() возвращает статистику в виде именованного кортежа: 81 промах (каждое число от 0 до 80 посчитано один раз) и 78 попаданий. По умолчанию maxsize=128: кэш хранит 128 последних вызовов и вытесняет давние. @cache из Python 3.9 работает как lru_cache(maxsize=None), без ограничения размера. Сбросить сохранённые результаты можно вызовом cache_clear().

Использовать кэш стоит только для функций, которые для одних аргументов всегда возвращают одно и то же и ничего не меняют снаружи. Функция с print, записью в файл или запросом к серверу под кэшем выполнит эти действия только при первом вызове с данными аргументами.

Порядок декораторов здесь тоже важен. Если поставить @timer выше @lru_cache, таймер будет мерить и попадания в кэш; если ниже, замер будет только у настоящих вычислений.

Декоратор класса работает так же, как декоратор функции: Temperature = register(Temperature). Из стандартной библиотеки так устроен @dataclass (модуль dataclasses, с Python 3.7): он дописывает в класс сгенерированные __init__(), __repr__() и другие специальные методы.

Типичные ошибки с декораторами

Монитор с консолью, где видна красная строка ошибки в размытом выводе

Последний блок собирает четыре ошибки, которые легко сделать в собственных декораторах. Он пользуется timer, tag и count_words из предыдущих блоков.

def broken(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    # забыли return wrapper


try:
    @broken
    def ping():
        return "pong"

    ping()
except TypeError as error:
    print("1)", error)

try:
    @timer()
    def shout(message):
        return message.upper()
except TypeError as error:
    print("2)", error)


@tag
def whisper(message):
    return message.lower()


print("3)", whisper.__name__)

try:
    count_words(["список", "не", "хешируется"])
except TypeError as error:
    print("4)", error)

В консоли:

1) 'NoneType' object is not callable
2) timer() missing 1 required positional argument: 'func'
3) decorator
4) unhashable type: 'list'
№ Что сделали Что видно Как исправить
1 Забыли return wrapper TypeError: 'NoneType' object is not callable при вызове функции Декоратор обязан вернуть wrapper: последней строкой return wrapper
2 Поставили скобки декоратору без параметров: @timer() TypeError: timer() missing 1 required positional argument: 'func' прямо при определении Писать @timer без скобок
3 Забыли скобки у декоратора с параметрами: @tag Ошибки нет, но функция стала decorator, вызов вернёт ещё одну функцию вместо текста Писать @tag("b") со скобками и параметром
4 Передали список в функцию под @cache или @lru_cache TypeError: unhashable type: 'list' Передавать кортеж или строку: аргументы кэшируемой функции должны быть хешируемыми
Читайте также:  Задачи по SQL с решениями: от SELECT до оконных функций

Третья ошибка самая коварная: программа не падает там, где ошиблись. Функция whisper превратилась в decorator, и сломается уже код, который ждёт от неё текст. Проверка print(whisper.__name__) после декорирования ловит такую подмену сразу: с правильно применённым декоратором и wraps там было бы whisper.

Список не годится для кэша, потому что результаты хранятся в словаре, а ключ словаря должен быть хешируемым. Что такое хеш и как он считается, разобрано в статье про хеш-функции.

Где применяются декораторы на практике

Схема веб-запроса, проходящего через обёртки к функции-обработчику

Во фреймворках декораторы связывают функцию с внешней системой. В Flask @app.route("/") говорит, на какой URL отвечает функция-обработчик, а @app.errorhandler(404) назначает страницу ошибки. Сам фреймворк при этом вызывает вашу функцию, когда приходит запрос.

В своём коде декораторы хороши для сквозных задач, которые одинаковы для многих функций:

  • логирование вызовов и аргументов для отладки;
  • замер времени выполнения, как timer выше;
  • кэширование результатов;
  • проверка, вошёл ли пользователь, до вызова обработчика;
  • повтор запроса при сетевой ошибке.

Логику самой задачи в декоратор лучше не прятать. Если обёртка меняет аргументы или подменяет ответ функции, читатель кода видит одно, а получает другое. Допустим, tag из примера выше начал бы ещё и переводить имя в верхний регистр: тогда greet("Оля") вернул бы не то, что написано в теле greet, и причину пришлось бы искать в декораторе.

Частые вопросы о декораторах Python

Студентка за столом с ноутбуком и тетрадью разбирает вопросы по Python

Чем декоратор отличается от обычной функции?

Обычно декоратор и есть функция: она принимает функцию и возвращает новую функцию. По устройству она ничем не отличается от любой другой, особенной её делает только применение через @ перед определением функции: имя подменяется результатом вызова. В общем случае декоратором может быть любой вызываемый объект, в том числе класс.

С какой версии Python есть декораторы?

Декораторы функций и методов появились в Python 2.4, декораторы классов в Python 3.0. В 3.9 сняли ограничения на выражение после @.

Можно ли применить несколько декораторов к одной функции?

Да. Они применяются снизу вверх: ближний к функции оборачивает её первым. Порядок легко проверить ручной записью вроде tag("b")(tag("i")(func)).

Зачем нужен functools.wraps?

Чтобы обёртка сохранила имя, docstring и другие метаданные исходной функции и получила атрибут __wrapped__ со ссылкой на неё. Без wraps все декорированные функции называются wrapper.

Может ли декоратором быть класс?

Да. После @ годится любой вызываемый объект, который примет функцию единственным аргументом. Это может быть класс или экземпляр класса с методом __call__. Обратная сторона тоже работает: декоратор можно применить к самому классу, как register и @dataclass в примерах выше.

Когда срабатывает код декоратора?

Тело декоратора выполняется, когда выполняется инструкция def; для функции на уровне модуля это один раз, когда модуль выполняется или импортируется. Тело wrapper выполняется при каждом вызове. Если декоратор что-то печатает или регистрирует, это случится ещё до первого вызова функции, как в первом примере статьи.

Оцените статью
bestprogrammer.ru
Добавить комментарий