Использование паттерна декоратор позволяет изменить поведение и расширить функциональность уже рабочего кода не переписывая его заново. В сети можно найти достаточно много материалов описывающих синтаксис декораторов в Python, однако статей поясняющих, что же находится “под капотом”, где рассматриваются интересные примеры их применения на практике, не так уж и много. Перевод статьи Untangling Python Decorators.

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

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

Объекты первого класса

Как вам, наверное, известно в Python практически всё является объектом, а функции рассматриваются как объекты первого класса. Это означает, что функции могут передаваться и использоваться в качестве аргументов, как и любой другой объект (например, строка, int, float, список и т.д.). Также функции можно присваивать переменным, то есть рассматривать их как любые другие объекты. Рассмотрим следующий пример:

def func_a():
    return "I was angry with my friend."

def func_b():
    return "I told my wrath, my wrath did end"

def func_c(*funcs):
    for func in funcs:
        print(func())

main_func = func_c
main_func(func_a, func_b)
>>> I was angry with my friend.
>>> I told my wrath, my wrath did end

Этот пример кода иллюстрирует тот факт, что функции в Python рассматриваются как объекты первого класса. В начале я определяю две функции, func_a и func_b, а затем функцию func_c, которая принимает их в качестве своих параметров. func_c запускает, принятые в качестве параметров функции на выполнение, и выводит в консоли результаты их работы. Затем мы присваиваем функцию func_c переменной main_func. В итоге мы запускаем функцию main_func() и она ведет себя точно так же, как и func_c.

Функции высшего порядка

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

def higher(func):
    """Это функция высшего порядка.
    Она возвращает другую функцию.
    """
    return func

def lower():
    return "I'm hunting high and low"

higher(lower)
>>> <function __main__.lower()>

Теперь вы можете присвоить результат выполнения функции higher другой переменной и выполнить возвращаемую ею внутреннюю функцию.

h = higher(lower)
h()
>>> "I'm hunting high and low"

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

def outer():
    """Определим и вернем вложенную функцию из другой функции."""

    def inner():
        return "Привет из внутренней  func"

    return inner

inn = outer()
inn()
>>> 'Hello from the inner func'

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

Замыкания

Примеры внутренних функций, определяемых в других функциях, мы разобрали в предыдущем разделе. Такие вложенные функции могут получить доступ к переменным из области видимости внешней (оборачивающей ее) функции. В Python по умолчанию предусмотрена возможность использования в функциях таких нелокальных переменных, и если мы хотим изменять их значения в коде внутренней, то должны объявить их нелокальными non-local явно (с ключевым словом nonlocal). Ниже приведён пример вложенной функции, получающей нелокальную переменную из внешней (по умолчанию без использования ключевого слова nonlocal).

def burger(name):
    
    def ingredients():
        if name == "deli":
            return ("steak", "pastrami", "emmental")
        elif name == "smashed":
            return ("chicken", "nacho cheese", "jalapeno")
        else:
            return None

    return ingredients

Теперь запустим следующий код на выполнение:

ingr = burger("deli")
dish = ingr()
print(dish)
>>> ('steak', 'pastrami', 'emmental')

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

Вначале функция burger была вызвана со строковым параметром deli, а затем возвращённая ею функция была присвоена переменной ingr. При вызове инструкции ingr(), переданное ранее значение name сохранилось и в последствии использовалось для получения результата, хотя внешняя функция burger уже закончила свое выполнение.

Этот способ, с помощью которого,как в примере выше, строковые данные deli присоединяются к исполняемому коду, называется замыканием.

Замыкание – это особый вид функции. Она определена в теле другой функции и создаётся каждый раз во время её выполнения. Синтаксически это выглядит как функция, находящаяся целиком в теле другой функции. При этом вложенная внутренняя функция содержит ссылки на локальные переменные внешней функции. Каждый раз при выполнении внешней функции происходит создание нового экземпляра внутренней функции, с новыми ссылками на переменные внешней функции.

Значение переменной из области видимости внешней функции запоминается даже в том случае, если сама переменная выходит из текущей области видимости, а также если сама (внешняя) функция удаляется из текущего контекста исполнения (пространства имен). Декораторы в своей работе используют тот же принцип применения нелокальных non-local переменных и скоро вы убедитесь в этом сами.

Пишем декоратор сами

И так вооруживший, полученными выше, знаниями о принципе действия нелокальных non-local переменных, создадим наш первый самый простейший декоратор.

def deco(func):
    def wrapper():
        print("Это сообщение будет напечатано до вызова функции.")
        func()
        print("Это сообщение будет напечатано после вызова функции.")

    return wrapper

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

def ans():
    print(42)

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

ans = deco(ans)
ans()

Ниже приведен результат выполнения этого кода.

>>> Это сообщение будет напечатано до вызова функции.
    42
    Это сообщение будет напечатано после вызова функции.

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

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

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

def deco(func):
    """Этот модифицированный декоратор также возвращает результат функции func."""

    def wrapper():
        print("Это сообщение будет напечатано до вызова функции.")
        val = func()
        print("Это сообщение будет напечатано после вызова функции.")
        return val

    return wrapper


def ans():
    return 42

В примере выше функция-обертка возвращает результат целевой функции и выполнения кода обертки. Этот прием позволяет получить модифицированной результат выполнения целевой функции.

ans = deco(ans)
print(ans())
>>> Это сообщение будет напечатано до вызова функции.
    Это сообщение будет напечатано после вызова функции.
    42

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

Используем символ @ (синтаксический сахар )

То, как вы использовали декоратор в последнем разделе, может показаться немного неуклюжим. Во-первых, нам необходимо использовать имя декорируемой (целевой) функции ans три раза для того, чтобы вызвать и использовать наш декоратор. Кроме того, становится труднее понять, где в коде наш декоратор на самом деле вызывается, и что его функциональное назначение декорирование целевых функций – код становится трудно читаемым и непонятным. Поэтому в Python предусмотрена возможность использования декораторов в вашем коде с применением специального синтаксиса с символом @. И вы можете использовать декораторы при определении своих функций, как это показано в примере ниже:

@deco
def func():
    # ...
    # код нашей декорируемой функции


# теперь можно вызвать нашу декорируемую функцию как обычную
func()

Часто этот синтаксис называют pie syntax (по аналогии со слоями пирога). Его использование повышает читаемость кода, однако его использование по сути является ни чем иным, как синтаксическим сахаром, использующимся вместо, рассмотренной нами выше, инструкции func = deco (func).

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

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

def yell(func):
    def wrapper(*args, **kwargs):
        val = func(*args, **kwargs)
        val = val.upper() + "!"
        return val

    return wrapper

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

@yell
def hello(name):
    return f"Hello {name}"
hello("redowan")
>>> 'HELLO REDOWAN!'

Функция hello принимает строку в качестве параметра name и возвращает сообщение в виде трансформированной строки. И так наш декоратор yell изменяет возвращаемую целевой функций строку, преобразует ее в верхний регистр и добавляет символ ! без непосредственного изменения кода в функции hello.

Решение проблемы идентификации функций

В Python вы можете исследовать любой объект и его свойства с помощью интерактивной оболочки. Функция предоставляет информацию о себе, используя, например, такой способ самодокументирования кода как docstring и т. д. Исследуем информацию о встроенной функции print следующими способами:

print
>>> <function print>
print.__name__
>>> 'print'
print.__doc__
>>> "print(value, ..., sep=' ', end='\\n', file=sys.stdout, flush=False)\n\nPrints the values to a stream, or to sys.stdout by default.\nOptional keyword arguments:\nfile:  a file-like object (stream); defaults to the current sys.stdout.\nsep:   string inserted between values, default a space.\nend:   string appended after the last value, default a newline.\nflush: whether to forcibly flush the stream."
help(print)
>>> Help on built-in function print in module builtins:

    print(...)
    print(value, ..., sep=' ', end='\n', file=sys.stdout, flush=False)

    Prints the values to a stream, or to sys.stdout by default.
    Optional keyword arguments:
    file:  a file-like object (stream); defaults to the current sys.stdout.
    sep:   string inserted between values, default a space.
    end:   string appended after the last value, default a newline.
    flush: whether to forcibly flush the stream.

Эти способы получения информации работают аналогично для пользовательских функций, которые вы определили бы сами. Давайте применим их для получения информации о ранее определенной нами функции hello().

hello.__name__
>>> 'wrapper'
help(hello)
>>> Help on function wrapper in module __main__:
    wrapper(*args, **kwargs)

Теперь проанализируем то, что же все таки произошло. Декоратор yell, который мы написали для нашей функции hello, приводит нас в заблуждение в отношении ее идентификации. Вместо того, чтобы сообщать нам свое имя, она выдает в консоли идентификатор функции wrapper. Этот факт может сбивать с толку при выполнении отладки вашего кода. Это можно исправить с помощью декоратора импортируемого из модуля functools.wraps. Следующий прием позволит сохранить первоначальную идентичность декорируемой функции.

import functools

def yell(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        val = func(*args, **kwargs)
        val = val.upper() + "!"
        return val

    return wrapper


@yell
def hello(name):
    "Hello from the other side."
    return f"Hello {name}"
hello("Galaxy")
>>> 'HELLO GALAXY!'

Анализ кода функции hello, к которой был применен этот декоратор, дает нам желаемый результат.

hello.__name__
>>> 'hello'
help(hello)
>>> Help on function hello in module __main__:

    hello(name)
        Hello from the other side.

Декораторы в дикой природе (практика)

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

import functools

def decorator(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        # Делаем что-то до
        val = func(*args, **kwargs)
        # Делаем что-то после
        return val

    return wrapper

Таймер

Декоратор таймер поможет измерить продолжительность во времени вызовов ваших функций простым и явным способом. Следующий пример кода применим для отладке и профайлинге Profiling пользовательских функций.

import time
import functools


def timer(func):
    """Этот декоратор выведет в консоли время выполнения вызываемого кода."""

    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start_time = time.time()
        val = func(*args, **kwargs)
        end_time = time.time()
        run_time = end_time - start_time
        print(f"Выполнение функции {func.__name__} завершено через {run_time:.4f} секунд.")
        return val

    return wrapper


@timer
def dothings(n_times):
    for _ in range(n_times):
        return sum((i ** 3 for i in range(100_000)))

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

dothings(100_000)
>>> Выполнение функции dothings завершено через 0.0231 секунд.
    24999500002500000000

Логирование исключений

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

import functools
from datetime import datetime


def logexc(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):

        # Преобразуем в строку имена аргументов и их значения
        args_rep = [repr(arg) for arg in args]
        kwargs_rep = [f"{k}={v!r}" for k, v in kwargs.items()]
        sig = ", ".join(args_rep + kwargs_rep)

        # Определяем блок Try для кода, который будем логировать
        try:
            return func(*args, **kwargs)
        except Exception as e:
            print("Time: ", datetime.now().strftime("%Y-%m-%d [%H:%M:%S]"))
            print("Arguments: ", sig)
            print("Error:\n")
            raise

    return wrapper


@logexc
def divint(a, b):
    return a / b

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

divint(1, 0)
>>> Time:  2020-05-12 [12:03:31]
    Arguments:  1, 0
    Error:

        ------------------------------------------------------------

        ZeroDivisionError         Traceback (most recent call last)
        ....

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

Валидация и проверки во время выполнения кода

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

Представьте себе, что у вас есть набор функций, каждая из которых возвращает словарь с данными, который (среди прочих полей) включает в себя поле summary (резюме, сводка). Значение этого поля не должно иметь длину свыше 30 символов, и если это не выполняется то, это будем считать ошибкой. Следующий пример кода реализует декоратор, который вызывает исключение типа ValueError, если проверка длинны поле summary не проходит:

import functools

def validate_summary(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        data = func(*args, **kwargs)
        if len(data["summary"]) > 30:
            raise ValueError("Ваше резюме превышает 30 символов.")
        return data

    return wrapper

@validate_summary
def short_summary():
    return {"summary": "Этот резюме короткое"}

@validate_summary
def long_summary():
    return {"summary": "Это резюме слишком длинное оно превышает лимит символов."}

print(short_summary())
print(long_summary())
>>> {'summary': 'Этот резюме короткое'}

    -------------------------------------------------------------------

    ValueError                       Traceback (most recent call last)

    <ipython-input-178-7375d8e2a623> in <module>
            19
            20 print(short_summary())
    ---> 21 print(long_summary())
    ...

Повторитель выполнения функций

Теперь представим ситуацию, когда вызов пользовательской функции, терпит неудачу из-за проблем, связанных, например, с процессом ввода/вывода данных, и вы хотели бы повторить ее выполнение несколько раз. Представляемый вашему вниманию декоратор повторитель позволит многократно выполнять ваш код заданное число раз или до получения успешного результата. И так давайте определим декоратор retry, который будет повторно запускать нашу декорируемую функцию 3 раза, пока будет возникать ошибка соединения с удаленным сервисом по протоколу http.

import functools
import requests


def retry(func):
    """Этот код перезапустит декорируемую функцию 3 раза пока будет получаться ошибка http 500/404."""
    
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        n_tries = 3
        tries = 0
        while True:
            resp = func(*args, **kwargs)
            if resp.status_code == 500 or resp.status_code == 404 and tries < n_tries:
                print(f"перезапускаем... ({tries})")
                tries += 1
                continue
            break
        return resp

    return wrapper


@retry
def getdata(url):
    resp = requests.get(url)
    return resp


resp = getdata("https://httpbin.org/get/1")
resp.text
>>> retrying... (0)
    retrying... (1)
    retrying... (2)

    '<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">\n<title>404 Not Found</title>\n<h1>Not Found</h1>\n<p>The requested URL was not found on the server.  If you entered the URL manually please check your spelling and try again.</p>\n'

Применяем сразу несколько декораторов

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

import functools

def greet(func):
    """Приветствуем на английском."""

    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        val = func(*args, **kwargs)
        return "Hello " + val + "!"

    return wrapper

def flare(func):
    """Кое-что добавим в нашу строку."""

    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        val = func(*args, **kwargs)
        return "🎉 " + val + " 🎉"

    return wrapper

@flare
@greet
def getname(name):
    return name

getname("Nafi")
>>> '🎉 Hello Nafi! 🎉'

Декораторы выполняются в порядке снизу вверх. Сначала будет выполнен декоратор greet, который применяется к результату, возвращенному функцией getname, а затем результат выполнения кода декоратора greet передается коду flare. Стек применения декораторов из примера выше может быть в функциональном стиле переписан в следующем виде: flare(greet (getname (name))). Самостоятельно измените порядок декораторов и посмотрите, что получится!

Используем декораторы с аргументами

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

Прежде мы сделаем это рассмотрим простой пример того, как можно определять декораторы с параметрами.

import functools

def joinby(delimiter=" "):
    """Этот декоратор разбивает строку, возвращаемую декорируемой функцией, по пробельному символу, а затем соединяет ее части с разделителем, переданным пользователем в качестве параметра."""

    def outer_wrapper(func):
        @functools.wraps(func)
        def inner_wrapper(*args, **kwargs):
            val = func(*args, **kwargs)
            val = val.split(" ")
            val = delimiter.join(val)
            return val

        return inner_wrapper

    return outer_wrapper


@joinby(delimiter=",")
def hello(name):
    return f"Hello {name}!"

@joinby(delimiter=">")
def greet(name):
    return f"Greetings {name}!"

@joinby()
def goodbye(name):
    return f"Goodbye {name}!"

print(hello("Nafi"))
print(greet("Redowan"))
print(goodbye("Delowar"))
>>> Hello,Nafi!
    Greetings>Redowan!
    Goodbye Delowar!

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

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

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

В коде реализации функции joinby присутствует несколько важных, но неочевидных деталей:

  • определение external_wrapper в качестве внутренней функции означает, что repeat будет ссылаться на объект функции external_wrapper.
  • аргумент delimiter, как очевидно, не используется в joinby. Но, при передаче разделителя delimiter, создается замыкание, в котором его значение сохраняется до тех пор, пока оно не будет использовано в функции inner_wrapper.

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

Ранее мы говорили, что наш декоратор, получающий параметры не может без них использоваться. Но что делать, если вы хотите создать такой декоратор, который можно использовать как с аргументами, так и без них. Для решения этой задачи перепишем декоратор joinby, чтобы при необходимости мы могли передавать в него параметры или использовать без них как обычный декоратор.

import functools

def joinby(_func=None, *, delimiter=" "):
    """Этот декоратор разбивает строку, возвращаемую декорируемой функцией, по пробельному символу, а затем соединяет ее части с разделителем, переданным пользователем в качестве параметра."""

    def outer_wrapper(func):
        @functools.wraps(func)
        def inner_wrapper(*args, **kwargs):
            val = func(*args, **kwargs)
            val = val.split(" ")
            val = delimiter.join(val)
            return val

        return inner_wrapper

    # Эта часть позволяет использовать декоратор с параметрами или без
    if _func is None:
        return outer_wrapper
    else:
        return outer_wrapper(_func)

@joinby(delimiter=",")
def hello(name):
    return f"Hello {name}!"

@joinby
def greet(name):
    return f"Greetings {name}!"

print(hello("Nafi"))
print(greet("Redowan"))
>>> Hello,Nafi!
    Greetings Redowan!

В этом примере параметр _func играет роль маркера, определяя, был ли декоратор вызван с параметрами или без.

Если joinby вызывается без аргументов, то функция, к которой мы применяем декоратор, будет передаваться как _func. Если же он был вызван с аргументами, то параметр _func принимает значение None. Символ * в списке аргументов означает, что остальные аргументы могут быть вызваны как не позиционные. На этот раз вы можете использовать joinby с аргументами или без них, и вывод результатов вызовов функций hello и greet проиллюстрирует это.

Пишем универсальный шаблон для декоратора

Лично я нахожу излишним и обременительным то, что нам нужно использовать три слоя вложенных функций для определения более совершенного декоратора, который мы можем использовать с аргументами или без. Дэвид Бизли в своей книге Python Cookbook предлагает отличный способ для определения универсальных декораторов без использования трех уровней вложенных функций. Для этого используется функционал модуля functools.partial. Ниже приведен пример кода, который можно использовать для определения универсальных декораторов более элегантным способом:

import functools

def decorator(func=None, foo="spam"):
    if func is None:
        return functools.partial(decorator, foo=foo)

    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        # Тут вы можете что-то сделать с `func` или `foo`
        pass

    return wrapper


# Применяем декоратор без параметров
@decorator
def f(*args, **kwargs):
    pass


# Применяем декоратор с параметрами
@decorator(foo="buzz")
def f(*args, **kwargs):
    pass

Давайте перепишем наш декоратор retry, используя этот шаблон кода.

import functools

def retry(func=None, n_tries=4):
    if func is None:
        return functools.partial(retry, n_tries=n_tries)

    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        tries = 0
        while True:
            resp = func(*args, **kwargs)
            if resp.status_code == 500 or resp.status_code == 404 and tries < n_tries:
                print(f"retrying... ({tries})")
                tries += 1
                continue
            break
        return resp

    return wrapper


@retry
def getdata(url):
    resp = requests.get(url)
    return resp


@retry(n_tries=2)
def getdata_(url):
    resp = requests.get(url)
    return resp


resp1 = getdata("https://httpbin.org/get/1")
print("-----------------------")
resp2 = getdata_("https://httpbin.org/get/1")
>>> retrying... (0)
    retrying... (1)
    retrying... (2)
    retrying... (3)
    -----------------------
    retrying... (0)
    retrying... (1)

И так, теперь нам не нужно писать три уровня вложенных функций, функция functools.partial позаботится об этом. Метод partial может быть использован для создания новых производящих функций, которым передаются некоторые входные параметры для инициализации. При этом partial будет выполнять следующий код:

def partial(func, *part_args):
    def wrapper(*extra_args):
        args = list(part_args)
        args.extend(extra_args)
        return func(*args)

    return wrapper

Определяем декораторы с помощью классов

В этом разделе мы рассмотрим как использовать классы для создания декораторов. И так, классы могут быть очень полезны, так как позволяют избежать вложенной архитектуры при определении декораторов. Кроме того полезно использовать классы для написания декораторов с сохранением некоторого внутреннего состояния от вызова к вызову. Ниже приведен пример кода, который поясняет как можно реализовать декоратор с использованием классов.

class ClassDeco:
    def __init__(self, func):
        functools.update_wrapper(self, func)
        self.func = func

    def __call__(self, *args, **kwargs):

        # Здесь вы можете добавить свой код перед вызовом функции
        val = self.func(*args, **kwargs)
        # Здесь вы можете добавить свой код после вызова функции

        return val

Давайте используем этот шаблон кода, чтобы написать декоратор Emphasis, который будет добавлять теги <b></b> к строке, возвращаемой функцией.

import functools

class Emphasis:
    def __init__(self, func):
        functools.update_wrapper(self, func)
        self.func = func

    def __call__(self, *args, **kwargs):
        val = self.func(*args, **kwargs)
        return "<b>" + val + "</b>"

@Emphasis
def hello(name):
    return f"Hello {name}"

print(hello("Nafi"))
print(hello("Redowan"))
>>> <b>Hello Nafi</b>
    <b>Hello Redowan</b>

Метод __init()__ сохраняет ссылку на функцию num_calls и может выполнять другой необходимый код инициализации. Метод __call()__ будет вызываться вместо функции, к которой мы хотим применять декоратор. По сути, он делает то же самое, что и функция wrapper() из наших предыдущих примеров. Обратите внимание, что в этом примере используется функция functools.update_wrapper() вместо @functools.wraps.

Прежде чем мы двигаться дальше, давайте определим декоратор с сохранением состояния, используя синтаксис класса. Декораторы с сохранением состояния могут запоминать его некоторое состояние от предыдущего запуска. В примере ниже декоратор Tally с сохранением данных состояния в словаре, который следит за тем, сколько раз были вызваны декорируемые функции. Ключи этого словаря tally будут содержать имена соответствующих функций, а значения – количество их вызовов.

import functools

class Tally:
    def __init__(self, func):
        functools.update_wrapper(self, func)
        self.func = func
        self.tally = {}
        self.n_calls = 0

    def __call__(self, *args, **kwargs):
        self.n_calls += 1
        self.tally[self.func.__name__] = self.n_calls

        print("Callable Tally:", self.tally)
        return self.func(*args, **kwargs)

@Tally
def hello(name):
    return f"Hello {name}!"

print(hello("Redowan"))
print(hello("Nafi"))
>>> Callable Tally: {'hello': 1}
    Hello Redowan!
    Callable Tally: {'hello': 2}
    Hello Nafi!

Еще несколько примеров

Кэширование возвращаемых значений

Декораторы могут предоставить элегантный способ запоминания (кэширования) возвращаемых значений функции.

Это способ оптимизации, при котором сохраняется результат выполнения функции, а затем этот результат используется при следующем вызове, называется мемоизацией (memoization).

Представим себе следующую ситуацию, у нас в приложении используется достаточно ресурсоемкий (или с длительным временем обращения) API, и вы хотели бы, по возможности, как можно реже к нему обращаться. Идея состоит в том, чтобы сохранять и кэшировать значения, возвращаемые вызовами API для конкретных значений параметров запроса. В случае их повторного запроса с помощью API с указанными аргументами, вы могли бы просто сразу возвращать результаты из кэша вместо совершения повторного вызова методов API. Этот прием может значительно улучшить производительность вашего приложения. В примере кода ниже я смоделировал “дорогой” вызов API с использованием модуля time.

import functools
import time

def api(a):
    """API принимает в качестве параметра целое число и возвращает его квадрат.
    Для имитации времени работы процесса обращения я добавил временную задержку."""

    print("The API has been called...")

    # Сделаем задержку 3 сек
    time.sleep(3)

    return a * a


api(3)
>>> The API has been called...
    9

И так запуск и выполнение функции api() занимает примерно 3 секунды. Для того, чтобы кэшировать результат ее выполнения, мы можем использовать функцию functools.lru_cache. С ее помощью мы можем сохранить результат выполнения функции api() в словаре, а затем использовать его, когда снова будет необходим запрос к API с тем же параметром. При этом словарь, содержащий информацию о прошлых запросах к API, будет иметь следующий вид: в качестве ключей будет использоваться значение параметра запроса, а в качестве значения, соответствующего ключу – ответ API (результат выполнения функции). Единственным недостатком этого способа является то, что параметры (аргументы) запроса к API должны иметь такой вид, что могут быть легко преобразованы в хэш, а точнее корректное наименование ключа словаря.

import functools


@functools.lru_cache(maxsize=32)
def api(a):
    """API принимает в качестве параметра целое число и возвращает его квадрат.
    Для имитации времени работы процесса обращения я добавил временную задержку."""

    print("The API has been called...")

    # This will delay 3 seconds
    time.sleep(3)

    return a * a


api(3)
>>> 9

Особенностью технической реализации метода functools.lru_cache() является принцип хранения полученных ранее результатов Least Recently Used LRU, что подразумевает организацию элементов словаря в порядке их использования, что позволяет быстро определить, какой элемент не использовался в течение длительного времени.

Least recently used (вытеснение давно неиспользуемых). Из словаря с данными запросов и ответов в первую очередь, вытесняется (убираются) не использующиеся дольше всех данные. Этот алгоритм требует отслеживания того, что и когда использовалось при работе кода.

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

Преобразование единиц измерения

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

import functools

def convert(func=None, convert_to=None):
    """Этот код конвертирует единицы измерения из одного типа в другой."""

    if func is None:
        return functools.partial(convert, convert_to=convert_to)

    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print(f"Conversion unit: {convert_to}")

        val = func(*args, **kwargs)

        # Добавим правила для преобразования
        if convert_to is None:
            return val

        elif convert_to == "km":
            return val / 1000

        elif convert_to == "mile":
            return val * 0.000621371

        elif convert_to == "cm":
            return val * 100

        elif convert_to == "mm":
            return val * 1000

        else:
            raise ValueError("Conversion unit is not supported.") # этот тип единиц не поддерживается

    return wrapper

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

@convert(convert_to="mile")
def area(a, b):
    return a * b


area(1, 2)
>>> Conversion unit: mile
    0.001242742

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

Регистрация функции для логирования выполнения кода

Ниже приведен пример регистрации функции логирования в фреймворке Flask. Декоратор register_logger не вносит никаких изменений в декорируемую функцию logger. Он берет функцию и регистрирует ее в списке logger_list каждый раз, когда она вызывается.

from flask import Flask, request

app = Flask(__name__)
logger_list = []


def register_logger(func):
    logger_list.append(func)
    return func


def run_loggers(request):
    for logger in logger_list:
        logger(request)


@register_logger
def logger(request):
    print(request.method, request.path)


@app.route("/")
def index():
    run_loggers(request)
    return "Hello World!"


if __name__ == "__main__":
    app.run(host="localhost", port="5000")

Если вы запустите локальный или другой, который вы используете при разработке, сервер и отправите ему запрос со следующим url http://localhost:5000/, то он поприветствует вас сообщением Hello World!. Также будет выведено тип http-метода запроса method и его относительный путь path. Более того, если вы просмотрите содержимое списка logger_list, то найдете там наш зарегистрированный логгер.

Самостоятельно ознакомившись с исходным кодом фреймворка Flask, вы найдете намного больше примеров реального использования декораторов.

Небольшое дополнение

Все фрагменты кода из этой статьи были разработаны и протестированы на компьютере с Ubuntu 18.04 и Python 3.8.

Оставить комментарий