Skip to content
wannacode

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.

Seat
class Seat:
def __init__(self, row, number):
self.row = row
self.number = number
reservations = {}
reservations[Seat(5, 12)] = "Maria"
print(reservations.get(Seat(5, 12)))
print(Seat(5, 12) in reservations)
Output
None
False
Maria is in row 5, seat 12, but get 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

With @dataclass the same thing takes a few lines:
from dataclasses import dataclass
@dataclass(frozen=True)
class Seat:
row: int
number: int
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}")
Output
Maria
True
Seat(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 why get now finds Maria;
  • __repr__ in the form Seat(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
  1. An empty dictionary: the key is a theater seat, the value is a name.
  2. The assignment adds the key Seat(5, 12) with the value "Maria".
  3. get receives a new Seat object. It is a different object in memory, not the key from the assignment.
  4. The __hash__ of a dataclass is computed from the fields, so it is the same: the lookup goes to the same place.
  5. The __eq__ of a dataclass compares field by field: row 5 and 5, seat 12 and 12. They are equal, and get returns "Maria".
  6. in does the same with yet another new Seat: True.
  7. The dataclass also writes __repr__ by itself, and the fields are read directly: seat.row.
More precisely, if you are curious
  • Without frozen=True the fields can change, and __hash__ is not written. Then the object cannot be a key in a dictionary:
from dataclasses import dataclass
@dataclass
class Seat:
row: int
number: int
seat = Seat(5, 12)
seat.row = 6
print(seat)
try:
reservations = {seat: "Maria"}
except TypeError as e:
print("TypeError:", e)
Output
Seat(row=6, number=12)
TypeError: unhashable type: 'Seat'
  • Trying to change a field of a frozen object stops with a FrozenInstanceError. And the type of the field is only a hint: Python does not check it, and Seat("fifth", 12) goes through:
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("fifth", 12))
Output
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)
Output
Cart(items=['coffee', 'honey'])
  • In the lesson on hashing you wrote __eq__ and __hash__ by hand. With @dataclass you already have them.

The idea

Checks and methods in a dataclass

A price cannot be negative. The check goes in the method __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)
Output
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 reads self.amount. If it raises an exception, the object is not created.
  • with_discount does not change the price. It returns a new Price. That is how you work with objects that do not change: coffee stays €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))
Answer

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.

The idea

match: case by case

Since Python 3.10 there is 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}")
Output
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, like default in Java. Here it raises an exception.
  • match does not give a value by itself. So it is inside a function, and every case returns with return.
  • Python does not check that every input is covered. Without case _, no case matches an unknown method: the function reaches its end and returns None, with no error:
def delivery_price(method):
match method:
case "office":
return 4.50
case "courier":
return 6.90
print(delivery_price("post"))
Output
None

A 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))
Output
6.9
9.9
More 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 | CashOnDelivery

Each 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

The fee depends on the payment method. 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.00
print(fee(CardPayment("4111222233334444"), 200))
print(fee(BankTransfer("BG80BNBG96611020345678"), 200))
print(fee(CashOnDelivery(), 200))
Output
3.0
0.5
2.0

case 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))
Output
None

So 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)
Output
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()))
Output
card ****4444
transfer from a Bulgarian account
transfer from abroad
cash on delivery
More 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:])
Output
The card ends in 4444
And with the field name: 4444
  • Patterns can be nested: case Order(Customer(name), total).

Try it

Which case wins?

The cases are checked from top to bottom. The first one that matches 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))
Answer
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))
Output
cash on delivery

Python 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)
Output
SyntaxError: wildcard makes remaining patterns unreachable

The 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 Shop
Coffee beans €18.50
Honey €12.00
Total: €30.50
"""
print(receipt, end="")
name = "Maria"
total = 75.5
data = f'{{"customer": "{name}", "total": {total:.2f}}}'
print(data)
Output
The Corner Shop
Coffee beans €18.50
Honey €12.00
Total: €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="")
Output
Coffee beans €18.50
Honey €12.00

In 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}")
Output
coffee: €18.5
honey: €12.0

Exercise

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.
Output
circle: 3.14
rectangle: 6.00
square: 16.00
triangle: 7.50
Total: 32.64
2. Delivery price. Three classes with @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.
Output
ToOffice(office='Varna Center') -> €4.50
ToAddress(city='Sofia', weight_kg=2) -> €5.90
ToAddress(city='Varna', weight_kg=8) -> €9.90
Pickup() -> €0.00
3. A point as a key. The courier passes through points on the map: (2, 3), (5, 1), (2, 3), (0, 0), (5, 1), (2, 3). Make Point 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.
Output
Point(x=2, y=3): 3
Point(x=5, y=1): 2
Point(x=0, y=0): 1
Visits to (2, 3): 3

More tasks

  • A student as a dataclass. Student with name and grade and a __post_init__ that rejects a grade outside 2 to 6. Sort a list of students with sorted(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

Check yourself

1. What do you get ready-made with @dataclass(frozen=True) above class Seat with the fields row: int and number: int?
2. Why does a Seat with @dataclass(frozen=True) work as a key in a dictionary, while a plain class with nothing added does not?
3. What does case "courier" | "address": do in a match?
4. A function with match has no case for CashOnDelivery and gets CashOnDelivery(). What happens?
5. What does case BankTransfer(iban) if iban.startswith("BG"): do?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·