Към съдържанието
wannacode

dataclass, match и шаблони

Проблем

Мястото е заето, но речникът не го намира

Резервации в кино: ключът е мястото (ред и номер), стойността е името на човека.

Seat
class Seat:
def __init__(self, row, number):
self.row = row
self.number = number
reservations = {}
reservations[Seat(5, 12)] = "Мария"
print(reservations.get(Seat(5, 12)))
print(Seat(5, 12) in reservations)
Изход
None
False
Мария е на ред 5, място 12, но get връща None. Помниш защо от урока за хеширането: Seat(5, 12) два пъти са два различни обекта, а Seat не казва кога са равни. Поправката е __eq__ и __hash__, а за отпечатване и __repr__. Още десетина реда за клас, който само носи две числа.

Идеята

@dataclass: клас за данни

С @dataclass същото е в няколко реда:
from dataclasses import dataclass
@dataclass(frozen=True)
class Seat:
row: int
number: int
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}")
Изход
Мария
True
Seat(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}")
Стъпка по стъпка, с думи
  1. Празен речник: ключът е място в киното, стойността е име.
  2. Записът слага ключ Seat(5, 12) със стойност „Мария“.
  3. get получава нов обект Seat. Това е друг обект в паметта, не ключът от записа.
  4. __hash__ на dataclass се смята от полетата, затова е еднакъв: търсенето отива на същото място.
  5. __eq__ на dataclass сравнява поле по поле: ред 5 и 5, място 12 и 12. Равни са, и get връща „Мария“.
  6. in прави същото с още един нов Seat: True.
  7. __repr__ dataclass-ът също пише сам, а полетата се четат направо: seat.row.
По-точно, за любопитните
  • Без frozen=True полетата може да се сменят, а __hash__ не се пише. Тогава обектът не може да е ключ в речник:
from dataclasses import dataclass
@dataclass
class Seat:
row: int
number: int
seat = Seat(5, 12)
seat.row = 6
print(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 = 6
except 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))
Отговор

Пробвай

Доставката излиза скъпа

Доставката до офис е 4,50 €, а програмата казва 6,90 €. На кой ред е грешката?

Избери реда с грешката.

Идеята

match: случай по случай

От Python 3.10 има 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.9
9.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.00
print(fee(CardPayment("4111222233334444"), 200))
print(fee(BankTransfer("BG80BNBG96611020345678"), 200))
print(fee(CashOnDelivery(), 200))
Изход
3.0
0.5
2.0

case 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))
Отговор
По-точно, за любопитните

Случаят с пазач трябва да е преди общия за същия тип. Обратното не е грешка: общият случай хваща всичко, и този с пазача просто никога не се стига:

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.5
data = 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
2. Цена на доставка. Три класа с @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 €
3. Точка като ключ. Куриерът минава през точки по картата: (2, 3), (5, 1), (2, 3), (0, 0), (5, 1), (2, 3). Направи Point с @dataclass(frozen=True) и полета x и y и преброй колко пъти е бил във всяка точка в речник с visits.get(p, 0) + 1. Накрая потърси Point(2, 3) в речника.
Изход
Point(x=2, y=3): 3
Point(x=5, y=1): 2
Point(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-низ направи писмо за потвърждение на резервация: име, дата, брой гости и цена в евро.

Тест

Провери се

1. Какво получаваш наготово с @dataclass(frozen=True) над class Seat с полета row: int и number: int?
2. Защо Seat с @dataclass(frozen=True) работи като ключ в речник, а обикновен клас без нищо добавено не?
3. Какво прави case "куриер" | "до адрес": в match?
4. Функция с match няма случай за CashOnDelivery и получи CashOnDelivery(). Какво става?
5. Какво прави case BankTransfer(iban) if iban.startswith("BG"):?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·