Практика · Декоратори¶
Лекція: lecture.html · Тест: quiz.html · ДЗ: homework.md
Наскрізний приклад той самий, що в лекції, — розрахунок позиції чека й довідник вартості доставки. Тут ми руками зробимо все, про що йшлося:
- переконаємось, що функція, яка повертає функцію, справді памʼятає значення зовнішньої — і подивимось на комірку замикання на власні очі;
- напишемо замикання-лічильник з
nonlocal; - зробимо свій декоратор часу двома способами — через
=і через@— і доведемоassert-ом, що результат функції не змінився; - побачимо, як без
functools.wrapsфункція втрачає імʼя, і полагодимо це; - напишемо декоратор з аргументом
@повторити(спроб=3)і змусимо його спрацювати з третьої спроби; - зміряємо секундоміром, скільки часу економить
lru_cache; - напишемо власний кеш і
assert-ом порівняємо його з бібліотечним; - зловимо
TypeErrorна нехешованому аргументі; - перевіримо порядок кількох декораторів — журналом, а не на віру.
Усе, що тут є, запускається зверху вниз без правок.
0 · Що нам знадобиться¶
Три речі зі стандартної бібліотеки: секундомір, інструменти functools і модуль
inspect, щоб питати функцію про її ж сигнатуру.
import sys
import time
import inspect
from functools import wraps, lru_cache
# наскрізні дані з лекції: чек і довідник вартості доставки
ЦІНИ_ДОСТАВКИ = {
"Київ": 60.0, "Львів": 75.0, "Одеса": 80.0, "Харків": 85.0,
"Дніпро": 82.0, "Вінниця": 70.0, "Полтава": 78.0, "Ужгород": 95.0,
}
print("міст у довіднику:", len(ЦІНИ_ДОСТАВКИ))
print("Python: ", sys.version.split()[0])
міст у довіднику: 8 Python: 3.12.2
1 · Функція, що повертає функцію¶
Почнемо з цеглинки, без якої декоратора не буде. Зовнішня функція нічого не рахує —
вона виготовляє внутрішню й повертає її. Зверни увагу на останній рядок:
return помножити без дужок, бо ми віддаємо саму функцію, а не результат її виклику.
def зробити_множник(коефіцієнт):
def помножити(число):
return число * коефіцієнт
return помножити # без дужок: віддаємо саму функцію
подвоїти = зробити_множник(2)
потроїти = зробити_множник(3)
print("подвоїти(10) =", подвоїти(10))
print("потроїти(10) =", потроїти(10))
print("це різні обʼєкти:", подвоїти is not потроїти)
подвоїти(10) = 20 потроїти(10) = 30 це різні обʼєкти: True
Виклик зробити_множник(2) давно завершився, а двійка жива. Перевіримо це не на віру,
а через службові поля: co_freevars показує імена, захоплені ззовні, а __closure__ —
самі комірки з їхнім вмістом.
# co_freevars — кортеж імен, які внутрішня функція взяла із зовнішньої
print("захоплені імена подвоїти:", подвоїти.__code__.co_freevars)
print("вміст комірки подвоїти: ", подвоїти.__closure__[0].cell_contents)
print("вміст комірки потроїти: ", потроїти.__closure__[0].cell_contents)
# кожна виготовлена функція має власну комірку — саме тому вони не заважають одна одній
assert подвоїти.__closure__[0].cell_contents == 2
assert потроїти.__closure__[0].cell_contents == 3
assert подвоїти.__closure__ is not потроїти.__closure__
print("✅ у кожної функції своя комірка замикання")
захоплені імена подвоїти: ('коефіцієнт',)
вміст комірки подвоїти: 2
вміст комірки потроїти: 3
✅ у кожної функції своя комірка замикання
2 · Замикання-лічильник¶
Комірка не лише зберігає значення — її можна змінювати. Для цього потрібне слово
nonlocal з теми 15: без нього присвоєння всередині внутрішньої функції створило б
нове локальне імʼя, а зовнішнє лишилось би недоторканим.
Це найкоротший приклад стану, який живе поза функцією, але не в глобальному просторі: ззовні його не видно й не зіпсувати.
def зробити_лічильник(початок=0):
порахували = початок
def порахувати():
nonlocal порахували # без цього рядка буде UnboundLocalError
порахували = порахували + 1
return порахували
return порахувати
чеки = зробити_лічильник()
доставки = зробити_лічильник(100)
print("три виклики чеків: ", чеки(), чеки(), чеки())
print("два виклики доставок:", доставки(), доставки())
# лічильники незалежні: у кожного своя комірка
assert чеки() == 4
assert доставки() == 103
print("✅ лічильники не заважають один одному")
три виклики чеків: 1 2 3 два виклики доставок: 101 102 ✅ лічильники не заважають один одному
Спробуймо дістатися до цього стану ззовні — і переконаймось, що звичайного імені для
нього не існує. Єдиний шлях — через __closure__, тобто через службові поля, а не
через простір імен модуля.
# у глобальному просторі жодного «порахували» немає
print("'порахували' серед глобальних імен:", "порахували" in globals())
# але комірку видно, якщо знати, куди дивитись
i = чеки.__code__.co_freevars.index("порахували")
print("поточне значення в комірці чеків:", чеки.__closure__[i].cell_contents)
'порахували' серед глобальних імен: False поточне значення в комірці чеків: 4
3 · Перший декоратор — без синтаксису @¶
Тепер зберемо декоратор. Це та сама будова, що в розділі 1: зовнішня функція приймає
func, виготовляє обгортку й повертає її. Усередині обгортки — три частини: щось
до, виклик справжньої функції, щось після.
Спершу застосуємо його «руками», щоб було видно: жодної магії в @ немає.
ЖУРНАЛ_ЧАСУ = [] # сюди обгортка складатиме заміри, щоб потім їх перевірити
def таймер(func):
def обгортка(ціна, кількість):
початок = time.perf_counter()
результат = func(ціна, кількість) # справжня робота
витрачено = time.perf_counter() - початок
ЖУРНАЛ_ЧАСУ.append((func.__name__, витрачено))
return результат # обовʼязково віддаємо далі!
return обгортка
def підсумок(ціна, кількість):
"""Ціна × кількість зі знижкою 10%."""
разом = ціна * кількість
return round(разом * 0.9, 2)
до_декорування = підсумок(28.5, 2)
підсумок = таймер(підсумок) # ← ось що насправді робить рядок @таймер
після_декорування = підсумок(28.5, 2)
print("до декорування: ", до_декорування)
print("після: ", після_декорування)
print("записів у журналі:", len(ЖУРНАЛ_ЧАСУ))
# головна властивість пристойного декоратора: результат не змінився
assert до_декорування == після_декорування == 51.3, "декоратор зіпсував відповідь!"
print("✅ значення те саме, додався лише замір часу")
до декорування: 51.3 після: 51.3 записів у журналі: 1 ✅ значення те саме, додався лише замір часу
4 · Те саме через @¶
А тепер той самий декоратор у звичному записі. Порівняємо результати двох версій — вони мусять збігтися до останнього знака, бо це буквально той самий код.
@таймер
def підсумок_з_собачкою(ціна, кількість):
"""Ціна × кількість зі знижкою 10%."""
разом = ціна * кількість
return round(разом * 0.9, 2)
перевірка = [(28.5, 2), (32.0, 1), (145.0, 1), (19.9, 3)]
for ціна, кількість in перевірка:
з_собачкою = підсумок_з_собачкою(ціна, кількість)
руками = підсумок(ціна, кількість)
print(f"{ціна:>6} × {кількість} → {з_собачкою:>6} (руками: {руками})")
assert з_собачкою == руками, "два записи мають давати однакове!"
print("✅ @таймер і присвоєння руками — це одне й те саме")
28.5 × 2 → 51.3 (руками: 51.3) 32.0 × 1 → 28.8 (руками: 28.8) 145.0 × 1 → 130.5 (руками: 130.5) 19.9 × 3 → 53.73 (руками: 53.73) ✅ @таймер і присвоєння руками — це одне й те саме
5 · Обгортка для будь-якої функції: *args, **kwargs¶
Наш таймер має ваду: його обгортка приймає рівно два аргументи. Спробуймо почепити
його на функцію з іншою сигнатурою — і подивімось на справжній текст помилки.
Клітинка навмисно падає.
@таймер
def сума_чека(ціни, кількості, знижка=0.0):
разом = sum(ціна * кількість for ціна, кількість in zip(ціни, кількості))
return round(разом * (1 - знижка), 2)
сума_чека([28.5, 32.0], [2, 1], знижка=0.1)
--------------------------------------------------------------------------- TypeError Traceback (most recent call last) Cell In[8], line 7 3 разом = sum(ціна * кількість for ціна, кількість in zip(ціни, кількості)) 4 return round(разом * (1 - знижка), 2) ----> 7 сума_чека([28.5, 32.0], [2, 1], знижка=0.1) TypeError: таймер.<locals>.обгортка() got an unexpected keyword argument 'знижка'
Лагодиться це зірочками з теми 15. Обгортка збирає позиційні аргументи в кортеж
args, іменовані — у словник kwargs і одразу розпаковує їх назад при виклику
справжньої функції. Тепер декоратору байдуже, скільки в неї параметрів.
def таймер_універсальний(func):
def обгортка(*args, **kwargs):
початок = time.perf_counter()
результат = func(*args, **kwargs) # віддаємо все, що прийшло
ЖУРНАЛ_ЧАСУ.append((func.__name__, time.perf_counter() - початок))
return результат
return обгортка
@таймер_універсальний
def сума_чека(ціни, кількості, знижка=0.0):
"""Сума всього чека з однією знижкою на все."""
разом = sum(ціна * кількість for ціна, кількість in zip(ціни, кількості))
return round(разом * (1 - знижка), 2)
print("два позиційні: ", сума_чека([28.5, 32.0], [2, 1]))
print("плюс іменований: ", сума_чека([28.5, 32.0], [2, 1], знижка=0.1))
print("усе іменованими: ", сума_чека(ціни=[28.5], кількості=[2], знижка=0.5))
assert сума_чека([28.5, 32.0], [2, 1]) == 89.0
print("✅ один декоратор працює з будь-якою сигнатурою")
два позиційні: 89.0 плюс іменований: 80.1 усе іменованими: 28.5 ✅ один декоратор працює з будь-якою сигнатурою
6 · Без functools.wraps функція втрачає імʼя¶
Тепер найважливіша перевірка практики. Запитаймо задекоровану функцію, як її звати, —
і побачимо чуже імʼя. Це не косметика: за цими самими полями функцію шукають
help(), редактор коду й журнали помилок.
print("імʼя функції: ", сума_чека.__name__)
print("докстрінг: ", сума_чека.__doc__)
print("сигнатура: ", inspect.signature(сума_чека))
# саме це й треба побачити на власні очі: імʼя загубилось
assert сума_чека.__name__ == "обгортка", "без wraps імʼя мусить загубитись"
assert сума_чека.__doc__ is None, "без wraps докстрінг теж зникає"
assert str(inspect.signature(сума_чека)) == "(*args, **kwargs)"
print("❌ функція втратила імʼя, докстрінг і правильну сигнатуру")
імʼя функції: обгортка докстрінг: None сигнатура: (*args, **kwargs) ❌ функція втратила імʼя, докстрінг і правильну сигнатуру
Лагодиться це одним рядком — @wraps(func) над обгорткою. Перепишемо декоратор і
переконаємось, що ті самі три запитання тепер дають правильні відповіді.
def таймер_чесний(func):
@wraps(func) # ← єдина зміна
def обгортка(*args, **kwargs):
початок = time.perf_counter()
результат = func(*args, **kwargs)
ЖУРНАЛ_ЧАСУ.append((func.__name__, time.perf_counter() - початок))
return результат
return обгортка
@таймер_чесний
def сума_чека_чесна(ціни, кількості, знижка=0.0):
"""Сума всього чека з однією знижкою на все."""
разом = sum(ціна * кількість for ціна, кількість in zip(ціни, кількості))
return round(разом * (1 - знижка), 2)
print("імʼя функції:", сума_чека_чесна.__name__)
print("докстрінг: ", сума_чека_чесна.__doc__)
print("сигнатура: ", inspect.signature(сума_чека_чесна))
assert сума_чека_чесна.__name__ == "сума_чека_чесна"
assert сума_чека_чесна.__doc__ == "Сума всього чека з однією знижкою на все."
assert str(inspect.signature(сума_чека_чесна)) == "(ціни, кількості, знижка=0.0)"
# wraps додає ще й посилання на справжню функцію — саме за ним працює signature
assert hasattr(сума_чека_чесна, "__wrapped__")
print("✅ @wraps повернув функції її імʼя, документацію й сигнатуру")
імʼя функції: сума_чека_чесна докстрінг: Сума всього чека з однією знижкою на все. сигнатура: (ціни, кількості, знижка=0.0) ✅ @wraps повернув функції її імʼя, документацію й сигнатуру
Через __wrapped__ можна дістати саму функцію — ту, що лежить у комірці замикання.
Це корисно, коли треба викликати її без обгортки: наприклад, у тесті.
без_обгортки = сума_чека_чесна.__wrapped__
# результат той самий, але жодного запису в журнал не додається
було = len(ЖУРНАЛ_ЧАСУ)
значення = без_обгортки([28.5, 32.0], [2, 1])
стало = len(ЖУРНАЛ_ЧАСУ)
print("значення:", значення)
print("записів у журналі було/стало:", було, "/", стало)
assert значення == 89.0
assert було == стало, "виклик через __wrapped__ не мав нічого записати"
print("✅ __wrapped__ веде до справжньої функції, повз обгортку")
значення: 89.0 записів у журналі було/стало: 13 / 13 ✅ __wrapped__ веде до справжньої функції, повз обгортку
7 · Декоратор з аргументом: @повторити(спроб=3)¶
Найважче місце теми — три рівні вкладеності. Читай знизу вгору: обгортка робить
роботу, декоратор віддає обгортку, повторити віддає декоратор.
Щоб перевірити його чесно, потрібна функція, яка передбачувано відмовляє перші два рази. Зробимо її на замиканні-лічильнику з розділу 2 — без випадковості, щоб результат був однаковий у всіх.
def повторити(спроб):
def декоратор(func):
@wraps(func)
def обгортка(*args, **kwargs):
for номер in range(1, спроб + 1):
try:
return func(*args, **kwargs) # вийшло — виходимо одразу
except ValueError as помилка:
print(f" спроба {номер} з {спроб}: {помилка}")
raise RuntimeError(f"{func.__name__}: не вийшло за {спроб} спроб")
return обгортка
return декоратор
ВИКЛИКІВ_ДОВІДНИКА = 0
@повторити(спроб=3)
def ненадійний_довідник(місто):
"""Відмовляє перші два рази, на третій відповідає."""
global ВИКЛИКІВ_ДОВІДНИКА
ВИКЛИКІВ_ДОВІДНИКА += 1
if ВИКЛИКІВ_ДОВІДНИКА < 3:
raise ValueError("довідник тимчасово недоступний")
return ЦІНИ_ДОСТАВКИ[місто]
print("питаємо вартість доставки в Київ:")
ціна = ненадійний_довідник("Київ")
print("отримали:", ціна)
assert ціна == 60.0
assert ВИКЛИКІВ_ДОВІДНИКА == 3, "мало бути рівно три спроби"
print("✅ спрацювало з третьої спроби, як і задумано")
питаємо вартість доставки в Київ: спроба 1 з 3: довідник тимчасово недоступний спроба 2 з 3: довідник тимчасово недоступний отримали: 60.0 ✅ спрацювало з третьої спроби, як і задумано
А тепер переконаємось, що спроби справді скінченні: якщо функція відмовляє завжди,
декоратор здається після третьої й кидає RuntimeError. Ловимо його й перевіряємо
текст, а не сам факт винятку.
СПРОБ_БЕЗНАДІЙНИХ = 0
@повторити(спроб=3)
def завжди_відмовляє(місто):
global СПРОБ_БЕЗНАДІЙНИХ
СПРОБ_БЕЗНАДІЙНИХ += 1
raise ValueError("довідник лежить")
try:
завжди_відмовляє("Львів")
текст_помилки = ""
except RuntimeError as помилка:
текст_помилки = str(помилка)
print("спіймали:", текст_помилки)
print("зроблено спроб:", СПРОБ_БЕЗНАДІЙНИХ)
assert "не вийшло за 3 спроб" in текст_помилки
assert СПРОБ_БЕЗНАДІЙНИХ == 3, "декоратор мусив зупинитись рівно на третій спробі"
# @wraps усередині декоратора теж працює — імʼя на місці
assert завжди_відмовляє.__name__ == "завжди_відмовляє"
print("✅ рівно три спроби, потім чесна помилка")
спроба 1 з 3: довідник лежить спроба 2 з 3: довідник лежить спроба 3 з 3: довідник лежить спіймали: завжди_відмовляє: не вийшло за 3 спроб зроблено спроб: 3 ✅ рівно три спроби, потім чесна помилка
Розгортка пояснює будову краще за опис. Рядок @повторити(спроб=3) — це два виклики
поспіль: спершу повторити(спроб=3) віддає декоратор, і вже він застосовується
до функції. Переконаймось у цьому напряму.
def проста(місто):
"""Звичайна функція без жодних прикрас."""
return ЦІНИ_ДОСТАВКИ[місто]
декоратор_на_3 = повторити(спроб=3) # перший виклик: дістали декоратор
проста_з_повтором = декоратор_на_3(проста) # другий: застосували його
print("що повернув повторити(спроб=3):", type(декоратор_на_3).__name__,
декоратор_на_3.__name__)
print("результат виклику:", проста_з_повтором("Одеса"))
assert проста_з_повтором("Одеса") == 80.0
assert декоратор_на_3.__name__ == "декоратор"
print("✅ @повторити(спроб=3) — це справді два виклики поспіль")
що повернув повторити(спроб=3): function декоратор результат виклику: 80.0 ✅ @повторити(спроб=3) — це справді два виклики поспіль
8 · lru_cache: скільки часу він економить насправді¶
У лекції інтерактив показував це на симуляції. Тут зміряємо секундоміром на живому коді. Довідник «повільний» навмисно — 40 мілісекунд на запит, як у лекції.
Послідовність замовлень фіксована (maxsize=4, вісім міст) — щоб числа були
відтворюваними, а не залежали від настрою машини.
ЗАМОВЛЕННЯ = [
"Київ", "Львів", "Київ", "Одеса", "Київ", "Львів", "Харків", "Київ",
"Одеса", "Львів", "Київ", "Дніпро", "Київ", "Львів", "Одеса", "Київ",
]
СПРАВЖНІХ_ЗВЕРНЕНЬ = 0
def повільний_довідник(місто):
"""Умовне звернення до зовнішнього джерела: 40 мс на запит."""
global СПРАВЖНІХ_ЗВЕРНЕНЬ
СПРАВЖНІХ_ЗВЕРНЕНЬ += 1
time.sleep(0.04)
return ЦІНИ_ДОСТАВКИ[місто]
початок = time.perf_counter()
без_кешу = [повільний_довідник(місто) for місто in ЗАМОВЛЕННЯ]
час_без_кешу = time.perf_counter() - початок
print(f"замовлень: {len(ЗАМОВЛЕННЯ)}")
print(f"справжніх звернень: {СПРАВЖНІХ_ЗВЕРНЕНЬ}")
print(f"часу витрачено: {час_без_кешу:.2f} с")
замовлень: 16 справжніх звернень: 16 часу витрачено: 0.64 с
Тепер той самий довідник із кешем. Функція не змінилась ані на символ — додався
один рядок над def.
СПРАВЖНІХ_З_КЕШЕМ = 0
@lru_cache(maxsize=4)
def кешований_довідник(місто):
"""Той самий довідник, але відповіді запамʼятовуються."""
global СПРАВЖНІХ_З_КЕШЕМ
СПРАВЖНІХ_З_КЕШЕМ += 1
time.sleep(0.04)
return ЦІНИ_ДОСТАВКИ[місто]
початок = time.perf_counter()
з_кешем = [кешований_довідник(місто) for місто in ЗАМОВЛЕННЯ]
час_з_кешем = time.perf_counter() - початок
print(f"справжніх звернень: {СПРАВЖНІХ_З_КЕШЕМ} (було {СПРАВЖНІХ_ЗВЕРНЕНЬ})")
print(f"часу витрачено: {час_з_кешем:.2f} с (було {час_без_кешу:.2f} с)")
print(f"кеш зняв: {час_без_кешу - час_з_кешем:.2f} с")
# найголовніше: відповіді ті самі, лише швидше
assert з_кешем == без_кешу, "кеш не має міняти відповіді!"
assert СПРАВЖНІХ_З_КЕШЕМ < СПРАВЖНІХ_ЗВЕРНЕНЬ
assert час_з_кешем < час_без_кешу
print("✅ відповіді ті самі, справжніх звернень менше")
справжніх звернень: 5 (було 16) часу витрачено: 0.20 с (було 0.64 с) кеш зняв: 0.44 с ✅ відповіді ті самі, справжніх звернень менше
Скільки саме зекономлено, кеш розповідає сам — cache_info() чіпляється прямо на
задекоровану функцію. Це, до речі, ще один доказ, що декоратор повернув інший
обʼєкт: у звичайної функції жодних cache_info немає.
статистика = кешований_довідник.cache_info()
print(статистика)
assert статистика.hits + статистика.misses == len(ЗАМОВЛЕННЯ)
assert статистика.misses == СПРАВЖНІХ_З_КЕШЕМ
assert статистика.maxsize == 4
print("влучань:", статистика.hits, "· промахів:", статистика.misses)
# у недекорованої функції такого поля немає
print("у повільного довідника є cache_info?", hasattr(повільний_довідник, "cache_info"))
assert not hasattr(повільний_довідник, "cache_info")
print("✅ cache_info живе на обгортці, а не на справжній функції")
CacheInfo(hits=11, misses=5, maxsize=4, currsize=4) влучань: 11 · промахів: 5 у повільного довідника є cache_info? False ✅ cache_info живе на обгортці, а не на справжній функції
9 · Свій кеш проти бібліотечного¶
Найцінніше, що дає практика: побачити, що всередині бібліотеки немає магії. Напишемо
власний декоратор кешу на звичайному словнику — і доведемо assert-ом, що на тих
самих даних він дає той самий результат, що й functools.lru_cache.
Наш кеш простіший: без обмеження розміру й без витіснення. Саме тому в бібліотеці
є maxsize, а в нас — ні.
def мій_кеш(func):
"""Найпростіший кеш: словник «аргументи → результат»."""
збережене = {} # живе в замиканні, окремо для кожної задекорованої функції
@wraps(func)
def обгортка(*args):
if args not in збережене: # ключ — кортеж аргументів
збережене[args] = func(*args)
return збережене[args]
def розмір_кешу():
return len(збережене)
обгортка.розмір_кешу = розмір_кешу
return обгортка
@мій_кеш
def мій_довідник(місто):
"""Той самий довідник, але з нашим кешем."""
time.sleep(0.04)
return ЦІНИ_ДОСТАВКИ[місто]
наш_результат = [мій_довідник(місто) for місто in ЗАМОВЛЕННЯ]
print("наш кеш зберіг записів:", мій_довідник.розмір_кешу())
print("перші пʼять відповідей:", наш_результат[:5])
# ось та сама перевірка «наша реалізація = бібліотечна»
assert наш_результат == з_кешем, "наш кеш розійшовся з lru_cache!"
assert мій_довідник.__name__ == "мій_довідник" # @wraps на місці
print("✅ збігається з functools.lru_cache")
наш кеш зберіг записів: 5 перші пʼять відповідей: [60.0, 75.0, 60.0, 80.0, 60.0] ✅ збігається з functools.lru_cache
10 · Чому аргументи мають бути хешованими¶
Кеш усередині — звичайний словник, а ключем у ньому стають аргументи виклику. З теми 08 ми знаємо: ключем словника може бути лише хешований обʼєкт. Тому список як аргумент ламає кеш ще на вході, в обгортці, — і падає не тіло функції. Клітинка навмисно падає.
@lru_cache(maxsize=8)
def вартість_кількох(міста):
return sum(ЦІНИ_ДОСТАВКИ[місто] for місто in міста)
вартість_кількох(["Київ", "Львів"]) # список не хешується
--------------------------------------------------------------------------- TypeError Traceback (most recent call last) Cell In[20], line 6 1 @lru_cache(maxsize=8) 2 def вартість_кількох(міста): 3 return sum(ЦІНИ_ДОСТАВКИ[місто] for місто in міста) ----> 6 вартість_кількох(["Київ", "Львів"]) # список не хешується TypeError: unhashable type: 'list'
Ліки прості: перетворити список на кортеж — він незмінний і тому хешований. Заразом переконаємось, що падало саме через хеш, а не через щось інше.
try:
вартість_кількох(["Київ", "Львів"])
текст = ""
except TypeError as помилка:
текст = str(помилка)
print("текст помилки:", текст)
assert "unhashable" in текст and "list" in текст
# кортеж працює без жодних змін у самій функції
разом = вартість_кількох(("Київ", "Львів"))
print("вартість для кортежа:", разом)
assert разом == 135.0
print("✅ той самий виклик із кортежем проходить")
текст помилки: unhashable type: 'list' вартість для кортежа: 135.0 ✅ той самий виклик із кортежем проходить
11 · Порядок кількох декораторів¶
Правило з лекції: застосування знизу вгору, виконання ззовні всередину. Перевіримо його журналом, а не на віру: кожен декоратор запише в спільний список, коли він зайшов і коли вийшов.
ЖУРНАЛ_ПОРЯДКУ = []
def зовнішній(func):
@wraps(func)
def обгортка(*args, **kwargs):
ЖУРНАЛ_ПОРЯДКУ.append("зовнішній: вхід")
результат = func(*args, **kwargs)
ЖУРНАЛ_ПОРЯДКУ.append("зовнішній: вихід")
return результат
return обгортка
def внутрішній(func):
@wraps(func)
def обгортка(*args, **kwargs):
ЖУРНАЛ_ПОРЯДКУ.append("внутрішній: вхід")
результат = func(*args, **kwargs)
ЖУРНАЛ_ПОРЯДКУ.append("внутрішній: вихід")
return результат
return обгортка
@зовнішній # стоїть вище — обгортає останнім, виконується першим
@внутрішній # стоїть нижче — обгортає першим, виконується другим
def порахувати(ціна, кількість):
ЖУРНАЛ_ПОРЯДКУ.append("сама функція")
return round(ціна * кількість * 0.9, 2)
значення = порахувати(28.5, 2)
for рядок in ЖУРНАЛ_ПОРЯДКУ:
print(рядок)
print("результат:", значення)
assert ЖУРНАЛ_ПОРЯДКУ == [
"зовнішній: вхід",
"внутрішній: вхід",
"сама функція",
"внутрішній: вихід",
"зовнішній: вихід",
]
assert значення == 51.3
print("✅ ззовні всередину й назад — саме так, як на схемі 4")
зовнішній: вхід внутрішній: вхід сама функція внутрішній: вихід зовнішній: вихід результат: 51.3 ✅ ззовні всередину й назад — саме так, як на схемі 4
12 · Декоратор на методі класу¶
Метод — це звичайна функція всередині класу, тому декоратор чіпляється на нього так
само. Єдине, що варто знати: self прийде в обгортку першим позиційним аргументом і
поїде далі в *args сам собою — нічого спеціального робити не треба.
ЖУРНАЛ_МЕТОДІВ = []
def записати_виклик(func):
@wraps(func)
def обгортка(*args, **kwargs):
# args[0] — це self; ми його не чіпаємо, лише передаємо далі
ЖУРНАЛ_МЕТОДІВ.append(func.__name__)
return func(*args, **kwargs)
return обгортка
class Чек:
def __init__(self, позиції):
self.позиції = позиції # список пар (ціна, кількість)
@записати_виклик
def сума(self):
"""Сума чека зі знижкою 10%."""
разом = sum(ціна * кількість for ціна, кількість in self.позиції)
return round(разом * 0.9, 2)
@записати_виклик
def найдорожча(self):
"""Вартість найдорожчої позиції — уже зі знижкою."""
найбільша = max(ціна * кількість for ціна, кількість in self.позиції)
return round(найбільша * 0.9, 2)
чек = Чек([(28.5, 2), (32.0, 1), (145.0, 1)])
print("сума чека: ", чек.сума())
print("найдорожча: ", чек.найдорожча())
assert чек.сума() == 210.6
assert Чек.сума.__name__ == "сума" # @wraps зберіг імʼя й тут
# кожен виклик методу лишив слід — журнал показує саме три виклики, зроблені вище
print("журнал викликів:", ЖУРНАЛ_МЕТОДІВ)
assert ЖУРНАЛ_МЕТОДІВ == ["сума", "найдорожча", "сума"]
print("✅ декоратор на методі працює так само, як на функції")
сума чека: 210.6 найдорожча: 130.5 журнал викликів: ['сума', 'найдорожча', 'сума'] ✅ декоратор на методі працює так само, як на функції
13 · Що ми довели¶
Підіб'ємо підсумок числами, а не словами: усі перевірки цього зошита пройшли.
підсумки = [
("замикання памʼятає значення зовнішньої функції", подвоїти(10) == 20),
("nonlocal дає приватний змінюваний стан", доставки() == 104),
("@таймер не міняє результату функції", підсумок(28.5, 2) == 51.3),
("без wraps імʼя губиться", сума_чека.__name__ == "обгортка"),
("з wraps імʼя на місці", сума_чека_чесна.__name__ == "сума_чека_чесна"),
("декоратор з аргументом дає рівно 3 спроби", СПРОБ_БЕЗНАДІЙНИХ == 3),
("lru_cache зменшив кількість звернень",
СПРАВЖНІХ_З_КЕШЕМ < СПРАВЖНІХ_ЗВЕРНЕНЬ),
("наш кеш = бібліотечний", наш_результат == з_кешем),
("порядок декораторів — ззовні всередину", len(ЖУРНАЛ_ПОРЯДКУ) == 5),
]
for опис, ознака in підсумки:
print(("✅" if ознака else "❌"), опис)
assert all(ознака for _, ознака in підсумки), "щось із перевірок не пройшло"
print()
print(f"пройдено перевірок: {len(підсумки)} з {len(підсумки)}")
✅ замикання памʼятає значення зовнішньої функції ✅ nonlocal дає приватний змінюваний стан ✅ @таймер не міняє результату функції ✅ без wraps імʼя губиться ✅ з wraps імʼя на місці ✅ декоратор з аргументом дає рівно 3 спроби ✅ lru_cache зменшив кількість звернень ✅ наш кеш = бібліотечний ✅ порядок декораторів — ззовні всередину пройдено перевірок: 9 з 9
Завдання¶
Роби їх у новому зошиті або файлі — цей знадобиться для звірки.
🟢 Рівень 1¶
Напиши декоратор @гучний, який перед викликом друкує імʼя функції та її аргументи,
а після виклику — результат. Почепи його на підсумок і переконайся assert-ом,
що значення не змінилось. Не забудь @wraps.
🟡 Рівень 2¶
Напиши декоратор з аргументом @округлити(знаків=2), який округлює результат
функції до заданої кількості знаків. Перевір його трьома assert-ами для
знаків=0, знаків=1 і знаків=3. Подумай і запиши в коментарі відповідь на
питання: чи не порушує такий декоратор правило «декоратор не повинен міняти
результат» — і за яких умов це прийнятно.
🔴 Рівень 3¶
Допиши мій_кеш до повноцінного LRU: додай параметр maxsize, витісняй найдавніше
вживаний запис і зроби метод статистика(), що повертає кількість влучань і промахів.
Доведи assert-ами, що на послідовності ЗАМОВЛЕННЯ з maxsize=4 твоя реалізація
дає точно ті самі hits і misses, що й functools.lru_cache(maxsize=4).
Повні умови з критеріями «зроблено, якщо» — у homework.md.