Урок 19. Функції як об'єкти першого класу¶
Карантин 2020-го минув, а доставка лишилася. Система «Смачно + Таксі» з фінального проєкту модуля 1 працює щодня, і власниця щотижня просить щось нове. Нова команда drivers. Промокод SUMMER10 на літо, STUDENT15 для студентів, NIGHT5 для нічних замовлень. Сортувати доставки то за вартістю, то за районом. Надсилати клієнтові SMS, коли водій виїхав, а тепер ще й у Telegram.
Кожне таке прохання в коді модуля 1 означає ще один elif в app.py, ще одну майже однакову функцію apply_summer10, ще один if channel == "sms". Код росте не від нових ідей, а від копіювання.
Усе це розв'язує одна ідея, з якої починається модуль 2: функція — це такий самий об'єкт, як число чи рядок. Її можна покласти у змінну, у словник, передати в іншу функцію, повернути з функції. Декоратори з уроку 10 вже спиралися на цю ідею — сьогодні розберемо її повністю.
Що потрібно з попередніх уроків: функції й return (урок 8), декоратори (урок 10), генератори (урок 11), словники й Counter (уроки 6, 13, 17), фінальний проєкт модуля 1 (урок 18).
Після уроку ти зможеш:
- зберігати функції в змінних, списках і словниках, будувати словник команд замість ланцюжка
if; - передавати функцію як аргумент:
key=дляsortedіmax, зворотні виклики (callback); - створювати функції іншою функцією — фабрики й замикання, змінювати захоплену змінну через
nonlocal; - писати
lambdaі обирати міжmap/filterі comprehension; - збирати конвеєр з функцій і пояснювати, чому його кроки мають бути чистими.
Задача розділу. Оформлення замовлення: промокод із реєстру, вартість доставки за районом і округлення — як конвеєр з функцій, а команди системи — як словник. Повний код — у розділі «Практика».
Ноутбук заняття: Відкрити вправи в Colab Переглянути розв’язки
Пригадай¶
Дай відповідь подумки, нічого не запускаючи:
- Що отримує на вхід і що повертає декоратор
require_roleз уроку 10? - Що робить
max(revenue, key=revenue.get)з уроку 15? - Як
app.pyфінального проєкту вирішує, яку команду виконати?
Відповіді
- Отримує функцію, повертає нову функцію-обгортку. Уже тоді функція була даними.
- Повертає ключ словника з найбільшим значенням:
maxвикликаєrevenue.getдля кожного ключа і порівнює результати. - Ланцюжком
if command == "add-order" …,if command == "import" …— по одному на кожну команду.
Функція — це об'єкт¶
Ім'я функції без дужок — це сама функція. З дужками — її виклик і результат:
def fee_for(district):
"""Вартість доставки за районом."""
return {"Поділ": 60, "Оболонь": 80}.get(district, 100)
print(type(fee_for))
print(fee_for.__name__)
print(fee_for("Поділ"))
fee_for — об'єкт типу function з іменем і докстрінгом, як 540 — об'єкт типу int. А раз це об'єкт, з ним можна робити те саме, що з будь-яким значенням. Покласти в іншу змінну:
Нової функції не створилося: delivery_fee і fee_for — два ярлики одного об'єкта, як b = a для списку в уроці 3.
Покласти в список і пройти циклом:
bills = [540.0, 320.0, 980.0]
for summary in [len, sum, max]:
print(summary.__name__, summary(bills))
Функції, які об'єкт мови дозволяє зберігати, передавати й повертати так само, як будь-яке значення, називають об'єктами першого класу (first-class objects). У Python функції саме такі.
Словник команд замість ланцюжка if¶
У фінальному проєкті app.py вибирав команду ланцюжком if. Кожна нова команда — ще одна гілка, і функція run росте без кінця. Але якщо функції — дані, то «яку функцію викликати для якої команди» — це просто словник:
def cmd_report(orders):
return f"Замовлень: {len(orders)}, виторг: {sum(orders):.2f} грн"
def cmd_top(orders):
return f"Найбільший чек: {max(orders):.2f} грн"
COMMANDS = {
"report": cmd_report,
"top": cmd_top,
}
def run(command, orders):
handler = COMMANDS.get(command)
if handler is None:
return f"Помилка: невідома команда {command}. Є: {', '.join(COMMANDS)}"
return handler(orders)
orders = [540.0, 320.0, 980.0]
print(run("report", orders))
print(run("top", orders))
print(run("fly", orders))
Замовлень: 3, виторг: 1840.00 грн
Найбільший чек: 980.00 грн
Помилка: невідома команда fly. Є: report, top
Нова команда — нова функція і один рядок у словнику. run не змінюється. Такий словник називають таблицею диспетчеризації (dispatch table). Бонус: список доступних команд для підказки береться з того самого словника й ніколи не застаріє.
Функція як аргумент¶
Функцію, яка приймає або повертає іншу функцію, називають функцією вищого порядку. Ти вже користувався ними: sorted, max, min приймають параметр key — функцію, яку викликають для кожного елемента, щоб отримати значення для порівняння.
from typing import NamedTuple
class Delivery(NamedTuple):
order_id: int
district: str
fare: int
driver: str
deliveries = [
Delivery(1, "Оболонь", 230, "D-3"),
Delivery(2, "Поділ", 180, "D-1"),
Delivery(3, "Оболонь", 270, "D-3"),
Delivery(4, "Печерськ", 150, "D-2"),
]
def by_fare(delivery):
return delivery.fare
print([d.order_id for d in sorted(deliveries, key=by_fare)])
print(max(deliveries, key=by_fare).order_id)
sorted нічого не знає про доставки. Він лише викликає by_fare(d) для кожного елемента й сортує за результатом. Зміни key — зміниться порядок, а сам sorted той самий.
Ключ може повертати кортеж — тоді порівняння йде по черзі: спершу район, у межах району — вартість:
def by_district_then_fare(delivery):
return (delivery.district, delivery.fare)
for d in sorted(deliveries, key=by_district_then_fare):
print(d.district, d.fare)
Функція, а не її результат
key=by_fare — передаємо функцію. key=by_fare(d) — передали б результат одного виклику, число. sorted спробує викликати число як функцію й впаде з TypeError: 'int' object is not callable. Та сама пастка — if word.isupper: без дужок: це завжди правда, бо функція-об'єкт істинна.
Callback: що робити, вирішує той, хто викликає¶
Водій виїхав — клієнта треба повідомити. Як саме — SMS, Telegram чи просто запис у журнал — функція доставки знати не повинна. Вона отримує функцію повідомлення аргументом і викликає її у потрібний момент. Таку функцію називають зворотним викликом (callback):
def dispatch(delivery, notify):
"""Відправляє водія і повідомляє клієнта через notify(текст)."""
notify(f"Замовлення №{delivery.order_id}: водій {delivery.driver} виїхав, {delivery.fare} грн")
def notify_sms(text):
print("SMS:", text)
def notify_telegram(text):
print("Telegram:", text)
dispatch(deliveries[0], notify_sms)
dispatch(deliveries[1], notify_telegram)
Новий канал — нова функція, dispatch не змінюється. А в тестах замість справжнього SMS можна передати sent.append і перевірити, що саме пішло б клієнтові:
sent.append — теж функція: метод конкретного списку. Передаємо його без дужок, і dispatch викликає його як будь-який інший notify.
Функція, що створює функції¶
Промокоди. Наївно — функція на кожен:
Функції-близнюки відрізняються одним числом. Можна додати параметр — apply_discount(bill, percent), — але тоді кожне місце виклику має знати відсоток, а реєстр «код → знижка» знову стає ланцюжком if. Краще функція, яка створює функцію-знижку з потрібним відсотком:
def make_discount(percent):
"""Фабрика: повертає функцію, що знижує чек на percent відсотків."""
def apply(bill):
return round(bill * (100 - percent) / 100, 2)
return apply
summer10 = make_discount(10)
student15 = make_discount(15)
print(summer10(540.0), student15(540.0))
print(summer10.__name__)
make_discount — фабрика функцій: кожен виклик створює нову функцію apply. І кожна apply пам'ятає свій percent, хоча make_discount давно завершилась. Функцію разом із запам'ятованими змінними зовнішньої функції називають замиканням (closure).
Тепер реєстр промокодів — словник, як таблиця команд:
PROMO = {
"SUMMER10": make_discount(10),
"STUDENT15": make_discount(15),
"NIGHT5": make_discount(5),
}
print(PROMO["NIGHT5"](980.0))
Де функція шукає змінні: LEGB¶
Коли apply бачить percent, Python шукає ім'я в чотирьох місцях по черзі — правило LEGB:
flowchart TD
classDef step fill:#eceff1,stroke:#546e7a,stroke-width:1px,color:#1a1a1a;
classDef decision fill:#e3f2fd,stroke:#1565c0,stroke-width:2px,color:#1a1a1a;
classDef success fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px,color:#1a1a1a;
classDef error fill:#ffebee,stroke:#c62828,stroke-width:3px,color:#1a1a1a;
classDef warning fill:#fff8e1,stroke:#e65100,stroke-width:2px,color:#1a1a1a;
L["L — Local<br>змінні самої apply: bill"] --> E["E — Enclosing<br>змінні make_discount: percent"]
E --> G["G — Global<br>імена модуля: PROMO"]
G --> B["B — Built-in<br>round, len, print"]
B --> X["не знайшли<br>NameError"]
class L,E,G,B step
class X error
percent знайшовся на рівні E — у функції, що оточує apply. Саме тому apply бачить його й після того, як make_discount повернула результат.
nonlocal: змінити захоплену змінну¶
Читати змінну з оточення можна вільно. А змінити? Лічильник номерів замовлень:
def make_counter_broken():
count = 0
def next_id():
count += 1
return count
return next_id
try:
make_counter_broken()()
except UnboundLocalError as error:
print(type(error).__name__)
Присвоєння count += 1 робить count локальною змінною next_id, і Python не шукає її в оточенні. А локальна count ще не має значення. nonlocal каже: це змінна з оточуючої функції, змінюй її там:
def make_counter(start=0):
count = start
def next_id():
nonlocal count
count += 1
return count
return next_id
new_order_id = make_counter()
print(new_order_id(), new_order_id(), new_order_id())
other = make_counter(100)
print(other(), new_order_id())
Кожен виклик make_counter створює свій лічильник: other і new_order_id не заважають один одному. Функція, що носить із собою власний стан, — це вже майже об'єкт. Саме туди приведе урок 20 про класи.
Пастка: функції в циклі
Усі три функції бачать ту саму змінну fee — і вона вже дорівнює 100, коли функції викликають. Замикання пам'ятає змінну, а не її значення в момент створення. Рішення — фабрика: [make_fee(fee) for fee in (60, 80, 100)], де кожен виклик make_fee має свій fee.
lambda, map і filter¶
Для короткої функції, яка потрібна один раз — зазвичай як key — є lambda: анонімна функція з одного виразу.
print([d.order_id for d in sorted(deliveries, key=lambda d: d.fare)])
print(max(deliveries, key=lambda d: d.fare).driver)
lambda d: d.fare — те саме, що by_fare, тільки без імені й без return: результат виразу повертається автоматично. Якщо функції потрібне ім'я, докстрінг чи більше одного рядка — пиши звичайний def.
map(функція, дані) застосовує функцію до кожного елемента, filter(функція, дані) лишає елементи, для яких вона повернула True. Обидві ліниві, як генератори з уроку 11:
fares = [230, 180, 270, 150]
with_fee = map(lambda fare: fare + 20, fares)
print(type(with_fee).__name__)
print(list(with_fee))
print(list(filter(lambda fare: fare >= 200, fares)))
Те саме через comprehension з уроку 6 — зазвичай читається легше:
Коли що
Comprehension — коли пишеш вираз прямо тут. map / filter — коли функція вже є й має ім'я: list(map(str.upper, districts)), list(filter(str.isdigit, codes)). lambda — для коротких key=.
Конвеєр з функцій¶
Ціна замовлення проходить кілька кроків: знижка за промокодом, вартість доставки за районом, округлення до гривні. Кожен крок — функція «число → число». А раз функції — дані, список кроків — теж дані:
def add_fee(bill):
return bill + 80
def to_uah(bill):
return round(bill)
def run_pipeline(value, steps):
for step in steps:
value = step(value)
return value
checkout = [PROMO["SUMMER10"], add_fee, to_uah]
print(run_pipeline(540.0, checkout))
print(run_pipeline(540.0, [add_fee, PROMO["SUMMER10"], to_uah]))
Той самий чек, ті самі кроки — різний результат. У другому варіанті знижку отримала й доставка. Порядок кроків — це бізнес-правило, і тепер воно записане в одному місці: у списку checkout, а не розмазане по коду.
flowchart TD
classDef step fill:#eceff1,stroke:#546e7a,stroke-width:1px,color:#1a1a1a;
classDef decision fill:#e3f2fd,stroke:#1565c0,stroke-width:2px,color:#1a1a1a;
classDef success fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px,color:#1a1a1a;
classDef error fill:#ffebee,stroke:#c62828,stroke-width:3px,color:#1a1a1a;
classDef warning fill:#fff8e1,stroke:#e65100,stroke-width:2px,color:#1a1a1a;
A["540.0<br>чек кафе"] --> B["SUMMER10<br>486.0"]
B --> C["add_fee<br>566.0"]
C --> D["to_uah<br>566"]
class A,B,C step
class D success
Кроки мають бути чистими¶
Конвеєр працює, лише якщо кожен крок чистий (урок 8): отримує значення, повертає нове й нічого не змінює навколо. Крок, який змінює вхідний список, ламає все, що йде після нього — і все, що використовує той самий список:
def add_packaging_bad(items):
items.append("пакет")
return items
def add_packaging(items):
return items + ["пакет"]
cart = ["борщ", "вареники"]
print(add_packaging(cart), cart)
print(add_packaging_bad(cart), cart)
['борщ', 'вареники', 'пакет'] ['борщ', 'вареники']
['борщ', 'вареники', 'пакет'] ['борщ', 'вареники', 'пакет']
Чиста версія повернула новий список і не зачепила cart. Нечиста змінила кошик клієнта: після другого виклику «пакет» опиниться там двічі.
Декоратор — це теж фабрика¶
Декоратор з уроку 10 тепер розкладається на знайомі частини: функція, яка приймає функцію (вищого порядку), створює нову (фабрика) і пам'ятає оригінал (замикання):
from functools import wraps
def log_calls(func):
@wraps(func)
def wrapper(*args):
result = func(*args)
print(f"{func.__name__}{args} -> {result}")
return result
return wrapper
@log_calls
def add_fee_logged(bill):
return bill + 80
add_fee_logged(486.0)
Практика¶
Розібраний приклад: оформлення замовлення¶
Зберемо все разом: реєстр промокодів, вартість доставки за районом, конвеєр і команди.
540.0 SUMMER10 Оболонь -> 566
320.0 None Поділ -> 380
980.0 STUDENT15 Печерськ -> 923
540.0 FREE100 Поділ -> помилка: промокод FREE100 не дійсний
540.0 None Троєщина -> помилка: не доставляємо в район Троєщина
Що відбувається в ключових рядках:
- рядок 2 — вартість доставки — дані, а не
ifдля кожного району; - рядки 6–8, 12–13 — фабрика
make_feeперевіряє район один раз, а функціяaddпам'ятаєfeeчерез замикання; - рядки 22, 25 — список кроків збирається з функцій: промокод береться з реєстру
PROMO, доставка — з фабрики, округлення — готоваto_uah; - рядки 31–33 —
checkoutне знає, які саме кроки виконує. Новий крок — рядок уcheckout_steps, а не переписанийcheckout; - помилки —
ValueErrorз поясненням, як у модулі 1: промокод і район перевіряються до обчислень.
Зміни приклад: команда drivers¶
Додай у таблицю COMMANDS з розділу про словник команд нову команду drivers. Вона отримує список доставок і повертає рядок з водіями за виторгом, від більшого до меншого:
Критерії перевірки:
runне змінюється — лише нова функція і рядок уCOMMANDS;- виторг рахується за один прохід (
Counterабо словник зget); - сортування —
sorted(..., key=…, reverse=True)зlambdaабоdict.getяк ключем; run("fly", deliveries)у підказці показує й нову команду.
Підказка
Counter уміє додавати: totals[d.driver] += d.fare. Потім sorted(totals, key=totals.get, reverse=True) дає водіїв у потрібному порядку, а ", ".join(f"{name} {totals[name]}" for name in …) — рядок.
Спробуй самостійно: промокод з обмеженням¶
Маркетинг роздає обмежені промокоди: LUCKY20 діє лише для перших трьох замовлень. Напиши фабрику make_limited_discount(percent, uses):
- повертає функцію
apply(bill)зі знижкоюpercent, якmake_discount; - кожен виклик зменшує кількість використань, що лишилися;
- коли використань не лишилося —
applyпіднімаєValueError("промокод вичерпано").
lucky = make_limited_discount(20, 3)
lucky(500.0) → 400.0
lucky(500.0) → 400.0
lucky(250.0) → 200.0
lucky(500.0) → ValueError: промокод вичерпано
Правила:
- лічильник живе в замиканні,
nonlocal— без глобальних змінних; - два промокоди, створені окремими викликами фабрики, мають незалежні лічильники;
applyможна покласти вPROMOі використати вcheckoutбез змін.
Знайди помилку¶
Кожен фрагмент — справжня помилка початківця. Що піде не так?
# 1
sum = sum([540.0, 320.0])
total_fares = sum([230, 180])
# 2
code = "summer10"
if code.isupper:
print("код у верхньому регістрі")
# 3
districts = ["Оболонь", "Поділ", "Печерськ"]
print(sorted(districts, key=len(districts)))
Відповіді
- Перший рядок перезаписав вбудовану функцію
sumчислом860.0. Другий рядок пробує викликати число:TypeError: 'float' object is not callable. Не називай змінні іменами вбудованих функцій:total_bills = sum(...). code.isupperбез дужок — сама функція, а функція завжди істинна. Умова виконається для будь-якого рядка. Потрібен виклик:code.isupper().len(districts)— це3, число, а не функція.sortedспробує викликати3(...)і впаде зTypeError. Треба передати саму функцію:key=len.
Підсумок¶
| Поняття | Що запам'ятати |
|---|---|
| Функція — об'єкт | f — функція, f() — її результат; можна в змінну, список, словник |
| Словник команд | COMMANDS[name](...) замість ланцюжка if; нова команда — рядок |
| Функція вищого порядку | приймає чи повертає функцію: sorted(key=…), max(key=…), декоратори |
| Callback | функція-аргумент, яку викликають у потрібний момент |
| Фабрика й замикання | функція створює функцію, а та пам'ятає змінні оточення |
LEGB, nonlocal |
порядок пошуку імен; nonlocal, щоб змінити змінну оточення |
lambda, map, filter |
коротка анонімна функція; ліниві map / filter чи comprehension |
| Конвеєр | список функцій-кроків; порядок — правило; кроки — чисті |
Самоперевірка¶
- Чим
fee_forвідрізняється відfee_for("Поділ")? - Що дає словник команд порівняно з ланцюжком
if? - Навіщо
dispatchприймаєnotifyаргументом, а не викликає SMS сама? - Чому
summer10пам'ятаєpercent = 10, хочаmake_discountвже завершилась? - Чому без
nonlocalлічильник падає зUnboundLocalError? - Чому всі функції з
[lambda bill: bill + fee for fee in (60, 80, 100)]додають 100? - Чому порядок кроків у конвеєрі змінює ціну? Що буде, якщо крок змінює вхідний список?
Відповіді
fee_for— сама функція, об'єкт.fee_for("Поділ")— результат виклику, число60.- Нова команда — функція й один рядок у словнику,
runне змінюється; перелік команд для підказки береться з того самого словника. - Щоб спосіб повідомлення обирав той, хто викликає: SMS, Telegram, журнал чи список у тесті.
dispatchвід цього не змінюється. apply— замикання: разом із функцією зберігаються змінні оточуючої функції, які вона використовує.- Присвоєння
count += 1робитьcountлокальною змінноюnext_id, а локальна ще не має значення.nonlocalкаже шукати й змінювати її в оточуючій функції. - Замикання пам'ятає змінну
fee, а не її значення в момент створення. Коли функції викликають, цикл уже завершився, іfeeдорівнює 100. - Кожен крок працює з результатом попереднього: знижка до доставки і після — різні суми. Крок, що змінює вхідний список, змінює дані для всіх наступних кроків і для всього коду, який тримає той самий список.
Що далі¶
- Ноутбук заняття: Відкрити вправи в Colab Переглянути розв’язки — сервіс доставки: прогнози, вправи з перевірками, три баги і міні-проєкт — конвеєр для промокодів.
- Поглиблення — п'ять патернів покроково, «від проблеми до рішення»: Callback, Function Factory, Decorator, Pipeline, Pure Functions.
- Наступне заняття — урок 20 «Класи, простір імен». Лічильник
make_counter— це дані й функція разом. Клас зробить це явно: дані — атрибути, функції — методи.
Документація і джерела¶
- Functional Programming HOWTO — функції вищого порядку,
map,filter,lambda - Sorting Techniques: Key Functions
- Туторіал: Lambda Expressions; довідник:
nonlocal, правила пошуку імен map,filter,functools.wraps- Для охочих: MIT 6.0001, лекція 4 «Decomposition, Abstraction, Functions» — функції, специфікації, область видимості, функції як аргументи.