Понимание декораторов в 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
в проект без изменения существующих тел методов.
В таком случае изменение класса с помощью декоратора гораздо элегантнее (потому что оно более модульное), чем наследование или использование миксинов.
Вернуться на верх