[Перевод] Разбираемся с декораторами в Python

2a442c8222323b564dba161b42244e87.png

Что такое декораторы?

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

В Python декораторы используются для «декорирования» функций (или методов). Возможно, один из самых популярных декораторов — это @property:

class Rectangle:
    def __init__(self, a, b):
        self.a = a
        self.b = b

    @property
    def area(self):
        return self.a * self.b

rect = Rectangle(5, 6)
print(rect.area)
# 30

Как видно в последней строке, вы можете получить доступ к area нашего Rectangle, как к атрибуту, то есть вам не нужно вызывать метод area. Вместо этого при доступе к area, как к атрибуту (без ()), метод вызывается неявно из-за декоратора @property

Как это работает?

Написать @property перед определением функции — то же самое, что написать 

area = property(area). Другими словами: property — это функция, которая принимает другую функцию в качестве аргумента и возвращает третью. Так и ведут себя декораторы. 

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

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

Декоратор retry

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

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

С учетом нашего изначального определения декоратора мы можем написать простой декоратор следующим образом:

def retry(func):
    def _wrapper(*args, **kwargs):
        try:
            func(*args, **kwargs)
        except:
            time.sleep(1)
            func(*args, **kwargs)
    return _wrapper

@retry
def might_fail():
    print("might_fail")
    raise Exception

might_fail()

Retry — имя нашего декоратора, который принимает в качестве аргумента любую функцию (func). Внутри декоратора определяется и возвращается новая функция (_wrapper). На первый взгляд определение одной функции внутри другой может показаться несколько непривычным. Однако синтаксически это совершенно нормально и имеет определенное преимущество, ведь функция _wrapperсуществует только внутри пространства имен нашего декоратора retry.

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

В итоге мы обрабатываем три функции:

  • retry

  • _wrapper

  • might_fail

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

  • Декоратор — не что иное, как функция (которая принимает другую функцию в качестве аргумента)

  • Чтобы использовать декоратор, нужно поместить его перед определением функции без ее вызова

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

Давайте попробуем так:

def retry(max_retries):
    def retry_decorator(func):
        def _wrapper(*args, **kwargs):
            for _ in range(max_retries):
                try:
                    func(*args, **kwargs)
                except:
                    time.sleep(1)
        return _wrapper
    return retry_decorator


@retry(2)
def might_fail():
    print("might_fail")
    raise Exception


might_fail()

Разложим на составляющие:

  • Сначала у нас была функция retry;

  • Retry принимает произвольный аргумент (max_retries в нашем случае) и возвращает функцию;

  • retry_decorator — это функция, возвращаемая retry и по факту наш декоратор;

  • _wrapper работает так же, как и раньше (теперь он просто выполняет максимальное количество попыток).

Для определения нашего декоратора:

  • На этот раз might_fail декорируется вызовом функции, т.е. @retry(2);

  • retry(2) вызывает retry, а та возвращает сам декоратор;

  • might_fail в конечном итоге будет отдекорирована retry_decorator, поскольку эта функция является результатом вызова retry(2).

Декоратор-таймер

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

import functools
import time

def timer(func):
    @functools.wraps(func)
    def _wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        runtime = time.perf_counter() - start
        print(f"{func.__name__} took {runtime:.4f} secs")
        return result
    return _wrapper

@timer
def complex_calculation():
    """Some complex calculation."""
    time.sleep(0.5)
    return 42

print(complex_calculation())

Вывод:

complex_calculation took 0.5041 secs
42

Как мы видим, декоратор-таймер выполняет код до и после функции и работает точно так же, как и в последнем примере.

functools.wraps

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

Однако, поскольку наш декоратор @timer мог быть написан как: complex_calculation = timer(complex_calculation), декоратор обязательно изменит нашу функцию complex_calculation. В частности, он меняет некоторые атрибуты специальных методов:

  • __module__

  • __name__

  • __qualname__

  • __doc__

  • __annotations__

При использовании @functools.wraps все эти атрибуты возвращаются к значениям по умолчанию.

Без @functools.wraps:

print(complex_calculation.__module__)       # __main__
print(complex_calculation.__name__)         # wrapper_timer
print(complex_calculation.__qualname__)     # timer..wrapper_timer
print(complex_calculation.__doc__)          # None
print(complex_calculation.__annotations__)  # {}

С @functools.wraps:

print(complex_calculation.__module__)       # __main__#
print(complex_calculation.__name__)         # complex_calculation
print(complex_calculation.__qualname__)     # complex_calculation
print(complex_calculation.__doc__)          # Some complex calculation.
print(complex_calculation.__annotations__)  # {} 

Декораторы классов

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

Давайте возьмем наш таймер из примера выше. Мы совершенно спокойно сможем обернуть в него класс:

@timer
class MyClass:
    def complex_calculation(self):
        time.sleep(1)
        return 42

my_obj = MyClass()
my_obj.complex_calculation()

В результате:

Finished 'MyClass' in 0.0000 secs

Очевидно, что выполнение complex_calculation не заняло времени. Помните, что @нотация — это просто эквивалент записи MyClass = timer(MyClass), т.е. декоратор будет вызван только тогда, когда вы «вызовете» класс. Вызов класса означает создание его экземпляра, поэтому таймер отработает только для строки my_obj = MyClass().

Методы класса не декорируются автоматически при декорации класса. Проще говоря, при использовании декоратора для обычного класса декорируется только его конструктор (метод __init__).

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

class MyDecorator:
    def __init__(self, function):
        self.function = function
        self.counter = 0
    
    def __call__(self, *args, **kwargs):
        self.function(*args, **kwargs)
        self.counter+=1
        print(f"Called {self.counter} times")


@MyDecorator
def some_function():
    return 42


some_function()
some_function()
some_function()

Вывод:

Called 1 times
Called 2 times
Called 3 times

Как это работает:

  • __init__ вызывается при оформлении some_function. Опять же, помните, что декорирование — это то же самое, что some_function = MyDecorator(some_function).

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

Декорирование класса в Python работает как изменение класса извне (т.е. из декоратора).

Смотрите:

def add_calc(target):

    def calc(self):
        return 42

    target.calc = calc
    return target

@add_calc
class MyClass:
    def __init__():
        print("MyClass __init__")

my_obj = MyClass()
print(my_obj.calc())

Вывод:

MyClass __init__
42

Опять же, вспомним определение декоратора, ведь все, что здесь происходит, следует той же логике:

  • my_obj = MyClass() сначала вызывает декоратор,

  • Декоратор add_calc добавляет метод calc к классу

  • В итоге класс создается с помощью конструктора.

Вы можете использовать декораторы для изменения классов подобно наследованию. Хорошо это или плохо в значительной степени зависит от архитектуры вашего проекта в целом. Декоратор dataclass из стандартной библиотеки — отличный пример разумного использования, при котором декораторы предпочтительнее наследования. Мы сейчас поговорим об этом.

Использование декораторов

Декораторы в стандартной библиотеке Python

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

Property

Как мы уже знаем, декоратор @property, вероятно, один из наиболее часто используемых декораторов в Python. Он нужен, чтобы вы могли получить доступ к результату выполнения метода как к атрибуту. Конечно, существует аналог @property, с помощью которого вы можете вызывать метод под капотом при выполнении операции присваивания.

class MyClass:
    def __init__(self, x):
        self.x = x
    
    @property
    def x_doubled(self):
        return self.x * 2
    
    @x_doubled.setter
    def x_doubled(self, x_doubled):
        self.x = x_doubled // 2

my_object = MyClass(5) 
print(my_object.x_doubled)  #  10  
print(my_object.x)          #  5  
my_object.x_doubled = 100   #    
print(my_object.x_doubled)  #  100 
print(my_object.x)          #  50  

Staticmethod

Еще один популярный декоратор — staticmethod. Он нужен, если вы хотите вызвать функцию, определенную внутри класса не создавая экземпляр класса:

class C:
    @staticmethod
    def the_static_method(arg1, arg2):
        return 42

print(C.the_static_method())

functools.cache

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

Можно сделать что-то вроде этого:

_cached_result = None
def complex_calculations():
    if _cached_result is None:
        _cached_result = something_complex()
    return _cached_result

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

from functools import cache

@cache
def complex_calculations():
    return something_complex()

Теперь каждый раз, когда вы вызываете complex_calculations(), Python сначала проверяет наличие кэшированного результата, прежде чем вызывать something_complex. Если в кэше есть результат, something_complex не будет вызываться дважды.

Dataclasses

В разделе про декораторы классов мы видели, что их можно использовать для изменения поведения классов по аналогии с наследованием.

Модуль dataclasses в стандартной библиотеке является хорошим примером, когда использование декоратора предпочтительнее, чем использование наследования. Давайте сначала посмотрим, как использовать dataclasses:

from dataclasses import dataclass

@dataclass
class InventoryItem:
    name: str
    unit_price: float
    quantity: int = 0

    def total_cost(self) -> float:
        return self.unit_price * self.quantity


item = InventoryItem(name="", unit_price=12, quantity=100)
print(item.total_cost())    # 1200

На первый взгляд, декоратор @dataclass добавил только конструктор, поэтому мы избегали шаблонного кода, подобного этому:

...
    def __init__(self, name, unit_price, quantity):
        self.name = name
        self.unit_price = unit_price
        self.quantity = quantity
...

Однако, если вы решите написать REST-API для своего проекта на Python и вам нужно преобразовать ваши объекты Python в строки JSON, для есть пакет под названием dataclasses-json (отсутствует в стандартной библиотеке), который декорирует классы и обеспечивает сериализацию и десериализацию объектов в строки JSON и наоборот.

Давайте посмотрим, как это выглядит:

from dataclasses import dataclass
from dataclasses_json import dataclass_json

@dataclass_json
@dataclass
class InventoryItem:
    name: str
    unit_price: float
    quantity: int = 0

    def total_cost(self) -> float:
        return self.unit_price * self.quantity


item = InventoryItem(name="", unit_price=12, quantity=100)

print(item.to_dict())
# {'name': '', 'unit_price': 12, 'quantity': 100}

Отсюда можно сделать два вывода:

  • Декораторы могут быть вложенными. Важен порядок их написания.

  • Декоратор @dataclass_json добавил в наш класс метод to_dict

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

Однако в данном случае декоратор добавляет только техническую функциональность (в отличие от расширения функционала). В результате мы можем просто «включать и выключать» декоратор без изменения поведения нашего приложения. «Естественная» иерархия классов сохраняется, и никаких изменений в код вносить не нужно. Мы можем добавить декоратор dataclasses-json в проект без изменения уже готовых методов.

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

Завтра в OTUS состоится открытое занятие «Docker для Python разработчика». Рассмотрим best practices написания Dockerfile’ов и работы с docker’ом в целом. Обсудим нюансы как общего характера, так и Python-специфичные. Регистрация — здесь.

© Habrahabr.ru