Прим. 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}
Разбирая этот код, можно сделать два наблюдения:
Декораторы могут быть вложены друг в друга. При этом важен порядок их появления в коде.
Декоратор
@dataclass_json
добавляет к классу методto_dict
.
Конечно, можно написать миксин (mixin, подмешанный класс), ответственный за решение всех сложных задач, связанных с типобезопасной реализацией метода to_dict
. Потом можно сделать наш класс InventoryItem
наследником этого класса.
В предыдущем примере, однако, декоратор оснащает класс лишь техническим функционалом (в противоположность расширению возможностей класса с учётом конкретной задачи). В результате можно отключать и подключать этот декоратор, не меняя поведения основной программы. Этот подход позволяет сохранить нашу «естественную» иерархию классов, код проекта не придётся подвергать изменениям. Декоратор dataclasses-json
можно добавить в проект, не переписывая при этом тела существующих методов.
В подобном случае модификация поведения класса с помощью декораторов выглядит гораздо более элегантным решением (за счёт его лучшей модульности), чем применение наследования или миксинов.
О, а приходите к нам работать? 😏
Мы в wunderfund.io занимаемся высокочастотной алготорговлей с 2014 года. Высокочастотная торговля — это непрерывное соревнование лучших программистов и математиков всего мира. Присоединившись к нам, вы станете частью этой увлекательной схватки.
Мы предлагаем интересные и сложные задачи по анализу данных и low latency разработке для увлеченных исследователей и программистов. Гибкий график и никакой бюрократии, решения быстро принимаются и воплощаются в жизнь.
Сейчас мы ищем плюсовиков, питонистов, дата-инженеров и мл-рисерчеров.