python

Путь к пониманию декораторов в Python

  • вторник, 29 марта 2022 г. в 00:37:19
https://habr.com/ru/company/wunderfund/blog/657355/
  • Блог компании Wunder Fund
  • Python


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

Материал рассчитан на начинающих программистов, которые хотят разобраться с тем, что такое декораторы, и с тем, как применять их в своих проектах.

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

Декораторы — это обёртки вокруг 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

Мы дали довольно-таки размытое определение декораторов. Для того чтобы разобраться с тем, как именно они работают, займёмся написанием собственных декораторов.

Предположим, имеется функция, которую мы хотим запустить повторно в том случае, если при её первом запуске произойдёт сбой. То есть — нам нужна функция (декоратор, имя которого, 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.

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

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

  • retry

  • _wrapper

  • might_fail

В некоторых случаях нужно, чтобы декораторы принимали бы дополнительные аргументы. Например, нам может понадобиться, чтобы декоратор retry принимал бы число, задающее количество попыток запуска декорируемой функции. Но декоратор обязательно должен принимать декорируемую функцию в качестве первого аргумента. Не будем забывать и о том, что нам не надо вызывать декоратор при декорировании функции. То есть — о том, что перед определением функции мы используем конструкцию @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_decorator — это и есть реальный декоратор.

  • Функция _wrapper работает так же, как и прежде (только теперь она руководствуется сведениями о максимальном количестве перезапусков декорированной функции).

О коде нового декоратора мне больше сказать нечего. Теперь поговорим об его использовании:

  • Функция might_fail теперь декорируется с помощью вызова функции вида @retry(2).

  • Вызов retry(2) приводит к тому, что вызывается функция retry, которая и возвращает реальный декоратор.

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

Декоратор timer

Напишем ещё один полезный декоратор — timer («таймер»). Он будет измерять время выполнения декорированной с его помощью функции:

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

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

Декоратор functools.wraps

Анализируя вышеприведённый код, вы могли заметить, что сама функция _wrapper декорирована с помощью @functools.wraps. Но это никоим образом не меняет логику или функционал декоратора timer. При этом разработчик может принять решение о целесообразности использования 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.<locals>.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 из предыдущего примера. Он отлично подходит и в качестве обёртки для класса:

@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). То есть — декоратор вызывается только когда «вызывают» класс. «Вызов» класса — это создание его экземпляра. Получается, что timer вызывается лишь при выполнении строки кода 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 — это теперь экземпляр класса MyDecorator, но использовать мы её при этом планируем как функцию. За это отвечает магический метод __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.

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

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

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

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

В следующих разделах мы познакомимся с несколькими наиболее популярными и наиболее широко используемыми декораторами, которые включены в состав стандартной библиотеки 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, запись в эту переменную некоего значения в том случае, если она не равна None — всё это — повторяющиеся задачи. А значит — перед нами идеальная ситуация для применения декораторов. Но самостоятельно писать такой декоратор нам не придётся — в стандартной библиотеке Python есть именно то, что нужно для решения этой задачи — декоратор cache:

from functools import cache

@cache
def complex_calculations():
    return something_complex()

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

Декоратор dataclass

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

Модуль стандартной библиотеки 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
...

Но не всё так просто. Предположим, решено оснастить Python-проект REST-API, при этом встанет необходимость преобразовывать 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}

Разбирая этот код, можно сделать два наблюдения:

  1. Декораторы могут быть вложены друг в друга. При этом важен порядок их появления в коде.

  2. Декоратор @dataclass_json добавляет к классу метод to_dict.

Конечно, можно написать миксин (mixin, подмешанный класс), ответственный за решение всех сложных задач, связанных с типобезопасной реализацией метода to_dict. Потом можно сделать наш класс InventoryItem наследником этого класса.

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

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

О, а приходите к нам работать? 😏

Мы в wunderfund.io занимаемся высокочастотной алготорговлей с 2014 года. Высокочастотная торговля — это непрерывное соревнование лучших программистов и математиков всего мира. Присоединившись к нам, вы станете частью этой увлекательной схватки.

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

Сейчас мы ищем плюсовиков, питонистов, дата-инженеров и мл-рисерчеров.

Присоединяйтесь к нашей команде.