Введение в декораторы функций

Курс по Python: https://stepik.org/course/100707

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

Давайте, я сразу приведу пример, из которого и сформулирую понятие декоратора? Используя вложенную функцию wrapper и механизм замыканий, будем вызывать переданную функцию func внутри вложенной функции wrapper:

def func_decorator(func):
    def wrapper():
        print("------ что-то делаем перед вызовом функции ------")
        func()
        print("------ что-то делаем после вызова функции ------")
 
    return wrapper

Далее, для примера, объявим некоторую функцию:

def some_func():
    print("Вызов функции some_func")

И передадим ссылку на нее функции func_decorator:

f = func_decorator(some_func)

В результате, переменная f будет ссылаться на вложенную функцию wrapper и при ее вызове:

f()

соответственно запустится wrapper(), а она, в свою очередь, запустит переданную функцию func(), то есть, some_func().

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

Часто, чтобы не создавать новые имена функций, вместо имени f используют то же самое имя функции:

some_func = func_decorator(some_func)
some_func()

И у нас получается, словно, мы модифицировали существующую функцию. В этом и заключается смысл декоратора – наполнить уже существующую функцию дополнительным функционалом.

Однако, если сейчас в нашу функцию some_func() добавить хотя бы один параметр:

def some_func(title):
    print(f"title = {title}")

А, затем, вызвать ее с одним аргументом:

some_func = func_decorator(some_func)
some_func("Python навсегда!")

то увидим ошибку, так как внутренняя функция wrapper() прописана без параметров. А именно на нее и ссылается сейчас some_func. В самом простом варианте, мы, конечно, может просто добавить этот параметр для wrapper():

def func_decorator(func):
    def wrapper(title):
        print("------ что-то делаем перед вызовом функции ------")
        func(title)
        print("------ что-то делаем после вызова функции ------")
 
    return wrapper

И теперь программа будет запускаться без ошибок. Но, очевидно, она перестанет работать, если число параметров функции func() изменится. Как описать универсальную реализацию декоратора для функций с разным числом фактических и формальных параметров? Конечно, для этого нужно определить вложенную функцию, принимающую произвольное число аргументов, следующим образом:

def func_decorator(func):
    def wrapper(*args, **kwargs):
        print("------ что-то делаем перед вызовом функции ------")
        func(*args, **kwargs)
        print("------ что-то делаем после вызова функции ------")
 
    return wrapper

А, затем, переданные значения распаковываются и передаются функции func(). Получаем универсальную реализацию декоратора. И, действительно, если теперь добавить еще один параметр в функцию some_func:

def some_func(title, tag):
    print(f"title = {title}, tag = {tag}")

То нам достаточно будет при вызове декоратора передать эти два аргумента:

some_func("Python навсегда!", 'h1')

Сам декоратор остается без изменений.

И, наконец, последний штрих. Сама по себе функция some_func() может возвращать какие-либо значения, например, помимо вывода сообщения в консоль, она еще будет возвращать сформированную строку:

def some_func(title, tag):
    print(f"title = {title}, tag = {tag}")
    return f"<{tag}>{title}</{tag}>"

Но, при попытке получить сейчас это значение:

some_func = func_decorator(some_func)
res = some_func("Python навсегда!", 'h1')
print(res)

Увидим None. И я, думаю, вы прекрасно понимаете почему? Да, это потому, что вложенная функция wrapper() ничего не возвращает. Исправим это и сделаем так, чтобы она возвращала значение функции func():

def func_decorator(func):
    def wrapper(*args, **kwargs):
        print("------ что-то делаем перед вызовом функции ------")
        res = func(*args, **kwargs)
        print("------ что-то делаем после вызова функции ------")
        return res
 
    return wrapper

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

Возможно, на протяжении всего этого урока вы задаете себе вопрос – зачем это надо? Где применяется? Приведу такой пример. Предположим, мы хотим протестировать различные функции на скорость их работы. Для этого возьмем функцию, которая реализует медленный алгоритм Евклида для поиска НОД (наибольшего общего делителя) двух натуральных чисел a и b (мы ее с вами делали на одном из прошлых занятий):

def get_nod(a, b):
    while a != b:
        if a > b:
            a -= b
        else:
            b -= a
    return a

И перед ней опишем декоратор-тестировщик:

def test_time(fn):
    def wrapper(*args, **kwargs):
        st = time.time()
        res = fn(*args, **kwargs)
        dt = time.time() - st
        print(f"Время работы: {dt} сек")
        return res
 
    return wrapper

Мы здесь замеряем время работы функции и выводим его в консоль. Чтобы воспользоваться функцией time, импортируем этот модуль:

import time

и декорируем функцию get_nod():

get_nod = test_time(get_nod)
res = get_nod(2, 1000000)
print(res)

После запуска программы видим время ее выполнения и возвращенный результат.

Мало того, если у нас будет еще какая-либо функция, например, тот же алгоритм Евклида, но с быстрой реализацией:

def get_fast_nod(a, b):
    if a < b:
        a, b = b, a
    while b:
        a, b = b, a % b
 
    return a

То мы легко можем применить тот же самый декоратор для тестирования скорости работы этой второй функции:

get_nod = test_time(get_nod)
get_fast_nod = test_time(get_fast_nod)
res = get_nod(2, 1000000)
res2 = get_fast_nod(2, 1000000)
print(res, res2)

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

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

get_nod = test_time(get_nod)
get_fast_nod = test_time(get_fast_nod)

достаточно перед объявлением функций прописать @test_time. В результате, эти функции, как бы, «обертываются» нашим декоратором test_time и расширяются его функционалом. Если мы теперь запустим программу, то увидим тот же самый результат. А вот если у какой-либо функции уберем эту строчку, то будет обычный вызов без оценки скорости работы. Фактически, запись:

@test_time
def get_nod(a, b):
    ...

эквивалентна строчке:

get_nod = test_time(get_nod)

но со значком «собачка» программа выглядит, на мой взгляд, понятнее, да и записывать это декорирование проще. На практике, как правило, используют именно этот второй вариант.

На этом мы завершим наше первое знакомство с декораторами функций. Надеюсь, вы поняли, как они работают и для чего применяются? Отнеситесь серьезно к этому материалу, так как декораторы в Python используются довольно часто и важно правильно понимать принцип их работы.

Курс по Python: https://stepik.org/course/100707

Видео по теме