Python · Синтаксис · Просунутий рівень
Декоратори
Функції, що обгортають інші функції для зміни або розширення їх поведінки. Покриває синтаксис `@`, декоратори з аргументами, стекування та `functools.wraps`.
Короткий вступ до теми та пояснення перед вправами (вправи нижче):
Фабрики декораторів, стекування, декоратори на основі класу
#Реальні патерни, помилки, коли не використовувати, анонс декораторів методів
#Вправи:
Декоратор 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('Аліса')
Декоратор 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)
Декоратор 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
Декоратор 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
Декоратор 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
Декоратор 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
Фабрика декораторів 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())
@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
Фабрика декораторів 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
Стекування декораторів
#Застосуйте декоратори `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