Python · Синтаксис · Просунутий рівень

Декоратори

10 завдань

Функції, що обгортають інші функції для зміни або розширення їх поведінки. Покриває синтаксис `@`, декоратори з аргументами, стекування та `functools.wraps`.

Навіщо декоратори, замикання, анатомія та functools.wraps

#
**Навіщо існують декоратори** Уявіть що потрібно логувати кожен виклик функції або вимірювати скільки вона виконується. Без декораторів довелося б копіювати цей код у кожну функцію. Декоратори дозволяють написати логіку один раз і прикріпити її будь-де: ```python # Без декораторів — потрібно повторювати скрізь: def compute(n): start = time.perf_counter() result = sum(range(n)) print(f'took {time.perf_counter() - start:.4f}s') return result # З декоратором — логіка таймінгу в одному місці: @timer def compute(n): return sum(range(n)) ``` Це називається *наскрізна турбота* — поведінка що стосується багатьох функцій але не є частиною роботи жодної з них. Декоратори — відповідь Python на це. **Що насправді робить `@decorator`** Синтаксис `@` — чистий скорочений запис. Ці два варіанти ідентичні: ```python @timer def greet(name): return f'Hello, {name}!' # Те саме що: def greet(name): return f'Hello, {name}!' greet = timer(greet) # greet тепер wrapper що повернув timer ``` **Чому замикання роблять це можливим** Функція `wrapper` всередині декоратора *замикається* над параметром `func` — вона зберігає посилання на оригінальну функцію навіть після того як `my_decorator` повернувся. Це фундаментальний механізм: ```python def my_decorator(func): # func це 'greet' під час декорування def wrapper(*args, **kwargs): print('before') # wrapper замикається над func result = func(*args, **kwargs) # все ще має доступ до func print('after') return result return wrapper # повертаємо замикання, не результат ``` `*args, **kwargs` у сигнатурі wrapper означає що декоратор працює з *будь-якою* функцією незалежно від кількості параметрів. **Завжди використовуйте `@functools.wraps`** Без нього wrapper замінює ідентичність оригінальної функції. Налагодження стає складнішим, а фреймворки що перевіряють `__name__` або `__doc__` (FastAPI, pytest) поводяться несподівано: ```python import functools def my_decorator(func): @functools.wraps(func) # копіює __name__, __doc__, __annotations__ def wrapper(*args, **kwargs): return func(*args, **kwargs) return wrapper @my_decorator def greet(name: str) -> str: 'Say hello to name.' return f'Hello, {name}!' greet.__name__ # 'greet' greet.__doc__ # 'Say hello to name.' greet.__annotations__ # {'name': str, 'return': str} ```

Фабрики декораторів, стекування, декоратори на основі класу

#
**Декоратор з аргументами — патерн фабрики** Простий декоратор приймає функцію і повертає функцію. Якщо потрібно передати *параметри* самому декоратору — додають ще один шар: фабрику що приймає параметри і повертає справжній декоратор: ```python import functools def repeat(n): # шар 1: фабрика — приймає аргумент def decorator(func): # шар 2: справжній декоратор @functools.wraps(func) def wrapper(*args, **kwargs): # шар 3: обгортка for _ in range(n): result = func(*args, **kwargs) return result return wrapper return decorator @repeat(3) # repeat(3) повертає декоратор; той декоратор обгортає say def say(msg): print(msg) say('hello') # друкує 'hello' тричі ``` **Стекування декораторів** Можна застосувати кілька декораторів до однієї функції. Вони застосовуються знизу-вгору під час декорування, але викликаються зверху-вниз: ```python @timer # застосовується другим → найзовнішній шар @log_calls # застосовується першим → найвнутрішній шар def compute(n): return sum(range(n)) # Еквівалентно: compute = timer(log_calls(compute)) # Порядок виклику під час виконання: # timer's wrapper → log_calls' wrapper → compute ``` **Декоратор на основі класу** Будь-який об'єкт з методом `__call__` може бути декоратором. Корисно коли декоратор повинен зберігати стан між викликами: ```python import functools class CountCalls: def __init__(self, func): functools.update_wrapper(self, func) self.func = func self.count = 0 def __call__(self, *args, **kwargs): self.count += 1 return self.func(*args, **kwargs) @CountCalls def say_hi(): print('hi') say_hi() say_hi() print(say_hi.count) # 2 — стан живе в екземплярі CountCalls ``` > **Про декоратори методів класу:** Python також має вбудовані `@property`, `@classmethod` та `@staticmethod` — декоратори що змінюють як метод прив'язується до свого класу. Це окрема тема що детально розглядається в наступному розділі.

Реальні патерни, помилки, коли не використовувати, анонс декораторів методів

#
**Реальні патерни декораторів** ```python import functools, time # 1. Таймер — вимірювання часу виконання def timer(func): @functools.wraps(func) def wrapper(*args, **kwargs): start = time.perf_counter() result = func(*args, **kwargs) elapsed = time.perf_counter() - start print(f'{func.__name__} took {elapsed:.4f}s') return result return wrapper # 2. Повтор при помилці def retry(n=3, exceptions=(Exception,)): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): for attempt in range(1, n + 1): try: return func(*args, **kwargs) except exceptions: if attempt == n: raise return wrapper return decorator # 3. Singleton def singleton(cls): instances = {} @functools.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 Config: def __init__(self): self.debug = False Config() is Config() # True ``` **Поширені помилки** **1. Забутий return результату:** ```python def bad_decorator(func): def wrapper(*args, **kwargs): func(*args, **kwargs) # БАГ: результат мовчки втрачається return wrapper def good_decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): return func(*args, **kwargs) # завжди повертати return wrapper ``` **2. Виклик wrapper замість повернення wrapper:** ```python def bad_decorator(func): def wrapper(*args, **kwargs): return func(*args, **kwargs) return wrapper() # БАГ: викликає wrapper одразу def good_decorator(func): def wrapper(*args, **kwargs): return func(*args, **kwargs) return wrapper # правильно: повертаємо об'єкт функції ``` **Коли НЕ використовувати декоратор** Декоратори додають непрямий виклик та приховують логіку. Використовуйте декоратор коли: - Поведінка застосовується до багатьох не пов'язаних функцій - Вона суто адитивна (логування, таймінг, кешування, валідація) - Не змінює сигнатуру або тип повернення Уникайте декораторів коли поведінка специфічна для однієї функції або суттєво ускладнює читання коду. **Анонс: вбудовані декоратори методів** Python має вбудовані `@property`, `@classmethod` та `@staticmethod`. Вони декорують методи класу а не звичайні функції, і взаємодіють з протоколом дескрипторів Python — окремим механізмом від патерну декораторів функцій описаного в цьому розділі. Вони детально розглядаються в наступному topic.
01

Декоратор timer

#

Напишіть декоратор `timer`, який вимірює час виконання декорованої функції та виводить результат у форматі: `'greet took 0.0001s'`. Використовуйте `time.time()` для вимірювання.

import time

def timer(func):
    pass


@timer
def greet(name):
    return f'Привіт, {name}!'

greet('Аліса')  # виводить: greet took 0.0001s (приблизно)
Рішення
import time
import functools

def timer(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        elapsed = time.time() - start
        print(f'{func.__name__} took {elapsed:.4f}s')
        return result
    return wrapper


@timer
def greet(name):
    return f'Привіт, {name}!'

greet('Аліса')
02

Декоратор log_calls

#

Напишіть декоратор `log_calls`, який виводить ім'я функції, її позиційні аргументи та повернуте значення щоразу, коли вона викликається. Формат: `'add called with (2, 3) -> 5'`.

def log_calls(func):
    pass


@log_calls
def add(a, b):
    return a + b

add(2, 3)   # виводить: add called with (2, 3) -> 5
add(10, 20) # виводить: add called with (10, 20) -> 30
Рішення
import functools

def log_calls(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        print(f'{func.__name__} called with {args} -> {result}')
        return result
    return wrapper


@log_calls
def add(a, b):
    return a + b

add(2, 3)
add(10, 20)
03

Декоратор memoize

#

Напишіть декоратор `memoize`, який кешує результати функції. При повторному виклику з тими самими аргументами — поверніть кешоване значення без повторного виконання функції. Зберігайте результати в словнику за ключем `args`.

def memoize(func):
    pass


@memoize
def fib(n):
    if n <= 1:
        return n
    return fib(n - 1) + fib(n - 2)

print(fib(10))  # 55
print(fib(30))  # 832040 (швидко завдяки кешу)
Рішення
import functools

def memoize(func):
    cache = {}
    @functools.wraps(func)
    def wrapper(*args):
        if args not in cache:
            cache[args] = func(*args)
        return cache[args]
    return wrapper


@memoize
def fib(n):
    if n <= 1:
        return n
    return fib(n - 1) + fib(n - 2)

print(fib(10))  # 55
print(fib(30))  # 832040
04

Декоратор count_calls

#

Напишіть декоратор `count_calls`, який відстежує кількість викликів декорованої функції. Зберігайте лічильник як атрибут `.count` на wrapper, щоб код міг його зчитувати.

def count_calls(func):
    pass


@count_calls
def say_hello():
    print('Привіт!')

say_hello()
say_hello()
say_hello()
print(say_hello.count)  # 3
Рішення
import functools

def count_calls(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        wrapper.count += 1
        return func(*args, **kwargs)
    wrapper.count = 0
    return wrapper


@count_calls
def say_hello():
    print('Привіт!')

say_hello()
say_hello()
say_hello()
print(say_hello.count)  # 3
05

Декоратор validate_positive

#

Напишіть декоратор `validate_positive`, який перевіряє кожен позиційний аргумент перед викликом функції. Якщо будь-який аргумент не є позитивним числом (≤ 0), згенеруйте `ValueError` з повідомленням `'All arguments must be positive'`.

def validate_positive(func):
    pass


@validate_positive
def area(width, height):
    return width * height

print(area(3, 4))   # 12
print(area(-1, 5))  # ValueError: All arguments must be positive
Рішення
import functools

def validate_positive(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        if any(a <= 0 for a in args):
            raise ValueError('All arguments must be positive')
        return func(*args, **kwargs)
    return wrapper


@validate_positive
def area(width, height):
    return width * height

print(area(3, 4))  # 12
try:
    area(-1, 5)
except ValueError as e:
    print(e)  # All arguments must be positive
06

Декоратор once

#

Напишіть декоратор `once`, який гарантує, що декорована функція виконається лише один раз. При першому виклику — виконайте функцію та збережіть результат. При всіх наступних викликах — повертайте збережений результат без повторного виконання.

def once(func):
    pass


@once
def load_config():
    print('Завантаження конфігурації...')
    return {'debug': True}

c1 = load_config()  # виводить: Завантаження конфігурації...
c2 = load_config()  # нічого не виводить
c3 = load_config()  # нічого не виводить
print(c1 is c2)     # True
Рішення
import functools

def once(func):
    sentinel = object()
    result = sentinel
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        nonlocal result
        if result is sentinel:
            result = func(*args, **kwargs)
        return result
    return wrapper


@once
def load_config():
    print('Завантаження конфігурації...')
    return {'debug': True}

c1 = load_config()
c2 = load_config()
print(c1 is c2)  # True
07

Фабрика декораторів retry

#

Напишіть фабрику декораторів `retry(n)`, яка повторює виконання декорованої функції до `n` разів при виникненні будь-якого виключення. Якщо всі спроби невдалі — перекиньте останнє виключення. Виводьте `'Attempt X failed'` після кожної невдалої спроби перед повторенням.

def retry(n):
    pass


attempts = [0]

@retry(3)
def flaky():
    attempts[0] += 1
    if attempts[0] < 3:
        raise RuntimeError('not ready')
    return 'success'

print(flaky())  # Attempt 1 failed, Attempt 2 failed, потім: 'success'
Рішення
import functools

def retry(n):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, n + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    if attempt == n:
                        raise
                    print(f'Attempt {attempt} failed')
        return wrapper
    return decorator


attempts = [0]

@retry(3)
def flaky():
    attempts[0] += 1
    if attempts[0] < 3:
        raise RuntimeError('not ready')
    return 'success'

print(flaky())
08

@property: клас Temperature

#

Створіть клас `Temperature`, який зберігає температуру в Цельсіях внутрішньо. Використовуйте `@property` для властивостей `celsius` (геттер і сеттер) та `fahrenheit` (геттер і сеттер, що конвертує з Фаренгейта в Цельсії). Формула: `F = C * 9/5 + 32`.

class Temperature:
    def __init__(self, celsius=0):
        pass

    # Додайте властивість celsius (геттер + сеттер)
    # Додайте властивість fahrenheit (геттер + сеттер)


t = Temperature(100)
print(t.celsius)      # 100
print(t.fahrenheit)   # 212.0
t.fahrenheit = 32
print(t.celsius)      # 0.0
Рішення
class Temperature:
    def __init__(self, celsius=0):
        self._celsius = celsius

    @property
    def celsius(self):
        return self._celsius

    @celsius.setter
    def celsius(self, value):
        self._celsius = value

    @property
    def fahrenheit(self):
        return self._celsius * 9 / 5 + 32

    @fahrenheit.setter
    def fahrenheit(self, value):
        self._celsius = (value - 32) * 5 / 9


t = Temperature(100)
print(t.celsius)     # 100
print(t.fahrenheit)  # 212.0
t.fahrenheit = 32
print(t.celsius)     # 0.0
09

Фабрика декораторів requires_role

#

Напишіть фабрику декораторів `requires_role(role)`, яка захищає функцію. Декорована функція завжди отримує словник `user` першим аргументом. Якщо `user['role']` не дорівнює `role`, згенеруйте `PermissionError` з повідомленням `'Access denied'`. Інакше — виконайте функцію нормально.

def requires_role(role):
    pass


@requires_role('admin')
def delete_record(user, record_id):
    return f"Запис {record_id} видалено користувачем {user['name']}"

admin = {'name': 'Аліса', 'role': 'admin'}
guest = {'name': 'Боб', 'role': 'guest'}

print(delete_record(admin, 42))  # Запис 42 видалено користувачем Аліса
delete_record(guest, 42)         # PermissionError: Access denied
Рішення
import functools

def requires_role(role):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(user, *args, **kwargs):
            if user.get('role') != role:
                raise PermissionError('Access denied')
            return func(user, *args, **kwargs)
        return wrapper
    return decorator


@requires_role('admin')
def delete_record(user, record_id):
    return f"Запис {record_id} видалено користувачем {user['name']}"

admin = {'name': 'Аліса', 'role': 'admin'}
guest = {'name': 'Боб', 'role': 'guest'}

print(delete_record(admin, 42))
try:
    delete_record(guest, 42)
except PermissionError as e:
    print(e)  # Access denied
10

Стекування декораторів

#

Застосуйте декоратори `timer` та `log_calls` на функцію `compute(n)`, яка повертає суму квадратів від 1 до n. Застосуйте `@timer` зовні та `@log_calls` всередині. Перевірте, що виведення містить і рядок логу, і рядок таймера.

import time
import functools

def timer(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        print(f'{func.__name__} took {time.time() - start:.4f}s')
        return result
    return wrapper

def log_calls(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        print(f'{func.__name__} called with {args} -> {result}')
        return result
    return wrapper


# Застосуйте обидва декоратори на compute:
def compute(n):
    return sum(i * i for i in range(1, n + 1))

compute(5)
Рішення
import time
import functools

def timer(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        print(f'{func.__name__} took {time.time() - start:.4f}s')
        return result
    return wrapper

def log_calls(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        print(f'{func.__name__} called with {args} -> {result}')
        return result
    return wrapper


@timer
@log_calls
def compute(n):
    return sum(i * i for i in range(1, n + 1))

compute(5)
# log_calls виводить: compute called with (5,) -> 55
# timer виводить: compute took 0.0000s