Понимание декораторов в 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 - это функция, которая принимает другую функцию в качестве аргумента и возвращает третью функцию. Именно это и делают декораторы.

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

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

Декоратор ретрита

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

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

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

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()

Разрывая его на части:

Это для определения нашего декоратора.

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

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

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 выполняет некоторый код до и после функции decorated и работает точно так же, как и в предыдущем примере.

functools.wraps

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

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

При использовании @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), т.е. декоратор будет вызван только тогда, когда вы "вызовете" класс.  Вызов класса означает его инстанцирование, поэтому таймер будет выполняться только в строке my_obj = MyClass().

Методы класса не автоматически декорируются при декорировании класса. Проще говоря, использование декоратора normal для украшения normal класса украшает только его конструктор (__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.some_function = MyDecorator(some_function)
  • __call__ вызывается, когда используется экземпляр класса, например, при вызове функции. Поскольку some_function теперь является экземпляром MyDecorator, но мы по-прежнему хотим использовать его как функцию, за это отвечает магический метод DoubleUnderscore __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 стандартной библиотеки является отличным примером разумного использования декораторов вместо наследования. Мы обсудим это через секунду.

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

decorators в стандартной библиотеке 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

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

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

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

Вернуться на верх