Декораторы в Python: от синтаксического сахара до функциональных инструментов
Разбираем декораторы изнутри: как они работают на уровне замыканий, зачем нужен functools.wraps и как писать декораторы с аргументами. Примеры из реального кода — кэширование, логирование, контроль доступа.
Содержание
Декораторы в Python: от синтаксического сахара до функциональных инструментов
Декораторы — одна из тех концепций Python, которая поначалу выглядит как магия. Вы видите @login_required над функцией, и она вдруг начинает проверять авторизацию. Вы пишете @cache, и результаты вычислений запоминаются между вызовами. Синтаксис лаконичный, эффект очевидный — но что именно происходит под капотом?
Ответ на этот вопрос важен не только для понимания чужого кода. Разработчик, который понимает механику декораторов, пишет более выразительный, тестируемый и поддерживаемый код. Тот, кто не понимает, — рано или поздно сталкивается с трудноуловимыми багами: потерянными именами функций, неожиданным поведением при стекировании декораторов или сломанными сигнатурами.
Разберём декораторы по-настоящему: от первых принципов до практических паттернов.
Функции как объекты первого класса
Прежде чем говорить о декораторах, нужно зафиксировать базовый факт: в Python функции — это объекты. Их можно присваивать переменным, передавать как аргументы и возвращать из других функций. Именно на этом фундаменте держится весь механизм декораторов.
def greet(name):
return f"Hello, {name}"
# Функцию можно присвоить переменной
say_hello = greet
print(say_hello("Alice")) # Hello, Alice
# Функцию можно передать как аргумент
def apply(func, value):
return func(value)
print(apply(greet, "Bob")) # Hello, Bob
Это не трюк и не исключение — это фундаментальное свойство языка. Python не делает различий между «обычными» объектами и функциями с точки зрения передачи и хранения.
Замыкания: механизм, на котором всё держится
Декоратор — это, по сути, функция, которая принимает другую функцию и возвращает новую. Чтобы эта новая функция «помнила» оригинальную, используется замыкание.
Замыкание возникает, когда вложенная функция ссылается на переменную из охватывающей области видимости. Эта переменная «захватывается» и остаётся доступной даже после того, как внешняя функция завершила работу.
def make_multiplier(factor):
def multiply(x):
return x * factor # factor захвачен из внешней области
return multiply
double = make_multiplier(2)
triple = make_multiplier(3)
print(double(5)) # 10
print(triple(5)) # 15
Переменная factor живёт в объекте функции multiply — её можно увидеть через double.__closure__. Именно этот механизм позволяет декораторам «обёртывать» функцию, сохраняя доступ к ней внутри обёртки.
Первый декоратор: разбираем по шагам
Напишем простейший декоратор — логгер вызовов:
def log_calls(func):
def wrapper(*args, **kwargs):
print(f"Вызов: {func.__name__} с аргументами {args}, {kwargs}")
result = func(*args, **kwargs)
print(f"Результат: {result}")
return result
return wrapper
def add(a, b):
return a + b
add = log_calls(add) # Явное применение декоратора
add(3, 5)
Вывод:
Вызов: add с аргументами (3, 5), {}
Результат: 8
Синтаксис @log_calls над функцией — это просто сокращённая запись того же самого:
@log_calls
def add(a, b):
return a + b
# Эквивалентно:
# add = log_calls(add)
Никакой магии. @ — это синтаксический сахар, который Python раскрывает в момент определения функции.
Проблема потери идентичности и functools.wraps
Есть одна серьёзная проблема с декоратором выше. После применения функция add — это уже не add, а wrapper:
print(add.__name__) # wrapper
print(add.__doc__) # None
Это ломает интроспекцию, документацию, логирование и — что критично — некоторые фреймворки, которые полагаются на имена функций (например, Flask при регистрации маршрутов).
Решение — functools.wraps. Это декоратор для декоратора, который копирует метаданные оригинальной функции на обёртку:
import functools
def log_calls(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"Вызов: {func.__name__}")
result = func(*args, **kwargs)
return result
return wrapper
@log_calls
def add(a, b):
"""Складывает два числа."""
return a + b
print(add.__name__) # add
print(add.__doc__) # Складывает два числа.
functools.wraps копирует __name__, __qualname__, __doc__, __dict__ и __module__. Это стандарт — использовать его нужно всегда, без исключений. Пропустить functools.wraps в продакшн-коде — распространённая ошибка, которая проявляется не сразу, но больно бьёт при отладке.
Декораторы с аргументами
Простые декораторы принимают функцию и возвращают функцию. Но что если декоратору нужны собственные параметры? Например, @retry(times=3) или @require_role("admin").
Здесь нужен ещё один уровень вложенности: фабрика декораторов.
import functools
import time
def retry(times=3, delay=1.0):
"""Повторяет вызов функции при исключении."""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last_exception = None
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except Exception as e:
last_exception = e
print(f"Попытка {attempt}/{times} не удалась: {e}")
if attempt < times:
time.sleep(delay)
raise last_exception
return wrapper
return decorator
@retry(times=3, delay=0.5)
def fetch_data(url):
# Симулируем нестабильное соединение
import random
if random.random() < 0.7:
raise ConnectionError("Нет соединения")
return f"Данные с {url}"
Структура: retry(times=3) возвращает decorator, decorator(func) возвращает wrapper. Три уровня вложенности — это нормально и читаемо, если каждый уровень несёт свою ответственность.
Ключевой момент: когда вы пишете @retry(times=3), Python сначала вызывает retry(times=3) и получает decorator, а затем применяет его к функции. Если же написать просто @retry без скобок — это уже другое: retry применится напрямую к функции, и она окажется аргументом times. Это частая ошибка при написании декораторов с опциональными аргументами.
Декоратор, работающий и со скобками, и без
Чтобы декоратор работал в обоих режимах (@retry и @retry(times=5)), можно использовать трюк с functools.partial:
import functools
def retry(func=None, *, times=3, delay=1.0):
if func is None:
# Вызван с аргументами: @retry(times=5)
return functools.partial(retry, times=times, delay=delay)
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == times:
raise
time.sleep(delay)
return wrapper
Теперь работают оба варианта:
@retry
def operation_a(): ...
@retry(times=5, delay=2.0)
def operation_b(): ...
Практические паттерны
Кэширование результатов
Стандартная библиотека уже содержит functools.lru_cache и functools.cache — но понимать, как они устроены, полезно:
import functools
def memoize(func):
"""Простое кэширование без ограничения размера."""
cache = {}
@functools.wraps(func)
def wrapper(*args):
if args not in cache:
cache[args] = func(*args)
return cache[args]
wrapper.cache = cache # Открываем доступ к кэшу снаружи
wrapper.cache_clear = lambda: cache.clear()
return wrapper
@memoize
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(50)) # Мгновенно, без рекурсии в 2^50 вызовов
Обратите внимание на wrapper.cache = cache — декоратор может расширять интерфейс функции, добавляя атрибуты. Именно так устроен lru_cache, который добавляет метод cache_info().
Контроль доступа
Паттерн, который активно используется в веб-фреймворках:
import functools
from typing import Callable
def require_permission(permission: str):
"""Проверяет наличие разрешения у текущего пользователя."""
def decorator(func: Callable):
@functools.wraps(func)
def wrapper(*args, **kwargs):
# В реальном приложении current_user берётся из контекста
current_user = get_current_user()
if not current_user.has_permission(permission):
raise PermissionError(
f"Требуется разрешение '{permission}', "
f"у пользователя {current_user.name} его нет"
)
return func(*args, **kwargs)
return wrapper
return decorator
@require_permission("reports.export")
def export_report(report_id: int) -> bytes:
"""Экспортирует отчёт в PDF."""
...
Этот паттерн чист и тестируем: логику проверки прав можно покрыть юнит-тестами независимо от бизнес-логики функции.
Измерение времени выполнения
import functools
import time
import logging
logger = logging.getLogger(__name__)
def timeit(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
try:
result = func(*args, **kwargs)
return result
finally:
elapsed = time.perf_counter() - start
logger.debug(
"%s выполнилась за %.4f сек",
func.__qualname__,
elapsed
)
return wrapper
Использование time.perf_counter() вместо time.time() — правильный выбор для измерения коротких интервалов: он не зависит от системных часов и имеет более высокое разрешение.
Стекирование декораторов и порядок применения
Декораторы можно стекировать — и порядок имеет значение:
@decorator_a
@decorator_b
@decorator_c
def my_func():
...
# Эквивалентно:
# my_func = decorator_a(decorator_b(decorator_c(my_func)))
Декораторы применяются снизу вверх при определении, но выполняются сверху вниз при вызове. Это интуитивно не очевидно и является источником багов. Если decorator_a — это @timeit, а decorator_b — @retry, то timeit будет измерять время включая все повторные попытки. Поменяйте их местами — и будете измерять время одного вызова.
Декораторы классов
Декораторы применимы не только к функциям, но и к классам. Стандартный пример — @dataclass:
from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float
# Python автоматически генерирует __init__, __repr__, __eq__
p = Point(1.0, 2.0)
print(p) # Point(x=1.0, y=2.0)
Декоратор класса получает класс как аргумент и может возвращать модифицированный класс или совершенно новый объект.
Когда декораторы — не лучший выбор
Декораторы элегантны, но не универсальны. Несколько ситуаций, где стоит остановиться:
Сложная условная логика. Если декоратор содержит разветвлённую логику с десятками условий, это сигнал, что логика должна быть в самой функции или в отдельном слое.
Изменение сигнатуры. Декоратор, который меняет аргументы функции, ломает статический анализ и документацию. functools.wraps копирует __wrapped__, но типизаторы всё равно могут запутаться. В таких случаях лучше рассмотреть явное наследование или протоколы.
Отладка. Стек вызовов с несколькими декораторами может быть трудночитаемым. Хорошо написанный декоратор с functools.wraps и осмысленными именами обёрток снижает эту проблему, но не устраняет полностью.
Именно такие нюансы — граница между «умело применил» и «переусложнил» — разбираются в программах уровня «Senior Python», где декораторы рассматриваются в контексте реальной архитектуры приложений, а не как изолированная синтаксическая конструкция.
Декораторы и типизация
Начиная с Python 3.10, правильная типизация декораторов стала значительно проще благодаря ParamSpec и TypeVar:
from typing import TypeVar, Callable
from collections.abc import Callable as CallableABC
import functools
F = TypeVar("F", bound=CallableABC)
def log_calls(func: F) -> F:
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"Вызов {func.__name__}")
return func(*args, **kwargs)
return wrapper # type: ignore[return-value]
Для декораторов с аргументами и сохранением сигнатуры используется ParamSpec:
from typing import ParamSpec, TypeVar, Callable
import functools
P = ParamSpec("P")
R = TypeVar("R")
def log_calls(func: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
print(f"Вызов {func.__name__}")
return func(*args, **kwargs)
return wrapper
Теперь mypy и Pyright понимают, что декорированная функция сохраняет оригинальную сигнатуру. Это важно в больших кодовых базах, где статический анализ — часть CI/CD.
Итог
Декораторы в Python — это не просто удобный синтаксис. Это полноценный инструмент для разделения ответственности: бизнес-логика функции остаётся чистой, а сквозные задачи (логирование, кэширование, авторизация, повторные попытки) выносятся в переиспользуемые обёртки.
Ключевые вещи, которые стоит держать в голове:
- Декоратор — это функция, возвращающая функцию. Замыкание — механизм, который это обеспечивает.
functools.wraps— не опциональная деталь, а обязательный элемент любого продакшн-декоратора.- Декораторы с аргументами требуют дополнительного уровня вложенности — фабрики декораторов.
- Порядок стекирования декораторов влияет на поведение и требует осознанного выбора.
- Правильная типизация с
ParamSpecделает декораторы совместимыми со статическим анализом.
Если хочется разобраться глубже — в том числе понять, как декораторы вписываются в более широкие паттерны Python-разработки, — курс «Senior Python» охватывает эти темы в контексте реальных задач: от архитектурных решений до тонкостей работы интерпретатора.
Декораторы — один из тех инструментов, которые отличают код, написанный «чтобы работало», от кода, написанного «чтобы поддерживалось».
Комментарии
Пока нет комментариев