dataclass, match и шаблони
Проблем
Мястото е заето, но речникът не го намира
Резервации в кино: ключът е мястото (ред и номер), стойността е името на човека.
class Seat: def __init__(self, row, number): self.row = row self.number = numberreservations = {}reservations[Seat(5, 12)] = "Мария"
print(reservations.get(Seat(5, 12)))print(Seat(5, 12) in reservations)NoneFalseget връща None. Помниш защо от урока за хеширането: Seat(5, 12) два пъти са два различни обекта, а Seat не казва кога са равни. Поправката е __eq__ и __hash__, а за отпечатване и __repr__. Още десетина реда за клас, който само носи две числа.Идеята
@dataclass: клас за данни
@dataclass същото е в няколко реда:from dataclasses import dataclass
@dataclass(frozen=True)class Seat: row: int number: intreservations = {}reservations[Seat(5, 12)] = "Мария"
print(reservations.get(Seat(5, 12)))print(Seat(5, 12) in reservations)
seat = Seat(5, 12)print(seat)print(f"Ред {seat.row}, място {seat.number}")МарияTrueSeat(row=5, number=12)Ред 5, място 12@dataclass (от модула dataclasses) пред класа казва, че работата му е да носи данни. Полетата се пишат в тялото с типа си: row: int. По тях Python пише вместо теб:
- конструктор
__init__с всички полета; __eq__, което сравнява полетата;- с
frozen=True: забрана да се сменят полетата след създаването и__hash__, който се смята от полетата. Затоваgetвече намира Мария; __repr__във видаSeat(row=5, number=12).
Полетата се четат направо: seat.row. Обект с frozen=True не се променя след създаването си.
reservations = {}reservations[Seat(5, 12)] = "Мария"
print(reservations.get(Seat(5, 12)))print(Seat(5, 12) in reservations)
seat = Seat(5, 12)print(seat)print(f"Ред {seat.row}, място {seat.number}")Стъпка по стъпка, с думи
- Празен речник: ключът е място в киното, стойността е име.
- Записът слага ключ Seat(5, 12) със стойност „Мария“.
- get получава нов обект Seat. Това е друг обект в паметта, не ключът от записа.
- __hash__ на dataclass се смята от полетата, затова е еднакъв: търсенето отива на същото място.
- __eq__ на dataclass сравнява поле по поле: ред 5 и 5, място 12 и 12. Равни са, и get връща „Мария“.
- in прави същото с още един нов Seat: True.
- __repr__ dataclass-ът също пише сам, а полетата се четат направо: seat.row.
По-точно, за любопитните
- Без
frozen=Trueполетата може да се сменят, а__hash__не се пише. Тогава обектът не може да е ключ в речник:
from dataclasses import dataclass
@dataclassclass Seat: row: int number: int
seat = Seat(5, 12)seat.row = 6print(seat)try: reservations = {seat: "Мария"}except TypeError as e: print("TypeError:", e)Seat(row=6, number=12)TypeError: unhashable type: 'Seat'- Опит да се смени поле на
frozenобект спира сFrozenInstanceError. А типът на полето е само подсказка: Python не го проверява, иSeat("пети", 12)минава:
from dataclasses import dataclass
@dataclass(frozen=True)class Seat: row: int number: int
seat = Seat(5, 12)try: seat.row = 6except AttributeError as e: print(type(e).__name__ + ":", e)print(Seat("пети", 12))FrozenInstanceError: cannot assign to field 'row'Seat(row='пети', number=12)- Неизменността е само на първо ниво. Ако полето е списък, то винаги сочи същия списък, но самият списък може да се променя:
from dataclasses import dataclass
@dataclass(frozen=True)class Cart: items: list
cart = Cart(["кафе"])cart.items.append("мед")print(cart)Cart(items=['кафе', 'мед'])- В урока за хеширането правеше
__eq__и__hash__на ръка. С@dataclassтова вече го има.
Идеята
Проверка и методи в dataclass
__post_init__: @dataclass го вика веднага след като е записал полетата.from dataclasses import dataclass
@dataclass(frozen=True)class Price: amount: float currency: str
def __post_init__(self): if self.amount < 0: raise ValueError(f"Цената не може да е отрицателна: {self.amount}")
def with_discount(self, percent): return Price(self.amount * (1 - percent / 100), self.currency)coffee = Price(18.50, "EUR")sale = coffee.with_discount(20)print(coffee)print(sale)
try: Price(-5, "EUR")except ValueError as e: print(e)Price(amount=18.5, currency='EUR')Price(amount=14.8, currency='EUR')Цената не може да е отрицателна: -5- В
__post_init__полетата вече са записани, затова проверката четеself.amount. Ако тя хвърли изключение, обектът не се създава. with_discountне променя цената. Връща новаPrice. Така се работи с обекти, които не се променят:coffeeси остава 18,50 €.
Пробвай
Равни ли са?
Seat е @dataclass(frozen=True), както в картата преди тази.Какво ще отпечата програмата?
a = Seat(5, 12)b = Seat(5, 12)print(a is b)print(a == b)print(hash(a) == hash(b))a и b са два обекта, затова is е False: той пита дали е същият обект. == вика __eq__ на dataclass-а, а той сравнява полетата и те са еднакви. hash също е от полетата, затова е еднакъв. Точно това иска речникът.Пробвай
Доставката излиза скъпа
Доставката до офис е 4,50 €, а програмата казва 6,90 €. На кой ред е грешката?
Избери реда с грешката.
method == "куриер" or "до адрес" се чете като (method == "куриер") or "до адрес". Първата част е False, но "до адрес" е непразен текст, а той е „истина“. Условието е вярно за всеки начин на доставка, и офисът никога не стига до своя elif. Поправката е method in ("куриер", "до адрес"). А още по-ясно е match, в който всеки случай изброява стойностите си. Това е следващата карта.Идеята
match: случай по случай
match. Той сравнява стойността със случаите един по един, отгоре надолу, и изпълнява първия, който пасва.def delivery_price(method): match method: case "офис": return 4.50 case "куриер" | "до адрес": return 6.90 case "магазин": return 0 case _: raise ValueError(f"Непознат начин на доставка: {method}")
price = delivery_price("офис")print(f"Доставка: {price} €")Доставка: 4.5 €- Пропадане няма: изпълнява се само кодът на случая, който пасва.
|значи „или“:case "куриер" | "до адрес".case _пасва на всичко. Той е последен и хваща останалото, катоdefaultв Java. Тук хвърля изключение.matchне дава стойност сам. Затова е във функция и всеки случай връща сreturn.- Python не проверява, че всеки вход е покрит. Без
case _за непознат начин не пасва нито един случай: функцията стига до края и връщаNone, без грешка:
def delivery_price(method): match method: case "офис": return 4.50 case "куриер": return 6.90
print(delivery_price("поща"))NoneСлучай с няколко реда е обикновен блок с отстъп:
def delivery(method, weight_kg): match method: case "офис": return 4.50 case "куриер": base = 6.90 return base + 3 if weight_kg > 10 else base case _: return 0
print(delivery("куриер", 4))print(delivery("куриер", 12))6.99.9По-точно, за любопитните
match е от Python 3.10. В по-стар код същото е верига от if и elif.
Идеята
Семейство от типове
Магазинът приема три начина на плащане: карта, банков превод и наложен платеж. Друг няма. Всеки начин е свой клас:
from dataclasses import dataclass
@dataclass(frozen=True)class CardPayment: card_number: str
@dataclass(frozen=True)class BankTransfer: iban: str
@dataclass(frozen=True)class CashOnDelivery: pass
Payment = CardPayment | BankTransfer | CashOnDeliveryВсеки начин на плащане е само данни: номер на карта, IBAN или нищо. Затова са @dataclass. CashOnDelivery няма полета: тялото му е pass.
Последният ред е подсказка за типа: Payment е „едно от трите“. В Java sealed кара компилатора да откаже четвърти тип. В Python такава забрана няма: подсказката само описва намерението, и Python не я проверява. Четвърти клас може да се появи навсякъде. Как да се пазиш от него, е в следващата карта.
Виж го
Шаблони в match
match може да проверява и типа на обекта:def fee(payment, amount): match payment: case CardPayment(): return amount * 0.015 case BankTransfer(): return 0.50 case CashOnDelivery(): return 2.00print(fee(CardPayment("4111222233334444"), 200))print(fee(BankTransfer("BG80BNBG96611020345678"), 200))print(fee(CashOnDelivery(), 200))3.00.52.0case CardPayment(): значи „ако обектът е от тип CardPayment“. Скобите тук не създават обект: това е шаблон, а не извикване. Същото е като isinstance(payment, CardPayment), но за много типове наведнъж.
Ако забравиш тип, Python не казва нищо. Нито един случай не пасва и функцията връща None:
def fee(payment, amount): match payment: case CardPayment(): return amount * 0.015 case BankTransfer(): return 0.50
print(fee(CashOnDelivery(), 200))NoneЗатова сложи в края case _, който хвърля изключение. Python пак няма да провери предварително, но поне програмата ще спре с ясно съобщение на мястото на пропуска:
def fee(payment, amount): match payment: case CardPayment(): return amount * 0.015 case BankTransfer(): return 0.50 case _: raise TypeError(f"Непознато плащане: {payment}")
try: fee(CashOnDelivery(), 200)except TypeError as e: print(e)Непознато плащане: CashOnDelivery()Шаблонът за клас вади полетата направо: CardPayment(number) слага номера на картата в number. Това става, защото @dataclass записва и реда на полетата. if след шаблона е пазач: случаят важи само ако условието е вярно.
def describe(payment): match payment: case CardPayment(number): return f"карта ****{number[-4:]}" case BankTransfer(iban) if iban.startswith("BG"): return "превод от българска сметка" case BankTransfer(iban): return "превод от чужбина" case CashOnDelivery(): return "наложен платеж"print(describe(CardPayment("4111222233334444")))print(describe(BankTransfer("BG80BNBG96611020345678")))print(describe(BankTransfer("DE89370400440532013000")))print(describe(CashOnDelivery()))карта ****4444превод от българска сметкапревод от чужбинаналожен платежПо-точно, за любопитните
- Едно плащане се проверява и с
isinstance. А в шаблона полето може да се посочи и по име:CardPayment(card_number=number).
payment = CardPayment("4111222233334444")if isinstance(payment, CardPayment): print("Картата завършва на " + payment.card_number[12:])
match payment: case CardPayment(card_number=number): print("И с име на полето: " + number[-4:])Картата завършва на 4444И с име на полето: 4444- Шаблоните се вгнездяват:
case Order(Customer(name), total).
Пробвай
Кой случай печели?
Какво ще отпечата програмата?
def label(payment, amount): match payment: case CashOnDelivery() if amount > 500: return "наложен платеж: не се приема над 500 €" case CashOnDelivery(): return "наложен платеж" case CardPayment() if amount > 1000: return "карта: нужно е потвърждение" case CardPayment(): return "карта" case BankTransfer(): return "превод"
print(label(CashOnDelivery(), 650))print(label(CashOnDelivery(), 80))print(label(CardPayment("4111222233334444"), 650))if и стига до втория. Картата е за 650 €, а потвърждение се иска над 1000 €, затова печели случаят без условие: „карта“.По-точно, за любопитните
Случаят с пазач трябва да е преди общия за същия тип. Обратното не е грешка: общият случай хваща всичко, и този с пазача просто никога не се стига:
def label(payment, amount): match payment: case CashOnDelivery(): return "наложен платеж" case CashOnDelivery() if amount > 500: return "наложен платеж: не се приема над 500 €"
print(label(CashOnDelivery(), 650))наложен платежPython спира само при case _, който не е последен. Той хваща всичко, и кодът не тръгва изобщо:
code = """match payment: case _: print("всичко") case CashOnDelivery(): print("наложен платеж")"""try: compile(code, "shop.py", "exec")except SyntaxError as e: print("SyntaxError:", e.msg)SyntaxError: wildcard makes remaining patterns unreachableИдеята
Текст на няколко реда
Текст на няколко реда, като касова бележка или JSON, с " и \n на всеки ред се чете трудно. В Python текстът между """ и """ може да е на няколко реда:
receipt = """\Магазин „Ъгъла“Кафе на зърна 18.50 €Мед 12.00 €Общо: 30.50 €"""print(receipt, end="")
name = "Мария"total = 75.5data = f'{{"customer": "{name}", "total": {total:.2f}}}'print(data)Магазин „Ъгъла“Кафе на зърна 18.50 €Мед 12.00 €Общо: 30.50 €{"customer": "Мария", "total": 75.50}\веднага след отварящите"""маха новия ред там, и текстът започва от следващия ред.- Отстъпът се пази, както е написан. Затова тук редовете са най-вляво.
- Кавичките вътре не се екранират.
- f-низът попълва
{name}и{total:.2f}. Къдравите скоби на самия текст се пишат двойно:{{и}}.
По-точно, за любопитните
В тялото на функция редовете имат отстъп. dedent от модула textwrap маха общия отстъп отляво, като текстовия блок в Java:
from textwrap import dedent
def receipt(): return dedent("""\ Кафе на зърна 18.50 € Мед 12.00 € """)
print(receipt(), end="")Кафе на зърна 18.50 €Мед 12.00 €В Java var оставя компилатора да изведе типа на променливата. В Python тип на променливата не се пише: всяка променлива е като var, а типът е на стойността, не на името.
prices = {"кафе": 18.50, "мед": 12.00}for name, price in prices.items(): print(f"{name}: {price} €")кафе: 18.5 €мед: 12.0 €Упражнение
Модерен Python
Реши задачите в редактора си.
1. Фигури. Три класа с@dataclass(frozen=True): Circle с radius, Rectangle с width и height и Triangle с base и height, и подсказка Shape = Circle | Rectangle | Triangle. Напиши area(shape) с match и шаблони за клас, с case _, който хвърля TypeError. Напиши и name(shape), който за правоъгълник с равни страни казва „квадрат“ (с пазач if). Отпечатай кръг с радиус 1, правоъгълник 2 на 3, квадрат 4 на 4, триъгълник с основа 3 и височина 5 и общата площ.кръг: 3.14правоъгълник: 6.00квадрат: 16.00триъгълник: 7.50Общо: 32.64@dataclass(frozen=True): ToOffice с office, ToAddress с city и weight_kg и Pickup без полета. Цената: до офис 4,50 €; до адрес 5,90 € в София и 6,90 € другаде, плюс 1 € за всеки килограм над 5; от магазина 0 €. Отпечатай всяка доставка (с __repr__ на dataclass-а) и цената ѝ.ToOffice(office='Варна Център') -> 4.50 €ToAddress(city='София', weight_kg=2) -> 5.90 €ToAddress(city='Варна', weight_kg=8) -> 9.90 €Pickup() -> 0.00 €Point с @dataclass(frozen=True) и полета x и y и преброй колко пъти е бил във всяка точка в речник с visits.get(p, 0) + 1. Накрая потърси Point(2, 3) в речника.Point(x=2, y=3): 3Point(x=5, y=1): 2Point(x=0, y=0): 1Посещения в (2, 3): 3Още задачи
- Студент като dataclass.
Studentсnameиgradeи__post_init__, който отказва оценка извън 2–6. Подреди списък от студенти сsorted(students, key=lambda s: s.grade). - Ден от седмицата. Пренапиши задачата „ден от седмицата“ от урока „Условия“ с
match:case 1 | 2 | 3 | 4 | 5: return "делник". - Писмо. С текст между
"""и f-низ направи писмо за потвърждение на резервация: име, дата, брой гости и цена в евро.
Тест