Декораторы: @decorator, functools.wraps, типичные паттерны
Декоратор - функция, которая принимает другую функцию и возвращает изменённую версию. Под капотом это замыкание плюс *args/**kwargs. В Python это один из самых выразительных инструментов: им построены маршруты во Flask/FastAPI, property/staticmethod в классах, регистрация плагинов, кеширование, retry-логика и многое другое. В этом уроке - базовый синтаксис, типичные паттерны и грабли.
Зачем нужны декораторы
Представь что хочешь логировать вызовы нескольких функций:
def add(a, b):
return a + b
def multiply(a, b):
return a * b
def divide(a, b):
return a / b
Можно вписать print в каждую - но это повторение, и логика логирования размазана по коду. Декоратор позволяет вынести её отдельно:
def logged(func):
def wrapper(*args, **kwargs):
print(f"Вызываю {func.__name__}({args}, {kwargs})")
result = func(*args, **kwargs)
print(f"Результат: {result}")
return result
return wrapper
@logged
def add(a, b):
return a + b
add(2, 3)
# Вызываю add((2, 3), {})
# Результат: 5
# 5
@logged над def add равносильно add = logged(add). То есть add теперь это wrapper, который оборачивает оригинальный add.
Базовый шаблон
def my_decorator(func):
def wrapper(*args, **kwargs):
# код ДО вызова
result = func(*args, **kwargs)
# код ПОСЛЕ вызова
return result
return wrapper
@my_decorator
def some_function():
...
Структура:
- Декоратор принимает функцию
- Внутри определяет
wrapper, который вызывает функцию - Возвращает
wrapper @my_decoratorприменяет это к декорируемой функции
Использование functools.wraps
Если оставить декоратор как выше, метаданные декорируемой функции теряются:
@logged
def add(a, b):
"""Складывает два числа."""
return a + b
print(add.__name__) # wrapper - не add!
print(add.__doc__) # None - docstring потерян
Это плохо для отладки, IDE-подсказок и инспекции. Решение - functools.wraps:
from functools import wraps
def logged(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"Вызываю {func.__name__}")
return func(*args, **kwargs)
return wrapper
@logged
def add(a, b):
"""Складывает два числа."""
return a + b
print(add.__name__) # add
print(add.__doc__) # Складывает два числа.
@wraps(func) переносит __name__, __doc__, __wrapped__ и другие метаданные с оригинальной функции на wrapper. Всегда используй wraps в своих декораторах.
Типичные паттерны
1. Логирование
@wraps(func)
def wrapper(*args, **kwargs):
print(f"[{datetime.now()}] {func.__name__}({args}, {kwargs})")
return func(*args, **kwargs)
2. Замер времени
import time
from functools import wraps
def timed(func):
@wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__}: {elapsed:.4f}s")
return result
return wrapper
@timed
def heavy():
sum(x ** 2 for x in range(1_000_000))
3. Кеширование (мемоизация)
def memoize(func):
cache = {}
@wraps(func)
def wrapper(*args):
if args not in cache:
cache[args] = func(*args)
return cache[args]
return wrapper
@memoize
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
В стандартной библиотеке уже есть готовый @functools.lru_cache и @functools.cache - используй их вместо собственного memoize в большинстве случаев.
4. Проверка прав доступа
def require_admin(func):
@wraps(func)
def wrapper(user, *args, **kwargs):
if not user.is_admin:
raise PermissionError("Только для администраторов")
return func(user, *args, **kwargs)
return wrapper
@require_admin
def delete_user(user, target_id):
db.delete(target_id)
5. Retry с backoff
import time
def retry(times=3, delay=1):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(times):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == times - 1:
raise
print(f"Попытка {attempt + 1} упала: {e}")
time.sleep(delay * (2 ** attempt))
return wrapper
return decorator
@retry(times=3, delay=1)
def fetch_data():
response = requests.get("http://api.example.com")
response.raise_for_status()
return response.json()
Это параметризованный декоратор - подробно про них в следующем уроке.
Несколько декораторов
Декораторы можно стэкать:
@logged
@timed
@memoize
def compute(x):
return x ** 2
Это эквивалентно:
compute = logged(timed(memoize(compute)))
Применение снизу вверх: сначала memoize, потом timed, потом logged. Порядок важен - меняет поведение.
Пример: что произойдёт при compute(5):
logged.wrapper(5)- логирует вход- →
timed.wrapper(5)- засекает время - →
memoize.wrapper(5)- проверяет кеш - → если нет в кеше:
compute(5)- реальный вызов - → результаты идут обратно по цепочке
При втором вызове compute(5):
logged.wrapper(5)- залогирует- →
timed.wrapper(5)- засечёт время - →
memoize.wrapper(5)- вернёт из кеша
Время будет крошечное (только overhead логирования и timing), реальный compute не выполнится.
Декораторы для классов
Декорировать можно и классы:
def add_repr(cls):
def __repr__(self):
attrs = ", ".join(f"{k}={v!r}" for k, v in self.__dict__.items())
return f"{cls.__name__}({attrs})"
cls.__repr__ = __repr__
return cls
@add_repr
class User:
def __init__(self, name, age):
self.name = name
self.age = age
print(User("Alice", 30)) # User(name='Alice', age=30)
В стандартной библиотеке такое уже сделано через @dataclass - модуль 6 «ООП».
Подводные камни
1. Забыл @wraps
# Плохо
def logged(func):
def wrapper(*args, **kwargs):
print(f"call")
return func(*args, **kwargs)
return wrapper
# Метаданные теряются
@logged
def add(): pass
print(add.__name__) # wrapper
Всегда используй @wraps(func).
2. Mutable state в decorator
# Плохо - count shared между всеми декорированными функциями
counter = 0
def counted(func):
def wrapper(*args, **kwargs):
global counter
counter += 1
return func(*args, **kwargs)
return wrapper
Если хочется счётчик для каждой функции отдельно, нужно держать в closure:
def counted(func):
count = 0
@wraps(func)
def wrapper(*args, **kwargs):
nonlocal count
count += 1
result = func(*args, **kwargs)
return result
wrapper.count = lambda: count # доступ к счётчику
return wrapper
3. Применение декоратора к методу
Декораторы работают и с методами, но wrapper должен принимать self:
def logged(func):
@wraps(func)
def wrapper(self, *args, **kwargs):
print(f"Calling {func.__name__} on {self}")
return func(self, *args, **kwargs)
return wrapper
class User:
@logged
def greet(self):
print(f"Hello from {self}")
Или используй *args, **kwargs - тогда self пройдёт через них без специальной обработки.
4. Декоратор не сохраняет сигнатуру
@logged
def add(a: int, b: int) -> int:
return a + b
import inspect
print(inspect.signature(add)) # без @wraps: (*args, **kwargs)
# с @wraps: (a: int, b: int) -> int
С @wraps сигнатура сохраняется в __wrapped__, и inspect.signature её находит. Без этого IDE не покажет правильные подсказки.
Когда не использовать декораторы
- Когда нужно изменить поведение только в одном месте (просто впиши логику)
- Когда декоратор делает что-то сильно неочевидное - читателю придётся искать
- Когда стек декораторов больше 3-4 - сложно отследить порядок
- Когда нужна явная конфигурация на call-site - параметр функции читаемее
Встроенные декораторы в Python
| Декоратор | Что делает |
|---|---|
@property | Превращает метод в свойство (доступ как атрибут) |
@staticmethod | Метод без self - просто функция в namespace класса |
@classmethod | Метод принимает класс как первый аргумент вместо self |
@functools.wraps | Сохраняет метаданные декорируемой функции |
@functools.lru_cache | Мемоизация с LRU |
@functools.cache | Простая мемоизация без вытеснения (3.9+) |
@functools.total_ordering | Генерирует все операторы сравнения из eq и lt |
@dataclass | Генерирует init, repr, eq из аннотированных полей |
@contextlib.contextmanager | Превращает генератор в context manager |
Мини-задание
- Декоратор логирования с wraps:
from functools import wraps
def logged(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}({args}, {kwargs})")
result = func(*args, **kwargs)
print(f" -> {result}")
return result
return wrapper
@logged
def add(a, b):
"""Складывает два числа."""
return a + b
add(3, 5)
print(add.__name__) # add (благодаря @wraps)
print(add.__doc__) # Складывает два числа.
- Замер времени для горячих функций:
import time
from functools import wraps
def timed(func):
@wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__}: {elapsed:.4f}s")
return result
return wrapper
@timed
def heavy():
return sum(x ** 2 for x in range(10_000_000))
heavy()
- Стек декораторов:
@logged
@timed
def slow_calc(n):
time.sleep(0.1)
return n * 2
slow_calc(5)
# Calling slow_calc((5,), {})
# slow_calc: 0.1024s
# -> 10
Что дальше
Освоили базовые декораторы. В следующем уроке - продвинутые декораторы: параметризованные, class-based, classmethod/staticmethod/property, и как декорировать класс целиком.