Разбираемся с декораторами в Python
- вторник, 17 мая 2022 г. в 00:38:25
Что такое декораторы?
Декораторы – это обертка вокруг функций (или классов) в 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.<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
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-специфичные. Регистрация — здесь.