Декоратори та Керування життєвим циклом методів
Декоратори та Керування життєвим циклом методів
Проблема: як змінити поведінку функції, не змінюючи її код
Уявіть реальну ситуацію. У вашому API є десятки методів. Ви хочете до кожного з них додати:
- перевірку прав доступу
- логування часу виконання
- кешування результатів
Наївне рішення — вставити відповідний код у початок і кінець кожного методу. Для десяти методів це ще терпимо. Для ста — вже катастрофа: дублювання коду, забуті оновлення, тести для кожного методу окремо.
# ❌ Наївний підхід: дублювання скрізь
class UserService:
def get_user(self, user_id: int):
if not current_user.has_role("admin"): # скрізь
raise PermissionError("Доступ заборонено")
start = time.time() # скрізь
result = db.query(...)
log(f"get_user: {time.time() - start}s") # скрізь
return result
def delete_user(self, user_id: int):
if not current_user.has_role("admin"): # знову
raise PermissionError("Доступ заборонено")
start = time.time() # знову
db.delete(...)
log(f"delete_user: {time.time() - start}s") # знову
Декоратори вирішують цю проблему елегантно: ви описуєте наскрізну логіку один раз і застосовуєте її оголошенням над функцією. Код методу залишається чистим — він робить лише свою справу.
# ✅ З декораторами: логіка відокремлена
class UserService:
@require_role("admin")
@timed
def get_user(self, user_id: int):
return db.query(...)
@require_role("admin")
@timed
def delete_user(self, user_id: int):
db.delete(...)
Але перш ніж писати власні декоратори, Python пропонує три вбудованих, які вирішують фундаментальніше питання: яке відношення метод має до свого класу.
@staticmethod
@classmethod
cls). Ідеальний для альтернативних конструкторів та фабричних методів.Функція-декоратор
Клас-декоратор
__call__. Може зберігати стан між викликами — кеш, лічильники, з'єднання.Частина I: Як Python бачить методи
Три типи методів: instance, class, static
Перш ніж перейти до синтаксису, важливо зрозуміти фундаментальне питання: що саме передається першим аргументом у метод?
У Python виклик obj.method(arg) не є прямим викликом функції. Він проходить через механізм дескрипторів (детально розглядається у статті 7), який перетворює функцію на зв'язаний метод (bound method) — автоматично передає self чи cls.
class Demo:
class_var = "Я атрибут класу"
def instance_method(self):
# self → екземпляр. Доступ до self.attr та type(self)
return f"instance method, self={self}"
@classmethod
def class_method(cls):
# cls → сам клас (не екземпляр). Доступ до cls.attr
return f"class method, cls={cls}"
@staticmethod
def static_method():
# нічого не передається автоматично
return "static method — звичайна функція"
obj = Demo()
# Три різних способи виклику — три різних перших аргументи
print(obj.instance_method()) # self = <Demo object>
print(obj.class_method()) # cls = <class 'Demo'>
print(obj.static_method()) # (нічого)
# Виклик класовий/статичний — через клас і через об'єкт однаковий
print(Demo.class_method()) # cls = <class 'Demo'>
print(Demo.static_method()) # (нічого)
Що відбувається без декораторів: дескриптори функцій
Щоб зрозуміти різницю між трьома типами методів, корисно знати: звичайна функція у класі є non-data дескриптором. Коли ви звертаєтесь до неї через obj.method, Python викликає function.__get__(obj, type(obj)) — і отримує зв'язаний метод з автоматично підставленим self.
class Plain:
def greet(self, name: str) -> str:
return f"Привіт, {name}! Я {self}"
obj = Plain()
# Звернення через клас → незв'язана функція
unbound = Plain.greet
print(type(unbound)) # <class 'function'>
print(unbound(obj, "Олена")) # Привіт, Олена! Я <Plain object>
# Звернення через екземпляр → зв'язаний метод (self підставлений)
bound = obj.greet
print(type(bound)) # <class 'method'>
print(bound("Олена")) # Привіт, Олена! Я <Plain object>
@staticmethod та @classmethod змінюють поведінку цього дескриптора: перший повертає саму функцію без прив'язки, другий — прив'язує до класу замість екземпляра.
Частина II: @staticmethod — функція у просторі імен класу
Коли метод не потребує self і не потребує cls
@staticmethod використовується, коли функція логічно належить до класу (вона пов'язана з його концепцією), але не потребує доступу ні до стану екземпляра, ні до атрибутів класу. По суті — звичайна функція, що «живе» у просторі імен класу для кращої організації.
class PasswordValidator:
"""Валідатор паролів — набір статичних утиліт."""
MIN_LENGTH = 8
SPECIAL_CHARS = "!@#$%^&*"
@staticmethod
def has_uppercase(password: str) -> bool:
return any(c.isupper() for c in password)
@staticmethod
def has_digit(password: str) -> bool:
return any(c.isdigit() for c in password)
@staticmethod
def has_special(password: str) -> bool:
return any(c in PasswordValidator.SPECIAL_CHARS for c in password)
@staticmethod
def is_strong(password: str) -> tuple[bool, list[str]]:
"""
Перевіряє пароль на відповідність усім правилам.
Повертає (passed: bool, errors: list[str]).
"""
errors = []
if len(password) < PasswordValidator.MIN_LENGTH:
errors.append(f"Мінімум {PasswordValidator.MIN_LENGTH} символів")
if not PasswordValidator.has_uppercase(password):
errors.append("Потрібна хоча б одна велика літера")
if not PasswordValidator.has_digit(password):
errors.append("Потрібна хоча б одна цифра")
if not PasswordValidator.has_special(password):
errors.append("Потрібен хоча б один спеціальний символ")
return len(errors) == 0, errors
# Виклик через клас — жодного екземпляра не потрібно
ok, errors = PasswordValidator.is_strong("qwerty")
print(f"Пройшов: {ok}")
print(f"Помилки: {errors}")
ok, errors = PasswordValidator.is_strong("MyP@ssw0rd")
print(f"Пройшов: {ok}")
PasswordValidator.MIN_LENGTH), подумайте: можливо, вам потрібен @classmethod — тоді при успадкуванні підклас отримає свої значення через cls.MIN_LENGTH, а не батьківські.Частина III: @classmethod — альтернативні конструктори
Чому cls потужніший за жорстко задану назву класу
@classmethod отримує сам клас як перший аргумент (cls). Це ключова різниця від @staticmethod: при успадкуванні cls вказуватиме на підклас, а не на батьківський клас. Це робить @classmethod незамінним для поліморфних фабричних методів.
Найпоширеніший і найважливіший патерн використання — альтернативні конструктори: методи, що створюють екземпляр класу з різних вхідних форматів.
from __future__ import annotations
from datetime import date, datetime
class Employee:
"""Працівник компанії."""
def __init__(self, name: str, department: str, salary: float, start_date: date):
self.name = name
self.department = department
self.salary = salary
self.start_date = start_date
def __repr__(self) -> str:
return (
f"Employee(name={self.name!r}, department={self.department!r}, "
f"salary={self.salary}, start_date={self.start_date})"
)
# --- Альтернативний конструктор №1: з рядка CSV ---
@classmethod
def from_csv(cls, csv_line: str) -> Employee:
"""
Створює Employee з CSV-рядка формату:
'Іван Петренко,Engineering,75000,2022-03-15'
"""
parts = csv_line.strip().split(",")
if len(parts) != 4:
raise ValueError(f"Очікується 4 поля, отримано {len(parts)}: {csv_line!r}")
name, department, salary_str, date_str = parts
return cls(
name=name.strip(),
department=department.strip(),
salary=float(salary_str.strip()),
start_date=date.fromisoformat(date_str.strip()),
)
# cls(...) замість Employee(...) → при успадкуванні створить підклас!
# --- Альтернативний конструктор №2: зі словника ---
@classmethod
def from_dict(cls, data: dict) -> Employee:
"""Створює Employee зі словника (наприклад, з JSON-відповіді API)."""
return cls(
name=data["name"],
department=data.get("department", "General"),
salary=float(data["salary"]),
start_date=date.fromisoformat(data["start_date"]),
)
# --- Альтернативний конструктор №3: новий працівник сьогодні ---
@classmethod
def new_hire(cls, name: str, department: str, salary: float) -> Employee:
"""Зручний конструктор для нових працівників з датою початку = сьогодні."""
return cls(name=name, department=department, salary=salary, start_date=date.today())
# --- Фабричний метод ---
@classmethod
def from_any(cls, source) -> Employee:
"""Універсальний метод: визначає формат автоматично."""
if isinstance(source, str):
return cls.from_csv(source)
elif isinstance(source, dict):
return cls.from_dict(source)
raise TypeError(f"Непідтримуваний тип джерела: {type(source)}")
# Три різних способи створення Employee
e1 = Employee.from_csv("Олена Коваль,Design,68000,2021-09-01")
e2 = Employee.from_dict({
"name": "Микола Бондар",
"department": "Engineering",
"salary": "95000",
"start_date": "2020-01-15",
})
e3 = Employee.new_hire("Аліна Шевченко", "Marketing", 55000)
print(e1)
print(e2)
print(e3)
Поліморфізм конструкторів: чому cls, а не Employee
Ключова перевага cls над жорстко заданою назвою класу проявляється при успадкуванні:
class Manager(Employee):
"""Менеджер — розширений Employee з додатковим атрибутом."""
def __init__(self, name, department, salary, start_date, team_size: int = 0):
super().__init__(name, department, salary, start_date)
self.team_size = team_size
def __repr__(self) -> str:
return (
f"Manager(name={self.name!r}, department={self.department!r}, "
f"salary={self.salary}, team_size={self.team_size})"
)
# from_csv визначено у Employee, але cls тут = Manager
mgr = Manager.from_csv("Тарас Гончар,Engineering,120000,2018-05-20")
print(type(mgr)) # <class 'Manager'> ← правильно!
print(mgr) # Manager(name='Тарас Гончар', ...)
# Якби у from_csv було Employee(...) замість cls(...):
# print(type(mgr)) # <class 'Employee'> ← неправильно!
@classmethod: метод from_csv написаний один раз у Employee, але знає, який клас створювати, — той, через який його викликали. cls динамічно прив'язується до конкретного класу в момент виклику.Частина IV: Функції-декоратори
Механіка декораторів: синтаксичний цукор
Декоратор у Python — це callable, що приймає функцію і повертає функцію. Синтаксис @decorator — це лише скорочення:
@decorator
def func():
pass
# Еквівалентно:
def func():
pass
func = decorator(func)
Тобто після оголошення func Python одразу передає її об'єкт у decorator і замінює ім'я func на результат.
Розглянемо спочатку найпростіший базовий приклад декоратора, який просто логує виклик функції:
def logger(func):
def wrapper(*args, **kwargs):
print(f"[LOG] Викликається '{func.__name__}' з args={args}, kwargs={kwargs}")
result = func(*args, **kwargs)
print(f"[LOG] '{func.__name__}' завершила виконання")
return result
return wrapper
@logger
def greet(name: str, greeting: str = "Привіт") -> str:
return f"{greeting}, {name}!"
# Виклик декорованої функції
res = greet("Олексій", greeting="Вітаю")
print(f"Результат: {res}")
Коли інтерпретатор бачить @logger над greet, він виконує:
greet = logger(greet)
Відтепер ім'я greet посилається на внутрішню функцію wrapper. При виклику greet("Олексій", greeting="Вітаю") ми фактично викликаємо wrapper, який робить логування, викликає оригінальну функцію через func(*args, **kwargs) та повертає результат.
Чому саме wrapper? Розбираємо на гвинтики
Якщо ви вперше бачите декоратори, конструкція «функція всередині функції, яка повертає функцію» може здатися дивною і переускладненою. Навіщо писати цей wrapper?
Давайте розберемо крок за кроком три найголовніших питання:
- Чому не можна обійтися без
wrapper? - Як працює замикання (closure)?
- Чому обов'язкові
*args,**kwargsтаreturn?
1. Декорування vs Виклик (Різниця в часі виконання)
Головне непорозуміння з декораторами: функція-декоратор (наприклад, logger) викликається лише один раз — під час імпорту/завантаження скрипта.
Уявімо, що ми спробували написати декоратор без wrapper:
# ❌ НЕПРАВИЛЬНО: без wrapper
def bad_logger(func):
print(f"[LOG] Декоруємо функцію {func.__name__}")
# Ми викликаємо функцію прямо тут:
result = func()
# Що повертати? Якщо ми хочемо замінити оригінальну функцію,
# нам треба повернути щось викликане (callable).
# Але в нас є лише результат виконання (наприклад, рядок).
return result
@bad_logger
def greet():
return "Привіт!"
Що відбудеться, коли Python прочитає цей код?
- В момент визначення
greetінтерпретатор автоматично запуститьbad_logger(greet). - На екрані з'явиться
[LOG] Декоруємо функцію greet. - Викличеться
func()(тобтоgreet()), і її результат"Привіт!"запишеться у змінну. - Декоратор поверне рядок
"Привіт!". - Змінна
greetтепер зберігає не функцію, а рядок"Привіт!".
Якщо ми потім спробуємо викликати greet():
greet() # ❌ TypeError: 'str' object is not callable!
Висновок: Декоратор має повернути нову функцію-замінник. Цю функцію-замінник ми і називаємо wrapper (обгортка). Вона не виконується в момент декорування — вона просто чекає, коли користувач вирішить викликати greet().
2. Як wrapper «пам'ятає» оригінальну функцію? (Замикання)
Коли logger повертає wrapper, функція logger завершує свою роботу. Локальна змінна func (яка зберігає оригінальну функцію) мала б видалитися з пам'яті.
Але завдяки механізму замикання (closure) у Python, внутрішня функція wrapper «захоплює» і «заморожує» у своєму оточенні всі змінні із зовнішньої функції, які вона використовує. Зокрема, вона назавжди запам'ятовує посилання на оригінальну func.
Ви можете це перевірити за допомогою атрибута __closure__:
# greet — це вже wrapper, який повернув logger
print(greet.__closure__) # Поверне кортеж з клітинками пам'яті
# У першій клітинці зберігається посилання на оригінальну greet
print(greet.__closure__[0].cell_contents)
3. Навіщо потрібні *args, **kwargs та return?
wrapper має бути універсальним «дублером». Він не знає заздалегідь, яку саме функцію він буде обгортати:
greet(name)приймає один аргумент.sum_three_numbers(a, b, c)приймає три аргументи.fetch_data()взагалі не приймає аргументів.
Якщо ми напишемо def wrapper(): без параметрів, то наш декоратор зможе працювати лише з функціями без аргументів. Будь-який виклик на кшталт greet("Олексій") викличе TypeError, оскільки wrapper не очікує параметрів.
Саме тому ми пишемо def wrapper(*args, **kwargs)::
*argsзбирає всі позиційні аргументи в кортеж (tuple).**kwargsзбирає всі іменовані аргументи в словник (dict).func(*args, **kwargs)розпаковує їх назад та передає оригінальній функції.
А return result потрібен для того, щоб передати результат оригінальної функції назад тому, хто її викликав. Без return наша обгортка завжди повертала б None.
Ще більше прикладів «розжованих» декораторів
Приклад 1: Декоратор подвоєння результату (double)
Цей декоратор змінює результат математичних функцій, множачи його на 2.
from functools import wraps
def double(func):
@wraps(func)
def wrapper(*args, **kwargs):
# 1. Отримуємо оригінальний результат від функції
original_result = func(*args, **kwargs)
# 2. Модифікуємо його
new_result = original_result * 2
# 3. Повертаємо змінене значення
return new_result
return wrapper
@double
def add(a: int, b: int) -> int:
return a + b
print(add(2, 3)) # Очікуємо 5, але отримаємо 10!
Приклад 2: Декоратор перевірки типів (require_strings)
Цей декоратор перевіряє, чи є всі передані аргументи рядками. Якщо ні — викидає помилку.
from functools import wraps
def require_strings(func):
@wraps(func)
def wrapper(*args, **kwargs):
# Перевіряємо всі позиційні аргументи
for arg in args:
if not isinstance(arg, str):
raise TypeError(f"Аргумент {arg} має бути рядком (str)!")
# Перевіряємо всі іменовані аргументи
for key, value in kwargs.items():
if not isinstance(value, str):
raise TypeError(f"Аргумент {key}={value} має бути рядком (str)!")
# Якщо все ок, викликаємо функцію
return func(*args, **kwargs)
return wrapper
@require_strings
def concat_words(a: str, b: str) -> str:
return a + b
print(concat_words("Привіт, ", "Світ")) # Працює: "Привіт, Світ"
# concat_words("Привіт, ", 42) # Викине TypeError: Аргумент 42 має бути рядком (str)!
Приклад 3: Декоратор кешування результатів (memoize)
Цей декоратор зберігає результати обчислень у словнику. Якщо функція викликається з тими самими аргументами знову, вона не рахує заново, а одразу віддає значення з кешу.
from functools import wraps
def memoize(func):
cache = {} # Словник для збереження результатів (живе в замиканні)
@wraps(func)
def wrapper(*args, **kwargs):
# Створюємо ключ для кешу на основі аргументів
# Оскільки kwargs може бути невпорядкованим, перетворюємо його на кортеж пар
key = (args, tuple(sorted(kwargs.items())))
if key not in cache:
print(f"[CACHE] Обчислюємо результат для аргументів: {args} {kwargs}")
cache[key] = func(*args, **kwargs)
else:
print(f"[CACHE] Беремо готове значення з кешу для: {args} {kwargs}")
return cache[key]
return wrapper
@memoize
def heavy_calculation(x: int) -> int:
return x * x * x
print(heavy_calculation(5)) # Перший раз: обчислить
print(heavy_calculation(5)) # Другий раз: візьме з кешу
Збереження метаданих: @functools.wraps
Оскільки декоратор замінює оригінальну функцію на wrapper, виникає проблема: втрачаються метадані оригінальної функції (її назва __name__, документація __doc__ тощо).
print(greet.__name__) # Виведе "wrapper" замість "greet"!
print(greet.__doc__) # Виведе None
Щоб цього уникнути, Python надає вбудований декоратор @functools.wraps (який сам є декоратором для нашого wrapper):
from functools import wraps
def logger(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"[LOG] Виклик {func.__name__}")
return func(*args, **kwargs)
return wrapper
@functools.wraps(func) у декораторах. Без нього обгортка wrapper замінює оригінальну функцію повністю — включно з __name__, __doc__, __annotations__. Це ламає інтроспекцію, логування, документацію та системи тестування (pytest, наприклад, показуватиме wrapper замість назви тесту).Статична типізація декораторів: Generics (ParamSpec та TypeVar)
Якщо типізувати декоратор просто як Callable (наприклад, def timed(func: Callable) -> Callable:), статичні аналізатори (MyPy, Pyright) та сучасні IDE втратять інформацію про сигнатуру оригінальної функції. Для них декорована функція перетвориться на абстрактний Callable з невідомими аргументами та типом повернення.
Щоб зберегти типи параметрів і поверненого значення, використовуються дженеріки (generics) з модуля typing:
TypeVar(змінна типу) — позначає тип поверненого значення. Назвемо їїR(від Return).ParamSpec(специфікація параметрів, додана в Python 3.10) — фіксує всі параметри оригінальної функції (їх імена, типи, порядок, обов'язковість). Назвемо їїP(від Parameters).
Що саме можна описати за допомогою Generics?
Завдяки дженерікам ми можемо чітко задекларувати зв'язок між вхідною функцією та результатом декоратора:
Callable[P, R]описує функцію, яка приймає довільні параметриPі повертає значення типуR.*args: P.argsта**kwargs: P.kwargsвказують, щоwrapperприймає рівно ті самі позиційні та іменовані аргументи, що й оригінальна функція.- Повернення
Rзwrapperгарантує, що тип результату не зміниться після декорування.
Тепер подивимося на правильну типізацію нашого декоратора timed:
import time
from functools import wraps
from typing import Callable, TypeVar, ParamSpec
P = ParamSpec('P')
R = TypeVar('R')
def timed(func: Callable[P, R]) -> Callable[P, R]:
"""Декоратор: вимірює та виводить час виконання функції."""
@wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
start = time.perf_counter()
try:
result = func(*args, **kwargs)
return result
finally:
elapsed = time.perf_counter() - start
print(f"[timed] {func.__qualname__} → {elapsed:.4f}с")
return wrapper
@timed
def slow_calculation(n: int) -> int:
"""Симуляція важкого обчислення."""
total = 0
for i in range(n):
total += i * i
return total
result = slow_calculation(1_000_000)
print(f"Результат: {result}")
print(f"Ім'я функції збережено: {slow_calculation.__name__}")
print(f"Документація: {slow_calculation.__doc__}")
Декоратори з параметрами: фабрика декораторів
Іноді декоратор потребує налаштування. Тоді потрібна фабрика декораторів — функція, що повертає декоратор:
from functools import wraps
def retry(max_attempts: int = 3, exceptions: tuple = (Exception,), delay: float = 0.0):
"""
Декоратор з параметрами: повторює виклик при виключенні.
@retry(max_attempts=3, exceptions=(ConnectionError, TimeoutError))
def fetch_data(url: str): ...
Три рівні вкладеності:
retry(3) → повертає декоратор
декоратор(func) → повертає wrapper
wrapper(...) → виконує логіку
"""
def decorator(func: Callable) -> Callable:
@wraps(func)
def wrapper(*args, **kwargs):
last_error: Exception | None = None
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except exceptions as e:
last_error = e
print(f"[retry] Спроба {attempt}/{max_attempts} не вдалась: {e}")
if attempt < max_attempts and delay > 0:
import time; time.sleep(delay)
raise RuntimeError(
f"Всі {max_attempts} спроб вичерпано"
) from last_error
return wrapper
return decorator
# Симуляція нестабільного мережевого виклику
_call_count = 0
@retry(max_attempts=3, exceptions=(ConnectionError,))
def unstable_request(url: str) -> str:
global _call_count
_call_count += 1
if _call_count < 3:
raise ConnectionError(f"Мережева помилка (спроба {_call_count})")
return f"200 OK: {url}"
print(unstable_request("https://api.example.com/data"))
Стекування декораторів: порядок застосування
Декоратори можна стекувати. Важливо розуміти порядок: застосовуються знизу вгору, виконуються зверху вниз:
@decorator_A # застосовується другим: A(B(func))
@decorator_B # застосовується першим: B(func)
def func(): ...
# Еквівалентно:
func = decorator_A(decorator_B(func))
# При виклику func():
# → wrapper_A() запускається першим
# → wrapper_B() запускається другим
# → оригінальний func()
@timed
@retry(max_attempts=2, exceptions=(ValueError,))
def parse_number(text: str) -> int:
"""Парсить число з рядка — може впасти при невалідному вводі."""
if not text.strip().lstrip('-').isdigit():
raise ValueError(f"Не число: {text!r}")
return int(text)
# Порядок виконання: timed → retry → parse_number
# timed вимірює загальний час, включно з повторними спробами
print(parse_number("42"))
Частина V: Декоратори методів класу
Особливість декорування методів: передача self
Декоратор, написаний для звичайних функцій, здебільшого вже працює з методами класу — *args першим аргументом автоматично захопить self. Але є нюанс: якщо декоратор потребує доступу до екземпляра чи класу — потрібно передавати його явно.
from functools import wraps
import logging
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger(__name__)
def log_call(func: Callable) -> Callable:
"""
Декоратор для методів класу: логує виклик з ім'ям класу, методу та аргументами.
self передається через *args[0].
"""
@wraps(func)
def wrapper(self, *args, **kwargs):
class_name = type(self).__name__
logger.info(
f"{class_name}.{func.__name__}("
f"args={args!r}, kwargs={kwargs!r})"
)
result = func(self, *args, **kwargs)
logger.info(f"{class_name}.{func.__name__} → {result!r}")
return result
return wrapper
class BankAccount:
"""Банківський рахунок із логуванням операцій."""
def __init__(self, owner: str, balance: float = 0.0):
self.owner = owner
self._balance = balance
@log_call
def deposit(self, amount: float) -> float:
if amount <= 0:
raise ValueError("Сума поповнення має бути > 0")
self._balance += amount
return self._balance
@log_call
def withdraw(self, amount: float) -> float:
if amount > self._balance:
raise ValueError(f"Недостатньо коштів: маємо {self._balance}, потрібно {amount}")
self._balance -= amount
return self._balance
@property
def balance(self) -> float:
return self._balance
account = BankAccount("Олена", 1000.0)
account.deposit(500.0)
account.withdraw(200.0)
print(f"Баланс: {account.balance}")
Частина VI: Декоратори класів
Клас як ціль декоратора
До цього моменту ми декорували функції та методи. Але @decorator можна застосувати і до цілого класу. У цьому випадку декоратор отримує об'єкт класу і може модифікувати його атрибути, методи або замінити клас повністю.
def singleton(cls):
"""
Декоратор класу: перетворює клас на Singleton.
Перший виклик cls() створює екземпляр і зберігає його.
Всі наступні виклики повертають той самий об'єкт.
"""
instances: dict = {}
@wraps(cls)
def get_instance(*args, **kwargs):
if cls not in instances:
instances[cls] = cls(*args, **kwargs)
return instances[cls]
return get_instance
@singleton
class DatabaseConnection:
"""З'єднання з базою даних — має існувати лише в одному екземплярі."""
def __init__(self, host: str = "localhost", port: int = 5432):
self.host = host
self.port = port
print(f"[DB] З'єднання встановлено: {self.host}:{self.port}")
def query(self, sql: str) -> str:
return f"[DB] Результат: {sql}"
# Перший виклик — створює з'єднання
conn1 = DatabaseConnection("production.db", 5432)
# Другий виклик — повертає той самий об'єкт (з'єднання не встановлюється знову)
conn2 = DatabaseConnection()
print(f"conn1 is conn2: {conn1 is conn2}") # True
print(conn1.query("SELECT 1"))
Декоратор класу для автоматичного додавання методів
Декоратор класу може автоматично доповнювати клас методами при оголошенні — без успадкування і без метакласів:
def add_repr(cls):
"""
Декоратор класу: автоматично генерує __repr__ на основі
анотацій типів (type hints) класу.
Не перезаписує __repr__ якщо він вже визначений явно.
"""
if '__repr__' not in cls.__dict__: # тільки якщо не визначено явно
def auto_repr(self) -> str:
attrs = {
name: getattr(self, name)
for name in cls.__annotations__
if not name.startswith('_') and hasattr(self, name)
}
params = ', '.join(f"{k}={v!r}" for k, v in attrs.items())
return f"{cls.__name__}({params})"
cls.__repr__ = auto_repr
return cls
def add_eq(cls):
"""
Декоратор класу: генерує __eq__ та __hash__ на основі
анотацій типів.
"""
if '__eq__' not in cls.__dict__:
def auto_eq(self, other: object) -> bool:
if type(self) is not type(other):
return NotImplemented
return all(
getattr(self, name) == getattr(other, name)
for name in cls.__annotations__
if not name.startswith('_')
)
cls.__eq__ = auto_eq
cls.__hash__ = None # після __eq__ клас стає нехешованим
return cls
@add_repr
@add_eq
class Point:
x: float
y: float
def __init__(self, x: float, y: float):
self.x = x
self.y = y
p1 = Point(1.0, 2.0)
p2 = Point(1.0, 2.0)
p3 = Point(3.0, 4.0)
print(p1) # Point(x=1.0, y=2.0) ← auto_repr
print(p1 == p2) # True ← auto_eq
print(p1 == p3) # False
Частина VII: Класи як декоратори
__call__ + стан = потужний декоратор
Коли декоратор потребує збереження стану між викликами (лічильник, кеш, час останнього виклику), клас зі __call__ є природнішим рішенням, ніж функція з замиканням:
from functools import wraps, update_wrapper
import time
class RateLimiter:
"""
Декоратор-клас: обмежує частоту виклику функції.
@RateLimiter(calls=3, period=10.0)
def expensive_api_call(query: str): ...
Якщо за останні `period` секунд функцію викликали вже `calls` разів —
підіймає RuntimeError замість виконання.
"""
def __init__(self, calls: int = 5, period: float = 60.0):
self.max_calls = calls
self.period = period
self._call_times: list[float] = []
self._func: Callable | None = None
def __call__(self, *args, **kwargs):
if self._func is None:
# Перший виклик: отримуємо декоровану функцію
func = args[0]
self._func = func
update_wrapper(self, func)
return self
# Наступні виклики: виконуємо з rate limiting
now = time.monotonic()
# Видаляємо старі записи за межами вікна
self._call_times = [t for t in self._call_times if now - t < self.period]
if len(self._call_times) >= self.max_calls:
oldest = self._call_times[0]
wait_time = self.period - (now - oldest)
raise RuntimeError(
f"Перевищено ліміт {self.max_calls} викликів за "
f"{self.period}с. Зачекайте {wait_time:.1f}с."
)
self._call_times.append(now)
return self._func(*args, **kwargs)
def __repr__(self) -> str:
name = self._func.__name__ if self._func else "?"
return (
f"RateLimiter({name!r}, "
f"calls={self.max_calls}, period={self.period}s, "
f"used={len(self._call_times)})"
)
@RateLimiter(calls=3, period=60.0)
def send_notification(user_id: int, message: str) -> str:
"""Надсилає push-сповіщення користувачу."""
return f"Надіслано [{user_id}]: {message}"
# Перші три виклики — успішні
print(send_notification(1, "Ваше замовлення підтверджено"))
print(send_notification(1, "Товар відправлено"))
print(send_notification(1, "Очікується доставка"))
# Четвертий виклик — перевищено ліміт
try:
send_notification(1, "Ще одне повідомлення")
except RuntimeError as e:
print(f"Помилка: {e}")
print(send_notification) # __repr__ показує стан
Порівняння: функція-декоратор vs клас-декоратор
| Критерій | Функція-декоратор | Клас-декоратор |
|---|---|---|
| Збереження стану | Замикання (nonlocal) | Атрибути self — природніше |
| Читабельність стану | Важко інтроспектувати | repr() може показати поточний стан |
| Успадкування | Неможливе | Можна успадкувати та перевизначити поведінку |
| Складність реалізації | Простіше для без-стану | Складніше, але чистіше зі станом |
@wraps | @wraps(func) | functools.update_wrapper(self, func) |
| Використання | 90% випадків | Коли потрібен стан або ієрархія |
Частина VIII: Практичний приклад від А до Я — Система авторизації на основі ролей
Постановка задачі
Побудуємо систему авторизації для API сервісу керування користувачами. Вимоги:
- Методи класу мають бути захищені декораторами, що перевіряють роль поточного користувача.
- Рівні доступу:
viewer(читання),editor(читання + редагування),admin(усе). - Авторизація не повинна забруднювати код методів — лише
@require_role("admin")над методом. - Клас
UserRepositoryстворюється через@classmethodз різних джерел. - Загальна статистика викликів збирається декоратором-класом
CallCounter.
Архітектура проекту
auth_system/
__init__.py
roles.py ← ролі та поточний контекст авторизації
decorators.py ← @require_role, CallCounter
repository.py ← UserRepository з @classmethod та @staticmethod
main.py ← демонстрація
Покрокова реалізація
Створення структури проекту
Реалізація файлів
Записуйте файли у такому порядку: roles.py → decorators.py → repository.py → __init__.py → main.py. Порядок важливий: decorators.py імпортує roles.py, repository.py імпортує обидва.
Запуск та перевірка
Зведена таблиця: що де застосовується
| Концепція | Де в проекті | Що вирішує |
|---|---|---|
@staticmethod | validate_email, normalize_name | Утиліти без стану — не потребують self або cls |
@classmethod | from_dict_list, from_json, empty | Альтернативні конструктори — cls(...) замість UserRepository(...) |
| Функція-декоратор з параметрами | require_role(Role.ADMIN) | Захист методів з конфігурованим мінімальним рівнем доступу |
| Клас-декоратор | CallCounter | Збір статистики зі збереженням стану між викликами |
@wraps / update_wrapper | В обох декораторах | Збереження __name__, __doc__ оригінальної функції |
__get__ у класі-декораторі | CallCounter.__get__ | Коректна передача self при використанні як метод класу |
IntEnum + Role | roles.py | Порядкове порівняння ролей: VIEWER < EDITOR < ADMIN |
@contextmanager | auth_as | Тестова заміна авторизованого користувача без глобальних змін |
Підсумок
Декоратори — це не магія. За синтаксисом @decorator стоїть проста підстановка: func = decorator(func). Розуміння цього відкриває широкий архітектурний простір.
Ключові висновки:
@staticmethod— функція у просторі імен класу без прив'язки до екземпляра чи класу. Для утиліт, що не потребуютьself.@classmethod— отримуєclsзамістьself. Ідеальний для альтернативних конструкторів:cls(...)поліморфно враховує спадкування.- Якщо у
@staticmethodви звертаєтесь до атрибутів класу по жорсткому імені — розгляньте заміну на@classmethod. @functools.wraps(func)є обов'язковим у кожному декораторі — він зберігає метадані оригінальної функції.- Клас зі
__call__як декоратор — природне рішення, коли декоратор потребує стану (лічильники, кеш, ліміти). Але потрібен__get__для коректної роботи як метод класу. - Стекування декораторів застосовується знизу вгору, виконується зверху вниз.
Наступна стаття досліджує дескриптори — механізм, що лежить в основі @property, @classmethod та @staticmethod. Ви побачите, що всі ці декоратори — лише зручний синтаксис над протоколом __get__/__set__/__delete__.
Магічні методи (Dunder) та Емуляція протоколів
Глибоке дослідження системи dunder-методів Python — від рядкового представлення та арифметики до контейнерних протоколів і контекстних менеджерів. Розуміння того, як Python реалізує оператори та вбудовані функції через спеціальні методи об'єктів.
Дескриптори — Магія доступу до атрибутів
Глибоке дослідження протоколу дескрипторів Python — методів __get__, __set__, __delete__ та __set_name__. Алгоритм пошуку атрибутів, різниця між data та non-data дескрипторами, а також практичні сценарії використання — від валідації полів до реалізації ORM і ледачого обчислення.