Декораторы#

Отчасти по причине того, что в python функции — это объекты первого порядка, в нем распространены то, что в других языках называется функциями высшего порядка, т.е. функции, которые принимают в качестве параметра или возвращают другие функции. Для конкретного вида таких функций в python добавили специальный элемент языка — декораторы.

Декорация функции одного аргумента#

Предположим, что в целях отладки мы хотим печатать логирующее сообщение при каждом вызове функции sin. Мы можем прийти примерно к такому решению.

import math 

def sin(x):
    print(f"Функция sin вызвана с аргументом {x}")
    return math.sin(x)

print(sin(0))
Функция sin вызвана с аргументом 0
0.0

Такие конструкции называют обертками над функциями: пользовательская функция sin оборачивает функцию math.sin.

Теперь, предположим, что мы захотим проделать то же самое и с другими функциями. Написание своей обертки для каждой из них приведет к излишнему дублированию кода. Чтобы этого избежать, напишем функцию announce, которая, принимая на вход некоторую функцию одного аргумента func, возвращает функцию обертку wrap. Функция обертка wrap в свою очередь вызывает func, но перед этим печатает её имя (func.__name__) и значение аргумента, с которым она была вызвана.

Реализация этой функции может выглядеть как-то так.

def announce(func):
    def wrap(x):
        print(f"Функция {func.__name__} вызвана с аргументом {x}")
        return func(x)
    return wrap

Функция announce внутри тела объявляет новую функцию wrap и тем самым создаёт объект этой функции. Когда этот объект создан, он сразу возвращается наружу. Функция wrap в свою очередь является оберткой: она возвращает результат вычисления обертываемой функции func, но перед этим делает дополнительные действия, а именно печатает имя функции (атрибут func.__name__) и аргумент, с которым вызывается функция. С этим же аргументом вызывается функция func.

Проверим работоспособность это функции. Для этого обернем функции синуса и косинуса.

from math import sin, cos

print(sin(0), cos(0))

sin = announce(sin)
cos = announce(cos)

print(sin(0), cos(0))
0.0 1.0
Функция sin вызвана с аргументом 0
Функция cos вызвана с аргументом 0
0.0 1.0

Видно, что обертывание функций sin и cos приводит к желаемому эффекту.

Такого вида функции и называют декораторами. При этом, т.к. такие функции часто применяются и к пользовательским функциям, то в python ввели специальный синтаксический сахар для их применения.

Вместо комбинации инструкций

def my_function(x):
    pass

my_function = announce(my_function)

можно смело писать

@announce
def my_function(x):
    pass

Они эквиваленты между собой с точки зрения синтаксиса.

Но у второго подхода есть ряд преимуществ, самый главный из которых, наверное, следующий. При первом подходе читатель анализирует тело функции, ещё не зная, что она потом будет задекорирована, т.е. модифицирована. При втором подходе декоратор встречается ещё до ключевого слова def и явно указывает на модификацию функции.

@announce
def square(x):
    return x * x

print(square(2))
Функция square вызвана с аргументом 2
4

Проблема с затиранием имени функции и её решение#

Попробуем получить имя декорированной функции square из предыдущего примера.

square.__name__
'wrap'

Упс. Вместо ожидаемого square мы получили wrap. Это объясняется тем, что

@announce
def square(x):

сводится к

square = announce(square)

А функция announce возвращает обертку wrap. Таким образом, имя исходной функции затирается.

Можно попробовать решить эту проблему задавав необходимое имя функции wrap перед её возвращением из функции announce.

def announce(func):
    def wrap(x):
        print(f"Функция {func.__name__} вызвана с аргументом {x}")
        return func(x)
    wrap.__name__ = func.__name__
    return wrap

@announce
def square(x):
    return x * x

print(square.__name__)
print(square)
square
<function announce.<locals>.wrap at 0x0000018B309F9AB0>

Но гораздо лучше будет воспользоваться декоратором wraps из модуля functools, что позволит сохранить все метаданные исходной функции.

from functools import wraps

def announce(func):
    @wraps(func)
    def wrap(x):
        print(f"Функция {func.__name__} вызвана с аргументом {x}")
        return func(x)
    return wrap

@announce
def square(x):
    return x * x

print(square.__name__)
print(square)
square
<function square at 0x00000142CABAFD30>

Декорация функции с произвольной сигнатурой#

В прошлом примере мы наложили ограничение на декорируемую функцию в один аргумент. Это было сделано лишь для наглядности. Используя *args и **kwargs можно объявлять декораторы для функций с произвольной сигнатурой (см. Перехват произвольного количества позиционных параметров.).

Переопределим декоратор announce.

def announce(func):
    def wrap(*args, **kwargs):
        print(f"Функция {func.__name__} вызвана с позиционными аргументами {args}", end="")
        print(f" и именованными аргументами {kwargs}.")
        return func(*args, **kwargs)
    return wrap

@announce
def add(x, y):
    return x + y

print(add(42, 3.14))
print(add(7, y=2.71))
Функция add вызвана с позиционными аргументами (42, 3.14) и именованными аргументами {}.
45.14
Функция add вызвана с позиционными аргументами (7,) и именованными аргументами {'y': 2.71}.
9.71

Пример: измеряющий время декоратор#

Рассмотрим вариант реализации декоратора, который при вызове декорируемой функции измеряет время, потребовавшееся на её выполнение и выводит его в стандартный поток вывода, прежде чем вернуть результат её вычисления.

from functools import wraps
from time import perf_counter


def timed(func):
    @wraps(func)
    def wrap(*args, **kwargs):
        t1 = perf_counter()
        result = func(*args, **kwargs)
        t2 = perf_counter()
        print(f"Вызов {func.__name__} занял {t2- t1} секунд, ", end="")
        print(f"(параметры {args}, {kwargs})")
        return result
    return wrap


@timed
def countdown(n):
    while n > 0:
        n -= 1


for n in [10_000, 100_000, 1_000_000, 10_000_000]:
    countdown(n)
Вызов countdown занял 0.00041140004759654403 секунд, (параметры (10000,), {})
Вызов countdown занял 0.003993600024841726 секунд, (параметры (100000,), {})
Вызов countdown занял 0.03891709999879822 секунд, (параметры (1000000,), {})
Вызов countdown занял 0.40471659996546805 секунд, (параметры (10000000,), {})

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

На примере декоратора functools.wraps, декораторы, принимающие аргументы, объявляются несколько сложнее. Дело в том, что в этом случае необходимо погрузиться ещё на один уровень абстракции. Предположим, что вы хотите написать декоратор my_decorator с параметрами x, y и z, а применять его в следующем виде:

@my_decorator(x, y, z)
def some_function():
    ...

Такое объявление будет эквивалентно инструкции

some_function = my_decorator(x, y, z)(some_function)

Иными словами, вызов функции my_decorator(x, y, z) должен возвращать другую функцию (или вызываемый объект), которая уже и будет параметризованным декоратором. Т.е. выглядеть она должна приблизительно так:

def my_decorator(x, y, z):
    ...
    def decorate(func):
        ...
        @functools.wraps
        def wrap(*args, **kwargs):
            ...
            result = func(*args, **kwargs)
            ...
            return result
        ...
        return wrap
        ...
    return decorate 

Таким образом инструкция

some_function = my_decorator(x, y, z)(some_function)

сводится к инструкции

some_function = decorate(some_function)

где функция decorate — то, что вернул вызов функции my_decorator(x, y, z) — и является непосредственным декоратором. Функция decorate может получить доступ к параметрам x, y и z с которыми была вызвана функция my_decorator, т.к. они находятся в пространстве локальных имен внешней для неё функции my_decorator. Таким образом поведение функции decorate может зависеть от параметров x, y и z.

В качестве примера, рассмотрим два декоратора. Первый декорирует функцию таким образом, чтобы печаталось заданное сообщение перед вызовом функции, а второй — после вызова функции.

from functools import wraps

def print_before(msg=""):
    def decorate(func):
        @wraps(func)
        def wrap(*args, **kwargs):
            print(msg)
            return func(*args, **kwargs)
        return wrap
    return decorate

def print_after(msg):
    def decorate(func):
        @wraps(func)
        def wrap(*args, **kwargs):
            result = func(*args, **kwargs)
            print(msg)
            return result
        return wrap
    return decorate

Приблизительно так может выглядеть реализация таких декораторов.

Проверим работоспособность первого из них.

@print_before(msg="Hello!")
def f():
    print("Функция f.")

f()
Hello!
Функция f.

Проверим работоспособность второго из них.

@print_after(msg="Good bye!")
def g():
    print("Функция g.")

g()
Функция g.
Good bye!

Теперь убедимся, что мы можем передать декоратору и другое значение, а заодно продемонстрируем возможность цепного применения декораторов.

@print_before(msg="Hi!")
@print_after(msg="Bye!")
def h():
    print("Функция h.")

h()
Hi!
Функция h.
Bye!

Note

Объявление декораторов с необязательными аргументами и другие примеры можно найти в [1].

Декоратор functools.cache#

В модуле functools определен очень полезный декоратор cache, который позволяет легко реализовать мемоизацию. Мемоизация направлена на предотвращение повторных вычислений при повторных вызовах функции с теми же самыми аргументами. Достигается это за счет сохранения истории (кэша) вызова функции: во время вызова функция записывает набор параметров и возвращаемое значение, если такой набор параметров встретился впервые. Если функция встречает набор параметров, который содержится в истории вызовов, то вместо повторных вычислений функция смотрит на значение в истории и сразу его возвращает. Декоратор functools.cache хранит историю вызовов в словаре, а значит поиск в истории вызовов очень быстрый, но накладывает ограничение на хешируемость параметров функции.

В качестве примера определим рекурсивную версию функции вычисления чисел Фибоначчи. В целях отладки применим к нему объявленный выше декоратора announce.

from functools import wraps

@announce
def fib(n: int) -> int:
    return fib(n - 1) + fib(n - 2) if n > 1 else 1


fib(5)
Функция fib вызвана с позиционными аргументами (5,) и именованными аргументами {}.
Функция fib вызвана с позиционными аргументами (4,) и именованными аргументами {}.
Функция fib вызвана с позиционными аргументами (3,) и именованными аргументами {}.
Функция fib вызвана с позиционными аргументами (2,) и именованными аргументами {}.
Функция fib вызвана с позиционными аргументами (1,) и именованными аргументами {}.
Функция fib вызвана с позиционными аргументами (0,) и именованными аргументами {}.
Функция fib вызвана с позиционными аргументами (1,) и именованными аргументами {}.
Функция fib вызвана с позиционными аргументами (2,) и именованными аргументами {}.
Функция fib вызвана с позиционными аргументами (1,) и именованными аргументами {}.
Функция fib вызвана с позиционными аргументами (0,) и именованными аргументами {}.
Функция fib вызвана с позиционными аргументами (3,) и именованными аргументами {}.
Функция fib вызвана с позиционными аргументами (2,) и именованными аргументами {}.
Функция fib вызвана с позиционными аргументами (1,) и именованными аргументами {}.
Функция fib вызвана с позиционными аргументами (0,) и именованными аргументами {}.
Функция fib вызвана с позиционными аргументами (1,) и именованными аргументами {}.
8

Видим, что вызов функции fib при \(n=5\) привел ещё к 14 рекурсивным вызовам этой же самой функции, среди которых много повторов: только вызовов fib(1) было осуществлено 5 раз.

Добавим к этой функции теперь ещё и декоратора cache.

from functools import cache

@cache
@announce
def fib(n: int) -> int:
    return fib(n - 1) + fib(n - 2) if n > 1 else 1

fib(5)
Функция fib вызвана с позиционными аргументами (5,) и именованными аргументами {}.
Функция fib вызвана с позиционными аргументами (4,) и именованными аргументами {}.
Функция fib вызвана с позиционными аргументами (3,) и именованными аргументами {}.
Функция fib вызвана с позиционными аргументами (2,) и именованными аргументами {}.
Функция fib вызвана с позиционными аргументами (1,) и именованными аргументами {}.
Функция fib вызвана с позиционными аргументами (0,) и именованными аргументами {}.
8

Теперь количество вызовов сократилось до 6.

Посмотрим, как это скажется на производительности. Для этого заново определим эту функцию без всех декораторов и функцию замеряющую время.

from time import perf_counter

def fib(n: int) -> int:
    return fib(n - 1) + fib(n - 2) if n > 1 else 1


def measure_time(f, n):
    t1 = perf_counter()
    y = f(n)
    t2 = perf_counter()
    print(f"{t2 - t1:7f} секунд. {f.__name__}(n) = {y} при {n=}")

measure_time(fib, 33)
measure_time(fib, 34)
measure_time(fib, 35)
1.276110 секунд. fib(n) = 5702887 при n=33
1.873235 секунд. fib(n) = 9227465 при n=34
3.035318 секунд. fib(n) = 14930352 при n=35

Видим, что вызов при \(n=35\) занимает порядка 3 секунд. Более того, вызов \(\mathrm{fib}(n+1)\) занимает чуть ли не в два раза больше времени, чем вызов \(\mathrm{fib}(n)\).

Применим декоратор cache к функции fib и измерим время её вызовов.

fib = cache(fib)

measure_time(fib, 35)
measure_time(fib, 1000)
measure_time(fib, 999)
0.000026 секунд при n=35
0.000930 секунд при n=1000
0.000000 секунд при n=999

Видим, что вызов даже при \(n=1000\) занимает меньше 1 миллисекунды. Кроме того можно заметить, что вызов \(\mathrm{fib}(999)\) был осуществлен почти мгновенно, т.к. было достаточно подсмотреть результат вычислений, произведенных при вызове \(\mathrm{fib}(1000)\).