dataclass, match and patterns
Problem
The seat is taken, but the dictionary cannot find it
Movie theater reservations: the key is the seat (row and number), the value is the person's name.
class Seat: def __init__(self, row, number): self.row = row self.number = numberreservations = {}reservations[Seat(5, 12)] = "Maria"
print(reservations.get(Seat(5, 12)))print(Seat(5, 12) in reservations)NoneFalseget returns None. You remember why from the lesson on hashing: Seat(5, 12) written twice is two different objects, and Seat does not say when two of them are equal. The fix is __eq__ and __hash__, plus __repr__ for printing. Another ten lines or so for a class that just carries two numbers.The idea
@dataclass: a class for data
@dataclass the same thing takes a few lines:from dataclasses import dataclass
@dataclass(frozen=True)class Seat: row: int number: intreservations = {}reservations[Seat(5, 12)] = "Maria"
print(reservations.get(Seat(5, 12)))print(Seat(5, 12) in reservations)
seat = Seat(5, 12)print(seat)print(f"Row {seat.row}, seat {seat.number}")MariaTrueSeat(row=5, number=12)Row 5, seat 12@dataclass (from the dataclasses module) in front of the class says that its job is to carry data. The fields are written in the body with their type: row: int. From them, Python writes for you:
- a constructor
__init__with all the fields; __eq__, which compares the fields;- with
frozen=True: a ban on changing the fields after creation, and a__hash__computed from the fields. That is whygetnow finds Maria; __repr__in the formSeat(row=5, number=12).
You read the fields directly: seat.row. An object with frozen=True does not change after it is created.
reservations = {}reservations[Seat(5, 12)] = "Maria"
print(reservations.get(Seat(5, 12)))print(Seat(5, 12) in reservations)
seat = Seat(5, 12)print(seat)print(f"Row {seat.row}, seat {seat.number}")Step by step, in words
- An empty dictionary: the key is a theater seat, the value is a name.
- The assignment adds the key Seat(5, 12) with the value "Maria".
- get receives a new Seat object. It is a different object in memory, not the key from the assignment.
- The __hash__ of a dataclass is computed from the fields, so it is the same: the lookup goes to the same place.
- The __eq__ of a dataclass compares field by field: row 5 and 5, seat 12 and 12. They are equal, and get returns "Maria".
- in does the same with yet another new Seat: True.
- The dataclass also writes __repr__ by itself, and the fields are read directly: seat.row.
More precisely, if you are curious
- Without
frozen=Truethe fields can change, and__hash__is not written. Then the object cannot be a key in a dictionary:
from dataclasses import dataclass
@dataclassclass Seat: row: int number: int
seat = Seat(5, 12)seat.row = 6print(seat)try: reservations = {seat: "Maria"}except TypeError as e: print("TypeError:", e)Seat(row=6, number=12)TypeError: unhashable type: 'Seat'- Trying to change a field of a
frozenobject stops with aFrozenInstanceError. And the type of the field is only a hint: Python does not check it, andSeat("fifth", 12)goes through:
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("fifth", 12))FrozenInstanceError: cannot assign to field 'row'Seat(row='fifth', number=12)- The immutability is only one level deep. If the field is a list, it always points to the same list, but the list itself can change:
from dataclasses import dataclass
@dataclass(frozen=True)class Cart: items: list
cart = Cart(["coffee"])cart.items.append("honey")print(cart)Cart(items=['coffee', 'honey'])- In the lesson on hashing you wrote
__eq__and__hash__by hand. With@dataclassyou already have them.
The idea
Checks and methods in a dataclass
__post_init__: @dataclass calls it right after it has set the fields.from dataclasses import dataclass
@dataclass(frozen=True)class Price: amount: float currency: str
def __post_init__(self): if self.amount < 0: raise ValueError(f"The price cannot be negative: {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')The price cannot be negative: -5- In
__post_init__the fields are already set, so the check readsself.amount. If it raises an exception, the object is not created. with_discountdoes not change the price. It returns a newPrice. That is how you work with objects that do not change:coffeestays €18.50.
Try it
Are they equal?
Seat is a @dataclass(frozen=True), as in the card before this one.What will the program print?
a = Seat(5, 12)b = Seat(5, 12)print(a is b)print(a == b)print(hash(a) == hash(b))a and b are two objects, so is is False: it asks whether they are the same object. == calls the dataclass's __eq__, and it compares the fields, and they are the same. hash is also computed from the fields, so it is the same too. That is exactly what the dictionary wants.Try it
The delivery comes out expensive
Delivery to an office is €4.50, but the program says €6.90. Which line is the bug on?
Pick the line with the bug.
method == "courier" or "address" is read as (method == "courier") or "address". The first part is False, but "address" is a non-empty string, and that is "truthy". The condition is true for every delivery method, and the office never gets to its elif. The fix is method in ("courier", "address"). Even clearer is a match, where each case lists its values. That is the next card.The idea
match: case by case
match. It compares the value with the cases one by one, from top to bottom, and runs the first one that matches.def delivery_price(method): match method: case "office": return 4.50 case "courier" | "address": return 6.90 case "store": return 0 case _: raise ValueError(f"Unknown delivery method: {method}")
price = delivery_price("office")print(f"Delivery: €{price}")Delivery: €4.5- There is no fall-through: only the code of the case that matches runs.
|means "or":case "courier" | "address".case _matches everything. It goes last and catches the rest, likedefaultin Java. Here it raises an exception.matchdoes not give a value by itself. So it is inside a function, and every case returns withreturn.- Python does not check that every input is covered. Without
case _, no case matches an unknown method: the function reaches its end and returnsNone, with no error:
def delivery_price(method): match method: case "office": return 4.50 case "courier": return 6.90
print(delivery_price("post"))NoneA case with several lines is a regular indented block:
def delivery(method, weight_kg): match method: case "office": return 4.50 case "courier": base = 6.90 return base + 3 if weight_kg > 10 else base case _: return 0
print(delivery("courier", 4))print(delivery("courier", 12))6.99.9More precisely, if you are curious
match is from Python 3.10. In older code the same thing is a chain of if and elif.
The idea
A family of types
The shop accepts three payment methods: card, bank transfer and cash on delivery. There are no others. Each method is its own class:
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 | CashOnDeliveryEach payment method is just data: a card number, an IBAN or nothing. That is why they are @dataclass. CashOnDelivery has no fields: its body is pass.
The last line is a type hint: Payment is "one of the three". In Java, sealed makes the compiler refuse a fourth type. Python has no such ban: the hint only describes the intent, and Python does not check it. A fourth class can show up anywhere. How to protect yourself from it is in the next card.
See it
Patterns in match
match can also check the type of the object: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(): means "if the object is of type CardPayment". The parentheses here do not create an object: this is a pattern, not a call. It is the same as isinstance(payment, CardPayment), but for many types at once.
If you forget a type, Python says nothing. No case matches, and the function returns None:
def fee(payment, amount): match payment: case CardPayment(): return amount * 0.015 case BankTransfer(): return 0.50
print(fee(CashOnDelivery(), 200))NoneSo put a case _ at the end that raises an exception. Python still will not check in advance, but at least the program will stop with a clear message at the place of the slip:
def fee(payment, amount): match payment: case CardPayment(): return amount * 0.015 case BankTransfer(): return 0.50 case _: raise TypeError(f"Unknown payment: {payment}")
try: fee(CashOnDelivery(), 200)except TypeError as e: print(e)Unknown payment: CashOnDelivery()A class pattern takes the fields out directly: CardPayment(number) puts the card number in number. This works because @dataclass also records the order of the fields. The if after the pattern is a guard: the case applies only if the condition is true.
def describe(payment): match payment: case CardPayment(number): return f"card ****{number[-4:]}" case BankTransfer(iban) if iban.startswith("BG"): return "transfer from a Bulgarian account" case BankTransfer(iban): return "transfer from abroad" case CashOnDelivery(): return "cash on delivery"print(describe(CardPayment("4111222233334444")))print(describe(BankTransfer("BG80BNBG96611020345678")))print(describe(BankTransfer("DE89370400440532013000")))print(describe(CashOnDelivery()))card ****4444transfer from a Bulgarian accounttransfer from abroadcash on deliveryMore precisely, if you are curious
- A single payment can also be checked with
isinstance. And in the pattern you can also name the field:CardPayment(card_number=number).
payment = CardPayment("4111222233334444")if isinstance(payment, CardPayment): print("The card ends in " + payment.card_number[12:])
match payment: case CardPayment(card_number=number): print("And with the field name: " + number[-4:])The card ends in 4444And with the field name: 4444- Patterns can be nested:
case Order(Customer(name), total).
Try it
Which case wins?
What will the program print?
def label(payment, amount): match payment: case CashOnDelivery() if amount > 500: return "cash on delivery: not accepted over €500" case CashOnDelivery(): return "cash on delivery" case CardPayment() if amount > 1000: return "card: confirmation needed" case CardPayment(): return "card" case BankTransfer(): return "transfer"
print(label(CashOnDelivery(), 650))print(label(CashOnDelivery(), 80))print(label(CardPayment("4111222233334444"), 650))if guard and gets to the second one. The card is for €650, and confirmation is needed over €1000, so the case with no condition wins: "card".More precisely, if you are curious
The case with a guard has to come before the general one for the same type. The other way around is not an error: the general case catches everything, and the one with the guard is simply never reached:
def label(payment, amount): match payment: case CashOnDelivery(): return "cash on delivery" case CashOnDelivery() if amount > 500: return "cash on delivery: not accepted over €500"
print(label(CashOnDelivery(), 650))cash on deliveryPython stops only on a case _ that is not last. It catches everything, and the code does not start at all:
code = """match payment: case _: print("everything") case CashOnDelivery(): print("cash on delivery")"""try: compile(code, "shop.py", "exec")except SyntaxError as e: print("SyntaxError:", e.msg)SyntaxError: wildcard makes remaining patterns unreachableThe idea
Text on several lines
Text on several lines, like a receipt or JSON, is hard to read with " and \n on every line. In Python the text between """ and """ can span several lines:
receipt = """\The Corner ShopCoffee beans €18.50Honey €12.00Total: €30.50"""print(receipt, end="")
name = "Maria"total = 75.5data = f'{{"customer": "{name}", "total": {total:.2f}}}'print(data)The Corner ShopCoffee beans €18.50Honey €12.00Total: €30.50{"customer": "Maria", "total": 75.50}- A
\right after the opening"""removes the line break there, and the text starts on the next line. - The indentation is kept as it is written. That is why the lines here are all the way to the left.
- Quotes inside are not escaped.
- The f-string fills in
{name}and{total:.2f}. The curly braces of the text itself are written double:{{and}}.
More precisely, if you are curious
In the body of a function the lines are indented. dedent from the textwrap module removes the common indentation on the left, like the text block in Java:
from textwrap import dedent
def receipt(): return dedent("""\ Coffee beans €18.50 Honey €12.00 """)
print(receipt(), end="")Coffee beans €18.50Honey €12.00In Java, var lets the compiler infer the type of the variable. In Python you do not write the type of a variable: every variable is like var, and the type belongs to the value, not the name.
prices = {"coffee": 18.50, "honey": 12.00}for name, price in prices.items(): print(f"{name}: €{price}")coffee: €18.5honey: €12.0Exercise
Modern Python
Solve the tasks in your editor.
1. Shapes. Three classes with@dataclass(frozen=True): Circle with radius, Rectangle with width and height, and Triangle with base and height, plus the hint Shape = Circle | Rectangle | Triangle. Write area(shape) with match and class patterns, with a case _ that raises a TypeError. Also write name(shape), which says "square" for a rectangle with equal sides (with an if guard). Print a circle with radius 1, a 2 by 3 rectangle, a 4 by 4 square, a triangle with base 3 and height 5, and the total area.circle: 3.14rectangle: 6.00square: 16.00triangle: 7.50Total: 32.64@dataclass(frozen=True): ToOffice with office, ToAddress with city and weight_kg, and Pickup with no fields. The price: to an office €4.50; to an address €5.90 in Sofia and €6.90 elsewhere, plus €1 for every kilogram over 5; pickup from the store €0. Print each delivery (with the dataclass's __repr__) and its price.ToOffice(office='Varna Center') -> €4.50ToAddress(city='Sofia', weight_kg=2) -> €5.90ToAddress(city='Varna', weight_kg=8) -> €9.90Pickup() -> €0.00Point with @dataclass(frozen=True) and the fields x and y, and count how many times they were at each point in a dictionary with visits.get(p, 0) + 1. At the end, look up Point(2, 3) in the dictionary.Point(x=2, y=3): 3Point(x=5, y=1): 2Point(x=0, y=0): 1Visits to (2, 3): 3More tasks
- A student as a dataclass.
Studentwithnameandgradeand a__post_init__that rejects a grade outside 2 to 6. Sort a list of students withsorted(students, key=lambda s: s.grade). - Day of the week. Rewrite the "day of the week" task from the lesson "Conditions" with
match:case 1 | 2 | 3 | 4 | 5: return "weekday". - A letter. With text between
"""and an f-string, make a letter confirming a reservation: name, date, number of guests and price in euros.
Test