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

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

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

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

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

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

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

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

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

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

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

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). А ще ця функція вміє викликати лише те, що приймає рівно два аргументи.

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

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

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

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

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

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

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

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

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

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

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

Кадр помирає, комірка живе

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

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

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

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

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

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

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

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

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

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

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

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

Що означає рядок з @

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

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

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

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

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

Один виклик, дві функції

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

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

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

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

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

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

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

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

Ремонт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 обгортка

Що губиться без functools.wraps

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

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

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

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

Хочеться писати не просто @повторити, а @повторити(спроб=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 віддає рівень нижче. Читай знизу вгору: обгортка робить роботу, декоратор віддає обгортку, повторити віддає декоратор. Механіка обробки помилок — з теми 19: return усередині try виходить з функції одразу, тому наступної спроби не буде, якщо ця вдалась.

Три рівні: хто кого віддає і коли спрацьовує

Два верхні рівні виконуються по одному разу — при читанні файлу. Третій — при кожному виклику.

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

З коробкиГотові декоратори: 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, «найдавніше вживана»). Число тут не косметичне: покрути обидва повзунки й подивись, як воно змінює висновок.

Скільки обчислень економить кеш

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

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

Скільки саме кеш зекономив, можна спитати в нього самого — без жодних вимірювань. Ось відповідь після тих самих шістдесяти замовлень, що в інтерактиві вище, і з тим самим maxsize=4:

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

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

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

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

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

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

Хто кого обгортає

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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