Декораторы функций и замыкания

Декораторы функций и замыкания

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

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

Давайте рассмотрим на примере, как определяются и используются декораторы. На одном из прошлых занятий мы с вами программировали алгоритм Евклида для поиска НОД двух натуральных чисел a и b. И в случае вычитаний, он выглядел так:

def getNOD(a, b):

    while a != b:

        if a > b: a-= b

        else: b -= a

    return a

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

def testTime(fn):

    def wrapper(*args):

        st = time.time()

        fn(*args)

        dt = time.time() - st

        print(f"Время работы: {dt} сек")

    return wrapper

и вначале файла подключим:

import time

Смотрите, здесь внутри функции testTime (нашего декоратора) объявлена еще одна функция wrapper (обертка), внутри которой уже и происходит вызов некой функции fn. Далее замеряется время ее работы и информация выводится в консоль. И в конце сам декоратор возвращает ссылку на функцию wrapper.

Почему все реализовано именно так? Здесь вот эта вложенная функция как раз и расширяет функционал для fn, не меняя ее саму. А благодаря вот этому оператору return мы имеем возможность вызывать эту обертку (wrapper) так:

test1 = testTime(getNOD)

test1(100000, 2)

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

Видите, как это красиво выглядит! Конечно, мы могли бы записать декоратор и без wrapper:

def testTime(fn, *args):

        st = time.time()

        fn(*args)

        dt = time.time() - st

        print(f"Время работы: {dt} сек")

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

testTime(getNOD, 100000, 2)

Первый вариант выглядит гораздо естественнее. Там мы имеем возможность создавать новое имя для функции и, затем, вызывать ее как новый, независимый объект.

Вернемся к первому варианту декоратора. И сразу отметим еще одну его особенность: обертка wrapper использует аргумент fn внешней функции testTime. Когда мы делаем вызов:

test1 = testTime(getNOD)

то у нас здесь создаются два объекта-функции: testTime и wrapper. На wrapper ссылается глобальная переменная test1, а сам wrapper содержит ссылку на внешний контекст, т.е. на содержимое функции testTime, откуда и берет переменную fn. Благодаря наличию этой ссылки объект testTime не удаляется сборщиком мусора и продолжает существовать, пока существует wrapper. Это в программировании называется замыканием, т.е. когда вложенная функция ссылается на контекст внешней функции и потому имеет возможность обращаться ко всем локальным переменным этого внешнего контекста.

Теперь, когда мы в деталях разобрались с работой декоратора, вызовем его для быстрого алгоритма Евклида. Он реализуется вот таким образом:

def getFastNOD(a, b):

    if a < b: a,b = b,a

    while b: a,b = b, a%b

    return a

И далее, запишем:

test1 = testTime(getNOD)

test2 = testTime(getFastNOD)

 

test1(100000, 2)

test2(100000, 2)

Все выглядит довольно элегантно и красиво. Кстати, функцию wrapper можно записать в еще более универсальном виде, добавив необязательный список возможных именованных параметров:

def testTime(fn):

    def wrapper(*args, **kwargs):

        st = time.time()

        fn(*args, **kwargs)

        dt = time.time() - st

        print(f"Время работы: {dt} сек")

    return wrapper

Так мы сможем вызывать и тестировать на скорость работы любые функции.

В Python есть один интересный синтаксис использования декораторов. Запишем нашу функцию testTime в самом верху программы:

import time

 

def testTime(fn):

    def wrapper(*args, **kwargs):

        st = time.time()

        fn(*args, **kwargs)

        dt = time.time() - st

        print(f"Время работы: {dt} сек")

    return wrapper

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

@testTime

getNOD(100000,2)

И теперь при ее вызове будет запускаться указанный декоратор. А вот вызов второй функции:

getFastNOD(100000, 2)

никак не связан с декоратором – это просто вычисление НОД для двух чисел. Если же мы и у нее укажем вызов декоратора:

@testTime

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

Конечно, если функция fn возвращает какое-либо значение, то это легко предусмотреть в обертке:

def testTime(fn):

    def wrapper(*args, **kwargs):

        st = time.time()

        res = fn(*args, **kwargs)

        dt = time.time() - st

        print(f"Время работы: {dt} сек")

        return res

    return wrapper

И, теперь, мы можем получить еще и результат работы функции getNOD:

res = getNOD(100000,2)

print( res )

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

Задания для самоподготовки

1. Напишите две функции создания списка из четных чисел от 0 до N (N – аргумент функции):

[0, 2, 4, …, N]

с помощью метода append и с помощью инструмента list comprehensions (генератор списков). Через декоратор определите время работы этих функций.

2. Напишите декоратор для кэширования результатов работы функции вычисления квадратного корня положительного целочисленного значения x. То есть, при повторном вызове функции (через декоратор) с одним и тем же аргументом, результат должен браться из кэша, а не вычисляться заново. (Подсказка: здесь следует использовать замыкание для хранения кэша).

icon