Python з нуля · Блок 7 · Тема 31

Декоратори: обгортка навколо функції

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

У темі 14 ми з'ясували річ, яка тоді виглядала цікавинкою: функція — такий самий об'єкт, як число чи список. Її можна присвоїти другому імені, покласти в список, передати іншій функції аргументом. У темі 15 у врізці промайнуло слово замикання й обіцянка розібратися з ним у темі 31. Це вона. Обидві нитки сходяться тут, в одному механізмі — і з нього виростає значок @, який ти вже бачив над класом у темі 28.

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

def підсумок(ціна, кількість): """Ціна × кількість зі знижкою 10%.""" разом = ціна * кількість return round(разом * 0.9, 2) >>> підсумок(28.5, 2) 51.3

01 / ЗадачаЗадача, яку нічим закрити

Крамниця виросла. Тепер від коду хочуть речей, які до самого розрахунку не мають жодного стосунку: знати, скільки мілісекунд працює кожна функція; записувати в журнал, кого і з чим викликали; не рахувати вдруге те, що вже рахували; повторити запит до довідника, якщо він відмовив. Чотири вимоги, і кожна стосується десятків функцій одразу.

Перший інстинкт — дописати потрібне в тіло. Виглядає це так:

def підсумок(ціна, кількість): початок = time.perf_counter() # ← не про чек разом = ціна * кількість відповідь = round(разом * 0.9, 2) print(time.perf_counter() - початок) # ← теж не про чек return відповідь

Функція з чотирьох рядків стала шестирядковою, і два з шести до чека не належать. Помнож це на тридцять функцій — і в кожній буде однакова пара рядків, скопійована тридцять разів. Проблему копії ми вже проходили в темі 14: коли захочеш міряти час не секундоміром perf_counter, а чимось іншим, правити доведеться тридцять місць. І це ще не найгірше. Гірше те, що читач функції більше не бачить, що вона робить: намір потонув у службовому.

Другий інстинкт — написати функцію, яка приймає іншу функцію. Ми вміємо це з теми 14:

def з_таймером(func, ціна, кількість): початок = time.perf_counter() відповідь = func(ціна, кількість) print(func.__name__, time.perf_counter() - початок) return відповідь >>> з_таймером(підсумок, 28.5, 2) підсумок 8.6e-07 51.3

Уже краще: службовий код живе в одному місці. Але ціна теж є, і вона висока — усі місця виклику тепер треба переписати. Замість підсумок(28.5, 2) всюди має стояти з_таймером(підсумок, 28.5, 2). А ще ця функція вміє викликати лише те, що приймає рівно два аргументи.

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

02 / ЦеглинкаФункція, що повертає функцію

Ми звикли, що функція повертає число, рядок або список. Але return віддає об'єкт — будь-який. А функція, як ми з'ясували в темі 14, — це об'єкт. Отже, функція може повернути функцію.

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

Тут два def, вкладені один в одного. Зовнішній зробити_множник нічого не рахує — він виготовляє функцію й віддає її. Внутрішній помножити у момент виготовлення не виконується: рядок def лише створює об'єкт-функцію, як ми бачили в темі 14.

Найважливіший рядок — останній. Там написано return помножити, а не return помножити(). Дужки означають «виклич», і якби вони там стояли, Python спробував би викликати внутрішню функцію без аргументу й одразу впав би з TypeError. Ми ж хочемо віддати саму функцію, тому дужок немає.

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

Зупинись і подивись на це уважно. Ми викликали зробити_множник двічі й дістали дві різні функції. Кожна пам'ятає своє значення коефіцієнт — двійку й трійку відповідно. Але сам виклик зробити_множник(2) давно завершився. За правилами теми 15 усе локальне при завершенні функції зникає. Чому ж двійка жива?

03 / ЗамиканняЗамикання: памʼять, що пережила виклик

Відповідь має назву. Внутрішня функція разом із захопленими іменами зовнішньої називається замиканням (closure). Коли Python створює внутрішню функцію й бачить, що та користується іменем із зовнішньої, він не просто запам'ятовує значення. Він кладе його в окрему довговічну комірку (cell) і чіпляє цю комірку до об'єкта-функції.

Кадр виклику справді знищується — правило з теми 15 не скасовано. Але комірка живе окремо від кадру, і поки на неї дивиться жива функція, збирач сміття її не чіпає.

Схема 1 · Кадр помирає, комірка живе

Ліворуч — момент, коли зовнішня функція ще працює. Праворуч — те, що лишилось після return.

що зникаєкадр виклику разом з іменами func, обгортка
що лишаєтьсякомірка з посиланням на справжню функцію
хто її тримаєоб'єкт-функція обгортка через __closure__
висновок
Замикання — не виняток із правил теми 15, а їх наслідок. Зникає кадр, а не об'єкти, на які з нього хтось ще дивиться.
Стрілка — посилання, як на схемах памʼяті з теми 03. Нове тут лише одне: комірка. Вона не належить жодному кадру, тому й переживає його — а разом з нею живе функція підсумок, до якої більше не веде жодне імʼя.

Захоплені імена можна побачити на власні очі. У кожної функції є поле __code__.co_freevars — кортеж імен, узятих ззовні, і поле __closure__ — кортеж самих комірок:

>>> подвоїти.__code__.co_freevars ('коефіцієнт',) >>> подвоїти.__closure__[0].cell_contents 2 >>> потроїти.__closure__[0].cell_contents 3
Чому це не те саме, що глобальна змінна. Двійка й трійка зберігаються окремо для кожної виготовленої функції. Глобальне ім'я було б одне на всіх, і другий виклик зробити_множник зіпсував би першу функцію. Замикання дає стан, який ніхто ззовні не бачить і не може випадково перезаписати — тому за nonlocal критикують значно менше, ніж за global.

04 / СинтаксисЩо насправді означає @

Тепер можна зібрати все докупи. Декоратор — це функція, яка приймає функцію й повертає нову функцію. Усе. Жодного окремого синтаксису для цього не потрібно, жодного нового типу об'єкта мова не додає.

def таймер(func): def обгортка(ціна, кількість): початок = time.perf_counter() результат = func(ціна, кількість) витрачено = time.perf_counter() - початок print(func.__name__, витрачено) return результат return обгортка

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

Скористатися цим можна вже зараз, без жодного @:

підсумок = таймер(підсумок)

Рядок виглядає дивно, але робить рівно те, чого ми хотіли в розділі 01. Ім'я підсумок тепер вказує на обгортку, а сама справжня функція нікуди не поділась — вона в комірці замикання під ім'ям func. Усі місця виклику лишились незмінними: там як стояло підсумок(28.5, 2), так і стоїть.

Оскільки конструкція виявилась потрібною постійно, у мову додали для неї короткий запис. Він не додає нічого нового — це синтаксичний цукор, інший спосіб написати той самий рядок:

Схема 2 · Що означає рядок з @

Ліворуч — як пишуть. Праворуч — те саме без цукру. Рожевим — рядок, який робить усю роботу.

коли виконуєтьсяодин раз, одразу після def — при читанні файлу, а не при виклику
що змінюєтьсяім'я підсумок перечіпляється на те, що повернув таймер
висновок
@таймер — це не магія й не «мітка». Це буквально рядок підсумок = таймер(підсумок), записаний коротше й до тіла, а не після нього.
Чому запис із @ кращий: він стоїть над функцією, тому видно одразу, ще до читання тіла. Варіант із присвоєнням доводиться шукати внизу — а коли функція на пів екрана, його там просто не помічають.

Тепер зрозуміло й те, що ти бачив у темі 28. @dataclass над класом — та сама конструкція: Продаж = dataclass(Продаж). Просто там декоратор приймає не функцію, а клас, і повертає той самий клас із дописаними методами.

05 / МеханікаКрок за кроком крізь обгортку

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

Інтерактив 1 · Один виклик, дві функції

Тисни «Наступний крок». Ліворуч підсвічується рядок, що виконується; праворуч видно стек викликів і те, що вже надруковано.

крок1 / 11
глибина стека1
де мимодуль
повернуто
Головне — кроки 5 і 6. Стек стає на три поверхи: модуль викликав обгортку, обгортка викликала справжню функцію. Тіло підсумок при цьому не змінилось ані на символ — воно й досі не знає, що його хтось міряє.

Зверни увагу на крок 10: обгортка повертає результат незміненим. Це не дрібниця, а обов'язок. Забути return результат у кінці обгортки — найпоширеніша помилка при написанні першого декоратора: функція раптом починає повертати None, і причину шукають у самій функції, де її немає.

06 / УніверсальністьОбгортка для будь-якої функції

Наш таймер має ваду: його обгортка приймає рівно (ціна, кількість). Спробуй почепити його на функцію з трьома параметрами — отримаєш TypeError. А сенс декоратора саме в тому, щоб чіпляти його на що завгодно.

Рятує зірочка з теми 15. Там ми казали, що *args, **kwargs «про всяк випадок» — погана звичка, і називали єдиний випадок, де без них ніяк: обгортка, яка передає аргументи далі, не знаючи їх. Ось цей випадок:

def таймер(func): def обгортка(*args, **kwargs): початок = time.perf_counter() результат = func(*args, **kwargs) # віддаємо все, що прийшло print(func.__name__, time.perf_counter() - початок) return результат return обгортка

Тепер обгортка збирає позиційні аргументи в кортеж args, іменовані — у словник kwargs і одразу розпаковує їх назад при виклику func. Це та сама пара зірочок, що в темі 15, у двох різних ролях: у рядку def вони пакують, у рядку виклику — розпаковують. Декоратору байдуже, скільки параметрів у функції та як їх назвали: він нічого з ними не робить, тільки передає далі.

07 / Ремонтfunctools.wraps

Задекоруй функцію й спитай, як її звати:

>>> підсумок.__name__ 'обгортка' >>> підсумок.__doc__ >>> help(підсумок) обгортка(*args, **kwargs)

Нічого дивного тут немає — це прямий наслідок розділу 04. Ім'я підсумок тепер вказує на об'єкт-функцію обгортка, а в неї своє власне __name__ і немає жодного докстрінга. Справжня функція жива, але вона сховалась у комірці замикання, і жодні розпитування ім'я підсумок до неї не доводять.

Проблема здається косметичною рівно доти, доки не заважає всерйоз. Заважає вона так: help() показує безглузде; автодоповнення в редакторі підказує (*args, **kwargs) замість справжніх параметрів; журнал помилок пише «обгортка» замість імені функції — і з нього неможливо зрозуміти, що зламалось; бібліотеки тестів шукають функції за іменем і перестають їх бачити.

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

from functools import wraps def таймер(func): @wraps(func) # ← єдина зміна def обгортка(*args, **kwargs): початок = time.perf_counter() результат = func(*args, **kwargs) print(func.__name__, time.perf_counter() - початок) return результат return обгортка

Схема 3 · Що губиться без functools.wraps

Одна й та сама функція підсумок після декорування — без рядка @wraps і з ним.

що робить wrapsкопіює __name__, __doc__, __module__, __qualname__, __dict__ і додає __wrapped__
чого не робитьне змінює поведінку — обгортка лишається обгорткою
правило
Пишеш декоратор — пиши @wraps(func). Це не стиль і не смак: без нього функція втрачає власне ім'я й документацію.
Останній рядок — найкорисніший. Поле __wrapped__ веде до справжньої функції, і саме за ним inspect.signature показує правильні параметри замість (*args, **kwargs).

08 / НайважчеДекоратор з аргументами

Попередження чесно: це найважче місце теми. Якщо після першого читання буде каша в голові — це нормально, така реакція в усіх. Ідея проста, важка тільки будова.

Хочеться писати не просто @повторити, а @повторити(спроб=3) — з налаштуванням. Питання: як це може працювати, якщо @ вимагає функцію, яка приймає функцію?

Розгорни цукор, як у розділі 04, — і все стане на місце:

@повторити(спроб=3) def завантажити(адреса): ... # те саме без цукру: завантажити = повторити(спроб=3)(завантажити)

Придивись до двох пар дужок поспіль. Спершу виконується повторити(спроб=3) — звичайний виклик звичайної функції. Вона повертає декоратор. І вже цей декоратор застосовується до завантажити. Тобто повторити — не декоратор, а виробник декораторів. Звідси й три рівні вкладеності:

рівень 1 — приймає налаштування
def повторити(спроб):

Звичайна функція зі звичайним параметром. Про декорування вона ще нічого не знає — просто запам'ятовує число в замиканні.

рівень 2 — приймає функцію
def декоратор(func):

А ось це вже справжній декоратор — той самий, що в розділі 04: приймає функцію, повертає нову. У його замиканні лежать одразу два імені: func і спроб з рівня вище.

рівень 3 — приймає аргументи виклику
@wraps(func) def обгортка(*args, **kwargs):

Знайома обгортка. Вона єдина з трьох виконується при кожному виклику; два зовнішніх рівні спрацювали по одному разу — тоді, коли Python читав файл.

Повністю це виглядає так:

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 декоратор

Три def, три return, і кожен return віддає рівень нижче. Читай знизу вгору: обгортка робить роботу, декоратор віддає обгортку, повторити віддає декоратор. Механіка обробки помилок — з теми 18: return усередині try виходить з функції одразу, тому наступної спроби не буде, якщо ця вдалась.

Найчастіша помилка. Написати @повторити замість @повторити(спроб=3). Помилки в цьому рядку не буде — Python слухняно передасть функцію в параметр спроб, і зламається все пізніше й в іншому місці: декоратор ніколи не викличеться, а завантажити стане об'єктом-функцією декоратор. Симптом — TypeError про несподівані аргументи там, де все виглядає правильним.

09 / З коробкиГотові декоратори: lru_cache

Свої декоратори пишуть рідше, ніж користуються чужими. Найкорисніший у стандартній бібліотеці — functools.lru_cache. Він запам'ятовує результати викликів і на повторний виклик із тими самими аргументами віддає збережену відповідь, не рахуючи вдруге.

Уяви довідник вартості доставки: щоб дізнатися ціну для міста, треба звернутись до повільного джерела — приблизно 40 мілісекунд на запит. Замовлень за день сотні, а міст у нашій крамниці всього вісім, і повторюються вони постійно:

from functools import lru_cache @lru_cache(maxsize=4) def вартість_доставки(місто): time.sleep(0.04) # умовне звернення до повільного довідника return ЦІНИ_ДОСТАВКИ[місто]

Параметр maxsize — скільки різних відповідей кеш тримає одночасно. Коли місць бракує, викидається та, до якої найдовше не зверталися (це і є LRU — least recently used, «найдавніше вживана»). Число тут не косметичне: покрути обидва повзунки й подивись, як воно змінює висновок.

Інтерактив 2 · Скільки обчислень економить кеш

Послідовність замовлень фіксована й однакова для всіх. Бірюзова клітинка — відповідь узята з кешу, рожева — справжнє звернення до довідника.

справжніх обчислень
влучань у кеш
без кешу
з кешем
Постав maxsize=1 — і кеш майже перестане допомагати. Міст вісім, замовлення чергуються, і єдине збережене місто встигає вилетіти до того, як його спитають удруге. Кеш розміром менше за кількість різних запитів — це не маленький кеш, а майже жодного.

Скільки саме кеш зекономив, можна спитати в нього самого — без жодних вимірювань:

>>> вартість_доставки.cache_info() CacheInfo(hits=52, misses=8, maxsize=4, currsize=4)

Поле hits — скільки разів відповідь знайшлась у кеші, misses — скільки разів довелося рахувати насправді. Є й cache_clear(), щоб усе забути. Обидва методи lru_cache чіпляє прямо на задекоровану функцію — це, до речі, ще один спосіб побачити, що декоратор повернув інший об'єкт: у звичайної функції жодних cache_info немає.

Для профі: чому аргументи мають бути хешованими. Кеш усередині — звичайний словник, а ключ у ньому складається з аргументів виклику. Зі теми 08 ми знаємо: ключем словника може бути лише хешований об'єкт. Тому вартість_доставки(["Київ", "Львів"]) впаде з TypeError: unhashable type: 'list' — і не в тілі функції, а ще на вході, в обгортці. Список як аргумент доводиться перетворювати на кортеж. Друга ціна — пам'ять: кеш тримає посилання і на аргументи, і на результати, тому @lru_cache на методі об'єкта не дає цьому об'єкту померти, поки живий кеш класу. maxsize=None (або декоратор @cache) знімає межу зовсім — зручно для чистих обчислень і небезпечно для всього іншого.

І ще одне обмеження, яке випливає з визначення: кешувати можна лише чисту функцію в сенсі теми 14 — таку, що на однакових аргументах завжди дає однакову відповідь. Задекоруєш @lru_cache функцію, яка читає файл або питає поточний час, — і вона назавжди застрягне на першій відповіді.

10 / ПорядокКілька декораторів одразу

Декоратори складаються. Над однією функцією їх може стояти скільки завгодно — і тоді важливо, у якому порядку вони застосовуються.

Схема 4 · Хто кого обгортає

Ліворуч — код і його розгортка. Праворуч — цибулина: хто всередині кого. Знизу — порядок виводу при одному виклику.

застосуваннязнизу вгору: спершу таймер, потім логувати
виконанняззовні всередину: перший рядок друкує логувати
запам'ятай
Найближчий до def декоратор обгортає функцію першим — і тому опиняється найглибше, найближче до неї ж.
Практичний наслідок: @lru_cache над таймером зміряє лише перший, справжній виклик — далі відповідь віддає кеш, і таймер не запуститься зовсім. Поміняй їх місцями — і таймер міритиме роботу кешу. Обидва варіанти правильні, але відповідають на різні питання.

Правило коротко: застосування — знизу вгору, виконання — ззовні всередину. Легко запам'ятати через розгортку: підсумок = логувати(таймер(підсумок)). Внутрішні дужки рахуються першими — це той декоратор, що стоїть нижче.

11 / Не лише функціїКласи й методи

Декоратор працює з будь-яким об'єктом, який можна викликати, — отже, і з класами теж. Механіка та сама: @dataclass над класом означає Продаж = dataclass(Продаж). Тепер зрозуміло, чому декоратор класу вимагав анотацій: він отримує готовий клас і читає його __annotations__, як звичайна функція читала б словник.

Методи декоруються так само, як функції, — вони й є функції всередині класу. Три готових декоратори зустрінуться тобі в чужому коді постійно:

декораторщо робить
@propertyметод читається як звичайне поле: чек.сума замість чек.сума() — зручно для обчислюваних значень
@staticmethodфункція живе в класі за змістом, але не отримує self і нічого про об'єкт не знає
@classmethodпершим аргументом отримує сам клас, а не об'єкт — типово для альтернативних конструкторів

Усі три — звичайні вбудовані декоратори без жодної магії. Коли пишеш свій декоратор для методу, пам'ятай лише одне: self прийде в обгортку першим позиційним аргументом і поїде далі в *args сам собою. Нічого спеціального робити не треба.

12 / МежаМежа розумного

Тепер про те, чого не пишуть у підручниках. Декоратор — інструмент із побічним ефектом: він ховає поведінку. Читач бачить виклик підсумок(28.5, 2), а насправді виконується щось інше, і дізнатися про це можна лише піднявши очі на рядок над def — або, якщо декоратор чужий, пішовши читати його код.

Просте правило перевірки: якщо читач мусить піти подивитись на декоратор, щоб зрозуміти, що робить функція, — краще звичайний виклик.

Ознаки того, що межу перейдено:

Хороший декоратор — прозорий: прибери його, і функція працюватиме як раніше, просто без журналу, без кешу, без таймера. Саме тому @lru_cache, @wraps і @dataclass прижились, а декоратор, що змінює формат відповіді, — ні.

13 / ПідсумокЩо лишається

Ця тема закриває обіцянку, дану в темі 15, і остаточно пояснює @dataclass з теми 28. Лишилось одне велике питання блоку: усе, що ми писали досі, виконується по черзі — рядок за рядком, виклик за викликом. Але коли програма чекає на відповідь з мережі, вона просто стоїть і нічого не робить. У темі 32 ми розберемо асинхронність — спосіб дозволити програмі зайнятися чимось іншим, поки триває очікування. І виявиться, що async def — теж не магія, а щось дуже схоже на те, що ти щойно розібрав.

Далі в практиці. У practice.ipynb ти напишеш замикання-лічильник і подивишся на його __closure__; зробиш свій декоратор часу двома способами — через = і через @ — і доведеш assert-ом, що результат не змінився; побачиш на власні очі, як без wraps функція втрачає ім'я, і полагодиш це; напишеш @повторити(спроб=3) й змусиш його спрацювати з третьої спроби; зміряєш секундоміром, скільки часу економить lru_cache; напишеш власний кеш і assert-ом порівняєш його з бібліотечним; і зловиш TypeError на нехешованому аргументі.

Далі в темі

Теорію прочитано. Тепер закріпи її на практиці.