Практика · Декоратори¶

Лекція: lecture.html · Тест: quiz.html · ДЗ: homework.md

Наскрізний приклад той самий, що в лекції, — розрахунок позиції чека й довідник вартості доставки. Тут ми руками зробимо все, про що йшлося:

  1. переконаємось, що функція, яка повертає функцію, справді памʼятає значення зовнішньої — і подивимось на комірку замикання на власні очі;
  2. напишемо замикання-лічильник з nonlocal;
  3. зробимо свій декоратор часу двома способами — через = і через @ — і доведемо assert-ом, що результат функції не змінився;
  4. побачимо, як без functools.wraps функція втрачає імʼя, і полагодимо це;
  5. напишемо декоратор з аргументом @повторити(спроб=3) і змусимо його спрацювати з третьої спроби;
  6. зміряємо секундоміром, скільки часу економить lru_cache;
  7. напишемо власний кеш і assert-ом порівняємо його з бібліотечним;
  8. зловимо TypeError на нехешованому аргументі;
  9. перевіримо порядок кількох декораторів — журналом, а не на віру.

Усе, що тут є, запускається зверху вниз без правок.

0 · Що нам знадобиться¶

Три речі зі стандартної бібліотеки: секундомір, інструменти functools і модуль inspect, щоб питати функцію про її ж сигнатуру.

In [1]:
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 помножити без дужок, бо ми віддаємо саму функцію, а не результат її виклику.

In [2]:
def зробити_множник(коефіцієнт):
    def помножити(число):
        return число * коефіцієнт
    return помножити          # без дужок: віддаємо саму функцію


подвоїти = зробити_множник(2)
потроїти = зробити_множник(3)

print("подвоїти(10) =", подвоїти(10))
print("потроїти(10) =", потроїти(10))
print("це різні обʼєкти:", подвоїти is not потроїти)
подвоїти(10) = 20
потроїти(10) = 30
це різні обʼєкти: True

Виклик зробити_множник(2) давно завершився, а двійка жива. Перевіримо це не на віру, а через службові поля: co_freevars показує імена, захоплені ззовні, а __closure__ — самі комірки з їхнім вмістом.

In [3]:
# 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: без нього присвоєння всередині внутрішньої функції створило б нове локальне імʼя, а зовнішнє лишилось би недоторканим.

Це найкоротший приклад стану, який живе поза функцією, але не в глобальному просторі: ззовні його не видно й не зіпсувати.

In [4]:
def зробити_лічильник(початок=0):
    порахували = початок

    def порахувати():
        nonlocal порахували        # без цього рядка буде UnboundLocalError
        порахували = порахували + 1
        return порахували

    return порахувати


чеки = зробити_лічильник()
доставки = зробити_лічильник(100)

print("три виклики чеків:   ", чеки(), чеки(), чеки())
print("два виклики доставок:", доставки(), доставки())

# лічильники незалежні: у кожного своя комірка
assert чеки() == 4
assert доставки() == 103
print("✅ лічильники не заважають один одному")
три виклики чеків:    1 2 3
два виклики доставок: 101 102
✅ лічильники не заважають один одному

Спробуймо дістатися до цього стану ззовні — і переконаймось, що звичайного імені для нього не існує. Єдиний шлях — через __closure__, тобто через службові поля, а не через простір імен модуля.

In [5]:
# у глобальному просторі жодного «порахували» немає
print("'порахували' серед глобальних імен:", "порахували" in globals())

# але комірку видно, якщо знати, куди дивитись
i = чеки.__code__.co_freevars.index("порахували")
print("поточне значення в комірці чеків:", чеки.__closure__[i].cell_contents)
'порахували' серед глобальних імен: False
поточне значення в комірці чеків: 4

3 · Перший декоратор — без синтаксису @¶

Тепер зберемо декоратор. Це та сама будова, що в розділі 1: зовнішня функція приймає func, виготовляє обгортку й повертає її. Усередині обгортки — три частини: щось до, виклик справжньої функції, щось після.

Спершу застосуємо його «руками», щоб було видно: жодної магії в @ немає.

In [6]:
ЖУРНАЛ_ЧАСУ = []       # сюди обгортка складатиме заміри, щоб потім їх перевірити


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 · Те саме через @¶

А тепер той самий декоратор у звичному записі. Порівняємо результати двох версій — вони мусять збігтися до останнього знака, бо це буквально той самий код.

In [7]:
@таймер
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¶

Наш таймер має ваду: його обгортка приймає рівно два аргументи. Спробуймо почепити його на функцію з іншою сигнатурою — і подивімось на справжній текст помилки. Клітинка навмисно падає.

In [8]:
@таймер
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 і одразу розпаковує їх назад при виклику справжньої функції. Тепер декоратору байдуже, скільки в неї параметрів.

In [9]:
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(), редактор коду й журнали помилок.

In [10]:
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) над обгорткою. Перепишемо декоратор і переконаємось, що ті самі три запитання тепер дають правильні відповіді.

In [11]:
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__ можна дістати саму функцію — ту, що лежить у комірці замикання. Це корисно, коли треба викликати її без обгортки: наприклад, у тесті.

In [12]:
без_обгортки = сума_чека_чесна.__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 — без випадковості, щоб результат був однаковий у всіх.

In [13]:
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. Ловимо його й перевіряємо текст, а не сам факт винятку.

In [14]:
СПРОБ_БЕЗНАДІЙНИХ = 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) віддає декоратор, і вже він застосовується до функції. Переконаймось у цьому напряму.

In [15]:
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, вісім міст) — щоб числа були відтворюваними, а не залежали від настрою машини.

In [16]:
ЗАМОВЛЕННЯ = [
    "Київ", "Львів", "Київ", "Одеса", "Київ", "Львів", "Харків", "Київ",
    "Одеса", "Львів", "Київ", "Дніпро", "Київ", "Львів", "Одеса", "Київ",
]

СПРАВЖНІХ_ЗВЕРНЕНЬ = 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.

In [17]:
СПРАВЖНІХ_З_КЕШЕМ = 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 немає.

In [18]:
статистика = кешований_довідник.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, а в нас — ні.

In [19]:
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 ми знаємо: ключем словника може бути лише хешований обʼєкт. Тому список як аргумент ламає кеш ще на вході, в обгортці, — і падає не тіло функції. Клітинка навмисно падає.

In [20]:
@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'

Ліки прості: перетворити список на кортеж — він незмінний і тому хешований. Заразом переконаємось, що падало саме через хеш, а не через щось інше.

In [21]:
try:
    вартість_кількох(["Київ", "Львів"])
    текст = ""
except TypeError as помилка:
    текст = str(помилка)

print("текст помилки:", текст)
assert "unhashable" in текст and "list" in текст

# кортеж працює без жодних змін у самій функції
разом = вартість_кількох(("Київ", "Львів"))
print("вартість для кортежа:", разом)
assert разом == 135.0
print("✅ той самий виклик із кортежем проходить")
текст помилки: unhashable type: 'list'
вартість для кортежа: 135.0
✅ той самий виклик із кортежем проходить

11 · Порядок кількох декораторів¶

Правило з лекції: застосування знизу вгору, виконання ззовні всередину. Перевіримо його журналом, а не на віру: кожен декоратор запише в спільний список, коли він зайшов і коли вийшов.

In [22]:
ЖУРНАЛ_ПОРЯДКУ = []


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 сам собою — нічого спеціального робити не треба.

In [23]:
ЖУРНАЛ_МЕТОДІВ = []


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 · Що ми довели¶

Підіб'ємо підсумок числами, а не словами: усі перевірки цього зошита пройшли.

In [24]:
підсумки = [
    ("замикання памʼятає значення зовнішньої функції", подвоїти(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.