Python

Декоратори та Керування життєвим циклом методів

Глибокий розбір декораторів у Python — від @staticmethod і @classmethod до декораторів класів і callable-екземплярів. Як декоратори трансформують методи, зберігають стан між викликами та дозволяють будувати гнучку авторизацію й кешування без зміни основного коду.

Декоратори та Керування життєвим циклом методів

Проблема: як змінити поведінку функції, не змінюючи її код

Уявіть реальну ситуацію. У вашому 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())    # (нічого)
Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #ffffff
skinparam ArrowColor #6366f1

object "obj = Demo()" as OBJ #d1fae5
object "Demo (клас)" as CLS #dbeafe
object "type (метаклас)" as META #fee2e2

OBJ --> CLS : __class__
CLS --> META : __class__

note right of OBJ
  obj.instance_method()
  → передається self=obj
end note

note right of CLS
  Demo.class_method()
  obj.class_method()
  → передається cls=Demo

  Demo.static_method()
  obj.static_method()
  → нічого не передається
end note
@enduml

Що відбувається без декораторів: дескриптори функцій

Щоб зрозуміти різницю між трьома типами методів, корисно знати: звичайна функція у класі є 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}")
python password_validator.py
$ python password_validator.py
Пройшов: False
Помилки: ['Мінімум 8 символів', 'Потрібна хоча б одна велика літера', 'Потрібна хоча б одна цифра', 'Потрібен хоча б один спеціальний символ']
Пройшов: True
Якщо ви пишете статичний метод, але він звертається до атрибутів класу через жорстко задане ім'я (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)
python employee.py
$ python employee.py
Employee(name='Олена Коваль', department='Design', salary=68000.0, start_date=2021-09-01)
Employee(name='Микола Бондар', department='Engineering', salary=95000.0, start_date=2020-01-15)
Employee(name='Аліна Шевченко', department='Marketing', salary=55000.0, start_date=2024-06-18)

Поліморфізм конструкторів: чому 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}")
python simple_decorator.py
$ python simple_decorator.py
[LOG] Викликається 'greet' з args=('Олексій',), kwargs={'greeting': 'Вітаю'}
[LOG] 'greet' завершила виконання
Результат: Вітаю, Олексій!

Коли інтерпретатор бачить @logger над greet, він виконує: greet = logger(greet)

Відтепер ім'я greet посилається на внутрішню функцію wrapper. При виклику greet("Олексій", greeting="Вітаю") ми фактично викликаємо wrapper, який робить логування, викликає оригінальну функцію через func(*args, **kwargs) та повертає результат.


Чому саме wrapper? Розбираємо на гвинтики

Якщо ви вперше бачите декоратори, конструкція «функція всередині функції, яка повертає функцію» може здатися дивною і переускладненою. Навіщо писати цей wrapper?

Давайте розберемо крок за кроком три найголовніших питання:

  1. Чому не можна обійтися без wrapper?
  2. Як працює замикання (closure)?
  3. Чому обов'язкові *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 прочитає цей код?

  1. В момент визначення greet інтерпретатор автоматично запустить bad_logger(greet).
  2. На екрані з'явиться [LOG] Декоруємо функцію greet.
  3. Викличеться func() (тобто greet()), і її результат "Привіт!" запишеться у змінну.
  4. Декоратор поверне рядок "Привіт!".
  5. Змінна 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:

  1. TypeVar (змінна типу) — позначає тип поверненого значення. Назвемо її R (від Return).
  2. 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__}")
python timed_decorator.py
$ python timed_decorator.py
[timed] slow_calculation → 0.0621с
Результат: 333332833333500000
Ім'я функції збережено: slow_calculation
Документація: Симуляція важкого обчислення.

Декоратори з параметрами: фабрика декораторів

Іноді декоратор потребує налаштування. Тоді потрібна фабрика декораторів — функція, що повертає декоратор:

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"))
python retry_decorator.py
$ python retry_decorator.py
[retry] Спроба 1/3 не вдалась: Мережева помилка (спроба 1)
[retry] Спроба 2/3 не вдалась: Мережева помилка (спроба 2)
200 OK: 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}")
python bank_account.py
$ python bank_account.py
INFO: BankAccount.deposit(args=(500.0,), kwargs={})
INFO: BankAccount.deposit → 1500.0
INFO: BankAccount.withdraw(args=(200.0,), kwargs={})
INFO: BankAccount.withdraw → 1300.0
Баланс: 1300.0

Частина 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"))
python singleton.py
$ python singleton.py
[DB] З'єднання встановлено: production.db:5432
# Друге DatabaseConnection() — мовчки повертає існуючий об'єкт
conn1 is conn2: True
[DB] Результат: 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__ показує стан
python rate_limiter.py
$ python rate_limiter.py
Надіслано [1]: Ваше замовлення підтверджено
Надіслано [1]: Товар відправлено
Надіслано [1]: Очікується доставка
Помилка: Перевищено ліміт 3 викликів за 60.0с. Зачекайте 59.9с.
RateLimiter('send_notification', calls=3, period=60.0s, used=3)

Порівняння: функція-декоратор vs клас-декоратор

КритерійФункція-декораторКлас-декоратор
Збереження стануЗамикання (nonlocal)Атрибути self — природніше
Читабельність стануВажко інтроспектуватиrepr() може показати поточний стан
УспадкуванняНеможливеМожна успадкувати та перевизначити поведінку
Складність реалізаціїПростіше для без-стануСкладніше, але чистіше зі станом
@wraps@wraps(func)functools.update_wrapper(self, func)
Використання90% випадківКоли потрібен стан або ієрархія

Частина VIII: Практичний приклад від А до Я — Система авторизації на основі ролей

Постановка задачі

Побудуємо систему авторизації для API сервісу керування користувачами. Вимоги:

  1. Методи класу мають бути захищені декораторами, що перевіряють роль поточного користувача.
  2. Рівні доступу: viewer (читання), editor (читання + редагування), admin (усе).
  3. Авторизація не повинна забруднювати код методів — лише @require_role("admin") над методом.
  4. Клас UserRepository створюється через @classmethod з різних джерел.
  5. Загальна статистика викликів збирається декоратором-класом CallCounter.

Архітектура проекту

auth_system/
  __init__.py
  roles.py       ← ролі та поточний контекст авторизації
  decorators.py  ← @require_role, CallCounter
  repository.py  ← UserRepository з @classmethod та @staticmethod
  main.py        ← демонстрація

Покрокова реалізація

Створення структури проекту

Ініціалізація структури
$ mkdir -p my_auth/auth_system
$ touch my_auth/auth_system/__init__.py my_auth/auth_system/roles.py
$ touch my_auth/auth_system/decorators.py my_auth/auth_system/repository.py
$ touch my_auth/auth_system/main.py
$ cd my_auth

Реалізація файлів

Записуйте файли у такому порядку: roles.pydecorators.pyrepository.py__init__.pymain.py. Порядок важливий: decorators.py імпортує roles.py, repository.py імпортує обидва.

Запуск та перевірка

python -m auth_system.main
$ python -m auth_system.main
=== @classmethod: альтернативні конструктори ===
from_dict_list → UserRepository(3 users)
from_json → UserRepository(1 users)
=== @staticmethod: утилітарні функції ===
✅ validate_email('user@example.com') → True
✅ validate_email('not-an-email') → False
✅ validate_email('a@b.co') → True
normalize_name(' іван петренко ') → 'Іван Петренко'
normalize_name('ОЛЕНА КОВАЛЬ') → 'Олена Коваль'
normalize_name('микола') → 'Микола'
=== @require_role: захист методів ===
[VIEWER] list_users → 3 записів ✅
[VIEWER] create_user → PermissionError ✅
[EDITOR] create_user → Новий Користувач (id=4) ✅
[EDITOR] update_user → email=updated@example.com ✅
[EDITOR] delete_user → PermissionError ✅
[ADMIN] delete_user → True ✅
[ANON] list_users → PermissionError ✅
=== @CallCounter: статистика викликів ===
UserRepository.create_user → 1 виклик(ів)
UserRepository.delete_user → 1 виклик(ів)
UserRepository.list_users → 1 виклик(ів)
UserRepository.update_user → 1 виклик(ів)

Зведена таблиця: що де застосовується

КонцепціяДе в проектіЩо вирішує
@staticmethodvalidate_email, normalize_nameУтиліти без стану — не потребують self або cls
@classmethodfrom_dict_list, from_json, emptyАльтернативні конструктори — cls(...) замість UserRepository(...)
Функція-декоратор з параметрамиrequire_role(Role.ADMIN)Захист методів з конфігурованим мінімальним рівнем доступу
Клас-декораторCallCounterЗбір статистики зі збереженням стану між викликами
@wraps / update_wrapperВ обох декораторахЗбереження __name__, __doc__ оригінальної функції
__get__ у класі-декораторіCallCounter.__get__Коректна передача self при використанні як метод класу
IntEnum + Roleroles.pyПорядкове порівняння ролей: VIEWER < EDITOR < ADMIN
@contextmanagerauth_asТестова заміна авторизованого користувача без глобальних змін

Підсумок

Декоратори — це не магія. За синтаксисом @decorator стоїть проста підстановка: func = decorator(func). Розуміння цього відкриває широкий архітектурний простір.

Ключові висновки:

  • @staticmethod — функція у просторі імен класу без прив'язки до екземпляра чи класу. Для утиліт, що не потребують self.
  • @classmethod — отримує cls замість self. Ідеальний для альтернативних конструкторів: cls(...) поліморфно враховує спадкування.
  • Якщо у @staticmethod ви звертаєтесь до атрибутів класу по жорсткому імені — розгляньте заміну на @classmethod.
  • @functools.wraps(func) є обов'язковим у кожному декораторі — він зберігає метадані оригінальної функції.
  • Клас зі __call__ як декоратор — природне рішення, коли декоратор потребує стану (лічильники, кеш, ліміти). Але потрібен __get__ для коректної роботи як метод класу.
  • Стекування декораторів застосовується знизу вгору, виконується зверху вниз.

Наступна стаття досліджує дескриптори — механізм, що лежить в основі @property, @classmethod та @staticmethod. Ви побачите, що всі ці декоратори — лише зручний синтаксис над протоколом __get__/__set__/__delete__.

Copyright © 2026