Продвинутые декораторы: параметризованные, class-based, classmethod
В прошлом уроке разобрали базовые декораторы - функция оборачивает функцию. В этом уроке - сложнее: декораторы с параметрами, реализованные через классы, и встроенные декораторы для методов классов. Эти приёмы лежат в основе фреймворков типа FastAPI, Click и SQLAlchemy.
Параметризованные декораторы
Что если декоратор должен принимать настройки? Например, retry с произвольным числом попыток:
@retry(times=3, delay=1)
def fetch():
...
retry(times=3, delay=1) это не сам декоратор, а функция, возвращающая декоратор. Поэтому нужен дополнительный уровень вложенности:
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:
if attempt == times - 1:
raise
time.sleep(delay)
return wrapper
return decorator
Структура - три уровня:
retry(...)- принимает параметры, возвращает декораторdecorator(func)- сам декоратор, принимает функцию, возвращает wrapperwrapper(*args, **kwargs)- то, что заменит оригинальную функцию
Эквивалент без @:
decorator = retry(times=3, delay=1)
fetch = decorator(fetch)
Декоратор, который и с параметрами, и без
Иногда хочется поддерживать оба синтаксиса:
@logged # без параметров
@logged() # с пустыми параметрами
@logged(level="DEBUG") # с параметрами
def f(): ...
Это решается через functools.wraps и проверку типа:
def logged(func=None, *, level="INFO"):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"[{level}] Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
if func is None:
return decorator # вызвано с параметрами: @logged(level="DEBUG")
return decorator(func) # без параметров: @logged
Используя trick: первый параметр default None, остальные - keyword-only.
Class-based декораторы
Декоратор может быть классом, реализующим __call__:
from functools import wraps
class Timer:
def __init__(self, func):
wraps(func)(self)
self.func = func
self.calls = 0
self.total_time = 0
def __call__(self, *args, **kwargs):
import time
start = time.perf_counter()
result = self.func(*args, **kwargs)
elapsed = time.perf_counter() - start
self.calls += 1
self.total_time += elapsed
return result
def stats(self):
avg = self.total_time / self.calls if self.calls else 0
return f"calls={self.calls}, avg={avg:.4f}s"
@Timer
def compute(x):
return x ** 2
compute(5)
compute(10)
print(compute.stats()) # calls=2, avg=...
Преимущества:
- Декоратор может хранить состояние в атрибутах инстанса
- Дополнительные методы доступны через декорированную функцию
- Более структурно для сложных декораторов
Недостатки:
- Сложнее с методами класса (нужна особая работа с self)
- Более многословно для простых случаев
Class-based с параметрами
class Retry:
def __init__(self, times=3, delay=1):
self.times = times
self.delay = delay
def __call__(self, func):
@wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(self.times):
try:
return func(*args, **kwargs)
except Exception:
if attempt == self.times - 1:
raise
time.sleep(self.delay)
return wrapper
@Retry(times=5, delay=2)
def fetch():
...
@property
Превращает метод в свойство, доступ как к атрибуту:
class Circle:
def __init__(self, radius):
self.radius = radius
@property
def area(self):
return 3.14159 * self.radius ** 2
c = Circle(5)
print(c.area) # 78.539... - вызов как к атрибуту, без скобок
С setter:
class Temperature:
def __init__(self, celsius):
self._celsius = celsius
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError("Ниже абсолютного нуля")
self._celsius = value
@property
def fahrenheit(self):
return self._celsius * 9 / 5 + 32
t = Temperature(25)
print(t.fahrenheit) # 77.0
t.celsius = 30 # вызывается setter с валидацией
t.celsius = -300 # ValueError
Это даёт encapsulation с удобным синтаксисом: пользователь обращается как к атрибуту, но за кадром есть логика. Полный разбор property, classmethod и staticmethod - в уроке про атрибуты и методы.
Не злоупотребляй: для простых атрибутов property избыточен. Используй когда есть реальная выгода.
@staticmethod и @classmethod
class Date:
def __init__(self, year, month, day):
self.year = year
self.month = month
self.day = day
@classmethod
def today(cls):
import datetime
now = datetime.datetime.now()
return cls(now.year, now.month, now.day)
@classmethod
def from_string(cls, s):
year, month, day = map(int, s.split("-"))
return cls(year, month, day)
@staticmethod
def is_leap_year(year):
return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
d1 = Date.today()
d2 = Date.from_string("2026-05-27")
print(Date.is_leap_year(2024)) # True
| Декоратор | Получает | Использование |
|---|---|---|
@staticmethod | Ничего особенного | Утилитарная функция, логически в namespace класса |
@classmethod | Класс как cls | Alternative constructors, factory methods |
| Обычный метод | Инстанс как self | Стандартный метод |
@classmethod особенно хорош для factory methods - создание объекта альтернативным способом. И поддерживает inheritance:
class SpecialDate(Date):
pass
d = SpecialDate.today() # вернёт SpecialDate, а не Date
print(type(d)) # SpecialDate
cls в classmethod - это фактический класс из которого вызывают, не Date.
Декорирование классов целиком
def add_str(cls):
def __str__(self):
attrs = ", ".join(f"{k}={v}" for k, v in self.__dict__.items())
return f"{cls.__name__}({attrs})"
cls.__str__ = __str__
return cls
@add_str
class User:
def __init__(self, name, age):
self.name = name
self.age = age
print(User("Alice", 30)) # User(name=Alice, age=30)
Это паттерн используется в @dataclass (модуль 6), @attrs.define и подобных.
@dataclass - встроенный класс-декоратор
from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float
p = Point(1.0, 2.0)
print(p) # Point(x=1.0, y=2.0)
print(p == Point(1.0, 2.0)) # True
@dataclass автоматически генерирует __init__, __repr__, __eq__ на основе аннотаций. Подробно про него в уроке про dataclass и Enum.
Декораторы с side effects при импорте
ROUTES = {}
def route(path):
def decorator(func):
ROUTES[path] = func
return func
return decorator
@route("/users")
def get_users():
return [...]
@route("/posts")
def get_posts():
return [...]
Декоратор регистрирует функцию в глобальном словаре ROUTES. После импорта модуля все маршруты автоматически собраны. Так работают Flask, FastAPI и многие другие фреймворки.
Альтернатива - явная регистрация: app.include_router(routes). Чуть многословнее, но предсказуемо.
Полезные декораторы из functools
| Декоратор | Что делает |
|---|---|
@functools.wraps(func) | Сохраняет метаданные декорируемой функции |
@functools.lru_cache(maxsize=128) | Мемоизация с LRU |
@functools.cache (3.9+) | Простая мемоизация без вытеснения |
@functools.cached_property | Свойство, кеширующее результат на инстансе |
@functools.total_ordering | Генерирует все <, <=, >, >= из __eq__ и одного из них |
@functools.singledispatch | Polymorphism по типу первого аргумента |
Распространённые ошибки
1. Параметризованный декоратор без скобок
@retry # ОШИБКА - не вызвали retry()
def fetch(): ...
# должно быть
@retry(times=3)
def fetch(): ...
Декоратор с параметрами требует обязательного вызова.
2. Property без setter
class A:
@property
def x(self):
return self._x
a = A()
a.x = 5 # AttributeError - property без setter read-only
Если хочется записывать, нужен @x.setter.
3. Mutable state в class-based декораторе
@Timer
def f(): ...
@Timer
def g(): ...
# f.calls и g.calls - НЕЗАВИСИМЫЕ, потому что каждый декоратор - свой инстанс
Это правильно. Если бы Timer хранил calls как class-attribute, было бы shared - тогда f и g делили бы счётчик.
Сравнение с Go и PHP
В Go нет декораторов как языковой конструкции, но похожее достигается через HOF:
func Logged(f func(int) int) func(int) int {
return func(x int) int {
log.Printf("calling with %d", x)
return f(x)
}
}
square = Logged(square)
В PHP до 8.0 декораторов не было, добавили Attributes (PHP 8) для метаданных, но они не модифицируют функцию автоматически - нужен runtime-reader.
Python декораторы исторически и идиоматически очень развиты. Многие фреймворки построены на них.
Мини-задание
- Параметризованный декоратор для лимита вызовов:
from functools import wraps
def max_calls(limit):
def decorator(func):
calls = 0
@wraps(func)
def wrapper(*args, **kwargs):
nonlocal calls
if calls >= limit:
raise RuntimeError(f"Превышен лимит {limit} вызовов")
calls += 1
return func(*args, **kwargs)
return wrapper
return decorator
@max_calls(3)
def greet(name):
return f"Hello, {name}"
greet("A") # OK
greet("B") # OK
greet("C") # OK
greet("D") # RuntimeError
- Class-based декоратор с stats:
from functools import wraps
class CallCounter:
def __init__(self, func):
wraps(func)(self)
self.func = func
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
return self.func(*args, **kwargs)
@CallCounter
def add(a, b):
return a + b
add(1, 2)
add(3, 4)
print(add.count) # 2
- Property с валидацией:
class Temperature:
def __init__(self, celsius):
self.celsius = celsius # вызовет setter
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError("Ниже абсолютного нуля")
self._celsius = value
@property
def fahrenheit(self):
return self._celsius * 9 / 5 + 32
t = Temperature(25)
print(t.fahrenheit) # 77.0
t.celsius = -300 # ValueError
Что дальше
Модуль 4 завершён. Освоили функции на всех уровнях: базовое определение, гибкие параметры, closures, лямбды, декораторы простые и продвинутые. В следующем модуле перейдём к коллекциям: списки, кортежи, словари, множества и инструменты collections.