Зачем DE декораторы
Внешний мир ненадёжен. REST API третьей стороны падает каждый сотый запрос. БД иногда отвечает за 30 секунд, иногда — за 30 миллисекунд. Файл, который вы скачиваете, может прийти битым. И на каждую такую функцию хочется навесить — повторить N раз, замерить время, залогировать вход/выход, проверить, что вызов идёт из главного потока. Если писать это в теле каждой функции — код превратится в кашу.
@retry(times=3, delay=1.0)
@timeit
def fetch_users(api_url: str) -> list[dict]:
response = httpx.get(api_url, timeout=10)
response.raise_for_status()
return response.json()
Сама функция ничего не знает ни о ретраях, ни о замерах. Это вынесено наружу, и каждую обвязку можно тестировать отдельно от бизнес-логики.
Что такое декоратор технически
Главная идея: в Python функции — это объекты первого класса. Их можно класть в переменные, передавать аргументами, возвращать из других функций. Декоратор — это просто функция, которая:
- Принимает другую функцию как аргумент;
- Возвращает какую-то функцию (обычно — обёрнутую).
Самый простой декоратор — без аргументов и без эффекта:
def identity(func):
return func
@identity
def hello():
print("hi")
hello() # "hi"
Запись @identity ровно эквивалентна hello = identity(hello). Питон видит @, вычисляет правую часть, передаёт туда функцию ниже и кладёт результат обратно под её именем.
Теперь сделаем что-то полезное — декоратор @log_call, который печатает аргументы каждого вызова:
def log_call(func):
def wrapper(*args, **kwargs):
print(f"call {func.__name__}(args={args}, kwargs={kwargs})")
result = func(*args, **kwargs)
print(f"return {func.__name__} -> {result!r}")
return result
return wrapper
@log_call
def add(a, b):
return a + b
add(2, 3)
# call add(args=(2, 3), kwargs={})
# return add -> 5
Что здесь происходит, шаг за шагом. log_call — это и есть декоратор: он принимает func (нашу add) и возвращает wrapper. После @log_call имя add указывает уже не на исходную функцию, а на wrapper. Когда снаружи зовут add(2, 3), на самом деле зовётся wrapper(2, 3). Внутри wrapper имя func благодаря
add. Получается: декоратор перехватил вызов, добавил логирование, и пропустил вызов дальше.
Снаружи имя add указывает на wrapper. Wrapper держит ссылку на исходную функцию через замыкание.
*args, **kwargs в wrapper — стандартная защита: позволяет обернуть любую функцию, с любой сигнатурой. Без этого декоратор работал бы только с конкретной сигнатурой.
Зачем нужен functools.wraps
С простым декоратором есть скрытая проблема. Посмотрим внимательно:
@log_call
def add(a, b):
"""Return sum of a and b."""
return a + b
print(add.__name__) # 'wrapper' — !
print(add.__doc__) # None — !
Имя и docstring исходной функции потерялись. Внешне всё работает, но help(add) теперь покажет документацию wrapper, IDE-подсказки сломаются, ошибки в трейсбеке станут менее читаемыми.
Решение — декоратор functools.wraps. Он копирует с исходной функции имя, docstring, аннотации и пару других атрибутов:
from functools import wraps
def log_call(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"call {func.__name__}(args={args}, kwargs={kwargs})")
result = func(*args, **kwargs)
print(f"return {func.__name__} -> {result!r}")
return result
return wrapper
@log_call
def add(a, b):
"""Return sum of a and b."""
return a + b
print(add.__name__) # 'add'
print(add.__doc__) # 'Return sum of a and b.'
Правило, без исключений: при написании любого собственного декоратора оборачивайте wrapper в @wraps(func). Если забыли — вы оставили мину для пользователей вашей функции.
Декоратор с аргументами
@retry(times=3, delay=1.0) — это уже не просто декоратор. Это функция, которая возвращает декоратор. Когда Python видит @something(...), он:
- Вычисляет
something(...)— получает «настоящий» декоратор. - Применяет результат к функции ниже.
То есть нужно три уровня вложенности: внешняя функция принимает параметры, средняя — функцию, внутренняя — реальные аргументы вызова.
import time
from functools import wraps
def retry(times: int = 3, delay: float = 1.0):
"""Повторить функцию до `times` раз при исключении, с задержкой между попытками."""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
last_exc = None
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except Exception as exc:
last_exc = exc
if attempt < times:
print(f"attempt {attempt} failed: {exc}; retrying in {delay}s")
time.sleep(delay)
raise last_exc
return wrapper
return decorator
@retry(times=3, delay=0.5)
def fetch_users():
response = httpx.get("https://api.example.com/users", timeout=5)
response.raise_for_status()
return response.json()
Прочтите ещё раз цепочку: retry(times=3, delay=0.5) — это вызов с параметрами, он возвращает decorator. decorator(fetch_users) — это уже настоящий декоратор, возвращает wrapper. И только wrapper будет вызываться, когда снаружи кто-то напишет fetch_users().
В production-коде на ретраях обычно пользуются библиотекой tenacity — она умеет экспоненциальный backoff, jitter, фильтрацию по типам ошибок, лимиты по суммарному времени. Мы детально разберём её в Module 06. А свой @retry мы пишем, чтобы понять, как такие декораторы устроены изнутри.
Exponential backoff в пять строк
Слегка прокачаем @retry: пусть задержка растёт между попытками. Это стандартный паттерн в работе с нестабильными API — даёт серверу время прийти в себя:
def retry(times: int = 3, base_delay: float = 0.5):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(times):
try:
return func(*args, **kwargs)
except Exception as exc:
if attempt == times - 1:
raise
sleep_for = base_delay * (2 ** attempt) # 0.5, 1, 2, 4...
print(f"retry in {sleep_for}s: {exc}")
time.sleep(sleep_for)
return wrapper
return decorator
Каждая следующая попытка ждёт в два раза дольше — это и есть
tenacity, AWS SDK, kubectl и почти любого production-клиента.
Stacking декораторов
Декораторы можно ставить друг на друга — порядок имеет значение:
@retry(times=3)
@timeit
def fetch_users():
...
Это эквивалентно fetch_users = retry(times=3)(timeit(fetch_users)). То есть ближайший к функции декоратор применяется первым, потом — следующий, и так наружу.
В этом примере timeit оборачивает исходную функцию, retry оборачивает уже обёрнутую timeit-версию. Каждая попытка retry будет измерена timeit’ом. Если порядок поменять — timeit измерит сразу всю серию ретраев как один вызов. Меняйте порядок, исходя из смысла.
Готовые декораторы из stdlib
Свои декораторы писать круто, но 80% случаев уже закрыты библиотечными.
@functools.cache — мемоизация без лимита: первая вычисленная пара (args, kwargs) → result сохраняется в словаре, последующие вызовы с теми же аргументами берут готовое.
from functools import cache
@cache
def fibonacci(n: int) -> int:
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
fibonacci(100) # без кэша висло бы вечность; с кэшем — мгновенно
Условие применимости: функция должна быть чистой — одни и те же аргументы всегда дают один и тот же результат. Аргументы должны быть hashable. Если функция читает БД или ходит в сеть — там кэш на уровне процесса часто это баг, а не фича.
@functools.lru_cache(maxsize=128) — то же самое, но с ограничением размера. Выбрасывает наименее недавно использованную пару, когда переполняется. Используют, если результаты тяжёлые и неограниченный рост кэша опасен.
@staticmethod — функция внутри класса, которая не получает ни self, ни cls. Просто живёт в namespace класса.
@classmethod — функция внутри класса, которая получает первым аргументом сам класс (cls), а не экземпляр. Используется для альтернативных конструкторов: User.from_dict(...), Path.cwd().
@property — превращает метод в атрибут. Снаружи пишется obj.name, под капотом срабатывает функция. Удобно для ленивых/вычисляемых атрибутов; глубже разберём в модуле про типы.
DE-кейс: @timeit для профилирования
Один из самых полезных собственных декораторов — замер времени функции. По духу — то же, что CM timing() из прошлого урока, только для функций целиком:
import time
from functools import wraps
def timeit(func):
@wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
try:
return func(*args, **kwargs)
finally:
elapsed = time.perf_counter() - start
print(f"{func.__name__}: {elapsed:.3f}s")
return wrapper
@timeit
def aggregate_orders(path: str) -> dict[str, int]:
...
Развернуть в production-вариант — заменить print на logger.info(...) со структурным форматом (про логирование — в уроке 06), и вынести time.perf_counter() в вспомогательный CM, чтобы переиспользовать. Базовый шаблон — этот.
Anti-pattern: декораторы для бизнес-логики
Декораторы выглядят настолько круто, что хочется засунуть в них всё. Не надо.
Плохо:
@require_admin
@audit_action("delete_user")
@validate_input(schema=DeleteUserSchema)
def delete_user(user_id: int):
db.execute("DELETE FROM users WHERE id = ?", (user_id,))
Что не так. Проверка прав доступа — это бизнес-логика, а не cross-cutting concern. Когда она в декораторе, её невозможно увидеть, не разворачивая всю обвязку. Тестировать delete_user без @require_admin нельзя — он мокается только на уровне самого декоратора. Поведение функции зависит от того, какие декораторы навешаны и в каком порядке. Через год новый разработчик попробует переиспользовать delete_user из админ-скрипта и неделю будет ловить непонятный PermissionError.
Здоровый паттерн — оставлять в декораторах только техническую обвязку, инфраструктурную, лишённую знания о домене:
- ОК:
@timeit,@retry,@cache,@log_call— это про инструментарий, не про данные. - Не ОК:
@require_admin,@bill_user,@send_notification— здесь решения зависят от состояния системы.
Простое правило: если декоратор смотрит в саму функцию или в её аргументы по смыслу (читает поле user_id, проверяет роль) — это бизнес-логика, и ей лучше быть явной строкой в начале функции, а не магией снаружи.
Подводные камни
Декоратор выполняется в момент определения функции, а не в момент её вызова. Если внутри декоратора есть тяжёлая работа — она случится один раз на импорт.
Декорированная функция — это не та же самая функция. add is original_add после декорации — False. Это иногда мешает мокать в тестах: моки нужно ставить на ту версию, которая в namespace модуля.
Декораторы плохо стекаются с другими протоколами. Например, @classmethod имеет особое поведение, и его нельзя оборачивать произвольным wraps. Если декорируете методы — проверяйте, всё ли срабатывает.
@functools.cache на методах класса держит ссылку на self, и объекты этим методом не собираются GC. Используйте cache только для чистых функций без self.
Что мы получили
- Декоратор — это функция, которая принимает функцию и возвращает функцию. Никакой магии — просто HOF и замыкания.
- Шаблон с тремя уровнями вложенности — стандарт для декораторов с аргументами.
@functools.wraps— обязательная гигиена, без неё страдают трейсбеки и интроспекция.- В stdlib есть готовые декораторы:
cache,lru_cache,staticmethod,classmethod,property. - DE-применение:
@retryдля нестабильного API,@timeitдля профилирования,@cacheдля чистых вычислений. - Не суйте бизнес-логику в декораторы — там должна жить только cross-cutting инфраструктура.
В следующем уроке — pathlib: современная замена os.path для работы с путями и файловой системой.