Zum Inhalt
wannacode

dataclass, match und Muster

Kurz gesagt: @dataclass macht eine Datenklasse, für die Python __init__, __eq__ und __repr__ selbst schreibt, und match wählt einen Fall nach dem Typ und den Feldern des Objekts.

Problem

Der Platz ist belegt, aber das Dictionary findet ihn nicht

Reservierungen im Kino: Der Schlüssel ist der Platz (Reihe und Nummer), der Wert ist der Name der Person.

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)
Ausgabe
None
False
Maria sitzt in Reihe 5, Platz 12, aber get gibt None zurück. Warum, weißt du noch aus der Lektion über Hashcodes: Seat(5, 12) zweimal geschrieben sind zwei verschiedene Objekte, und Seat sagt nicht, wann zwei davon gleich sind. Die Lösung sind __eq__ und __hash__, dazu __repr__ für die Ausgabe. Noch etwa zehn Zeilen für eine Klasse, die nur zwei Zahlen trägt.

Die Idee

@dataclass: eine Klasse für Daten

Mit @dataclass braucht dasselbe ein paar Zeilen:
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"Reihe {seat.row}, Platz {seat.number}")
Ausgabe
Maria
True
Seat(row=5, number=12)
Reihe 5, Platz 12

@dataclass (aus dem Modul dataclasses) vor der Klasse sagt, dass ihre Aufgabe ist, Daten zu tragen. Die Felder stehen im Rumpf mit ihrem Typ: row: int. Daraus schreibt Python für dich:

  • einen Konstruktor __init__ mit allen Feldern;
  • __eq__, das die Felder vergleicht;
  • mit frozen=True: ein Verbot, die Felder nach dem Erzeugen zu ändern, und ein __hash__, der aus den Feldern berechnet wird. Deshalb findet get Maria jetzt;
  • __repr__ in der Form Seat(row=5, number=12).

Die Felder liest du direkt: seat.row. Ein Objekt mit frozen=True ändert sich nach dem Erzeugen nicht.

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"Reihe {seat.row}, Platz {seat.number}")
Schritt für Schritt, in Worten
  1. Ein leeres Dictionary: Der Schlüssel ist ein Kinoplatz, der Wert ist ein Name.
  2. Die Zuweisung legt den Schlüssel Seat(5, 12) mit dem Wert „Maria“ an.
  3. get bekommt ein neues Seat-Objekt. Das ist ein anderes Objekt im Speicher, nicht der Schlüssel aus der Zuweisung.
  4. Der __hash__ einer Dataclass wird aus den Feldern berechnet, deshalb ist er gleich: Die Suche geht an dieselbe Stelle.
  5. __eq__ einer Dataclass vergleicht Feld für Feld: Reihe 5 und 5, Platz 12 und 12. Sie sind gleich, und get gibt „Maria“ zurück.
  6. in macht dasselbe mit einem weiteren neuen Seat-Objekt: True.
  7. Auch __repr__ schreibt die Dataclass selbst, und die Felder liest man direkt: seat.row.
Genauer, für Neugierige
  • Ohne frozen=True können sich die Felder ändern, und __hash__ wird nicht geschrieben. Dann kann das Objekt kein Schlüssel in einem Dictionary sein:
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)
Ausgabe
Seat(row=6, number=12)
TypeError: unhashable type: 'Seat'
  • Der Versuch, ein Feld eines frozen-Objekts zu ändern, bricht mit einem FrozenInstanceError ab. Und der Typ des Feldes ist nur ein Hinweis: Python prüft ihn nicht, und Seat("fünfte", 12) geht durch:
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("fünfte", 12))
Ausgabe
FrozenInstanceError: cannot assign to field 'row'
Seat(row='fünfte', number=12)
  • Die Unveränderlichkeit reicht nur eine Ebene tief. Wenn das Feld eine Liste ist, zeigt es immer auf dieselbe Liste, aber die Liste selbst kann sich ändern:
from dataclasses import dataclass
@dataclass(frozen=True)
class Cart:
items: list
cart = Cart(["Kaffee"])
cart.items.append("Honig")
print(cart)
Ausgabe
Cart(items=['Kaffee', 'Honig'])
  • In der Lektion über Hashcodes hast du __eq__ und __hash__ von Hand geschrieben. Mit @dataclass hast du sie schon.

Die Idee

Prüfungen und Methoden in einer Dataclass

Ein Preis kann nicht negativ sein. Die Prüfung kommt in die Methode __post_init__: @dataclass ruft sie direkt auf, nachdem es die Felder gesetzt hat.
from dataclasses import dataclass
@dataclass(frozen=True)
class Price:
amount: float
currency: str
def __post_init__(self):
if self.amount < 0:
raise ValueError(f"Der Preis darf nicht negativ sein: {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)
Ausgabe
Price(amount=18.5, currency='EUR')
Price(amount=14.8, currency='EUR')
Der Preis darf nicht negativ sein: -5
  • In __post_init__ sind die Felder schon gesetzt, deshalb liest die Prüfung self.amount. Wenn sie eine Exception auslöst, wird das Objekt nicht erzeugt.
  • with_discount ändert den Preis nicht. Es gibt einen neuen Price zurück. So arbeitest du mit Objekten, die sich nicht ändern: coffee bleibt bei 18,50 €.

Probier es aus

Sind sie gleich?

Seat ist eine @dataclass(frozen=True), wie in der Karte davor.

Was gibt das Programm aus?

a = Seat(5, 12)
b = Seat(5, 12)
print(a is b)
print(a == b)
print(hash(a) == hash(b))
Antwort

Probier es aus

Die Lieferung wird zu teuer

Die Lieferung in eine Filiale kostet 4,50 €, aber das Programm sagt 6,90 €. In welcher Zeile ist der Fehler?

Wähle die Zeile mit dem Fehler.

Die Idee

match: Fall für Fall

Seit Python 3.10 gibt es match. Es vergleicht den Wert der Reihe nach mit den Fällen, von oben nach unten, und führt den ersten aus, der passt.
def delivery_price(method):
match method:
case "filiale":
return 4.50
case "kurier" | "adresse":
return 6.90
case "laden":
return 0
case _:
raise ValueError(f"Unbekannte Lieferart: {method}")
price = delivery_price("filiale")
print(f"Lieferung: {price} €")
Ausgabe
Lieferung: 4.5 €
  • Es gibt kein Durchfallen: Nur der Code des Falls, der passt, läuft.
  • | bedeutet „oder“: case "kurier" | "adresse".
  • case _ passt auf alles. Er steht zuletzt und fängt den Rest, wie default in Java. Hier löst er eine Exception aus.
  • match liefert von selbst keinen Wert. Deshalb steht es in einer Funktion, und jeder Fall kehrt mit return zurück.
  • Python prüft nicht, dass jede Eingabe abgedeckt ist. Ohne case _ passt kein Fall auf eine unbekannte Lieferart: Die Funktion kommt an ihr Ende und gibt None zurück, ohne Fehler:
def delivery_price(method):
match method:
case "filiale":
return 4.50
case "kurier":
return 6.90
print(delivery_price("post"))
Ausgabe
None

Ein Fall mit mehreren Zeilen ist ein normaler eingerückter Block:

def delivery(method, weight_kg):
match method:
case "filiale":
return 4.50
case "kurier":
base = 6.90
return base + 3 if weight_kg > 10 else base
case _:
return 0
print(delivery("kurier", 4))
print(delivery("kurier", 12))
Ausgabe
6.9
9.9
Genauer, für Neugierige

match gibt es seit Python 3.10. In älterem Code ist dasselbe eine Kette aus if und elif.

Die Idee

Eine Familie von Typen

Der Shop nimmt drei Zahlungsarten an: Karte, Banküberweisung und Nachnahme. Andere gibt es nicht. Jede Art ist eine eigene Klasse:

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

Jede Zahlungsart ist nur Daten: eine Kartennummer, eine IBAN oder nichts. Deshalb sind sie @dataclass. CashOnDelivery hat keine Felder: Sein Rumpf ist pass.

Die letzte Zeile ist ein Typhinweis: Payment ist „eine der drei“. In Java sorgt sealed dafür, dass der Compiler einen vierten Typ ablehnt. Python hat kein solches Verbot: Der Hinweis beschreibt nur die Absicht, und Python prüft ihn nicht. Eine vierte Klasse kann überall auftauchen. Wie du dich davor schützt, steht in der nächsten Karte.

Sieh es dir an

Muster in match

Die Gebühr hängt von der Zahlungsart ab. match kann auch den Typ des Objekts prüfen:
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("DE89370400440532013000"), 200))
print(fee(CashOnDelivery(), 200))
Ausgabe
3.0
0.5
2.0

case CardPayment(): bedeutet „wenn das Objekt vom Typ CardPayment ist“. Die Klammern erzeugen hier kein Objekt: Das ist ein Muster, kein Aufruf. Es ist dasselbe wie isinstance(payment, CardPayment), aber für viele Typen auf einmal.

Wenn du einen Typ vergisst, sagt Python nichts. Kein Fall passt, und die Funktion gibt None zurück:

def fee(payment, amount):
match payment:
case CardPayment():
return amount * 0.015
case BankTransfer():
return 0.50
print(fee(CashOnDelivery(), 200))
Ausgabe
None

Setz deshalb ans Ende einen case _, der eine Exception auslöst. Python prüft trotzdem nicht im Voraus, aber wenigstens stoppt das Programm mit einer klaren Meldung an der Stelle des Ausrutschers:

def fee(payment, amount):
match payment:
case CardPayment():
return amount * 0.015
case BankTransfer():
return 0.50
case _:
raise TypeError(f"Unbekannte Zahlung: {payment}")
try:
fee(CashOnDelivery(), 200)
except TypeError as e:
print(e)
Ausgabe
Unbekannte Zahlung: CashOnDelivery()

Ein Klassenmuster holt die Felder direkt heraus: CardPayment(number) legt die Kartennummer in number. Das funktioniert, weil @dataclass auch die Reihenfolge der Felder festhält. Das if nach dem Muster ist ein Guard: Der Fall gilt nur, wenn die Bedingung wahr ist.

def describe(payment):
match payment:
case CardPayment(number):
return f"Karte ****{number[-4:]}"
case BankTransfer(iban) if iban.startswith("DE"):
return "Überweisung von einem deutschen Konto"
case BankTransfer(iban):
return "Überweisung aus dem Ausland"
case CashOnDelivery():
return "Nachnahme"
print(describe(CardPayment("4111222233334444")))
print(describe(BankTransfer("DE89370400440532013000")))
print(describe(BankTransfer("AT611904300234573201")))
print(describe(CashOnDelivery()))
Ausgabe
Karte ****4444
Überweisung von einem deutschen Konto
Überweisung aus dem Ausland
Nachnahme
Genauer, für Neugierige
  • Eine einzelne Zahlung lässt sich auch mit isinstance prüfen. Und im Muster kannst du das Feld auch beim Namen nennen: CardPayment(card_number=number).
payment = CardPayment("4111222233334444")
if isinstance(payment, CardPayment):
print("Die Karte endet auf " + payment.card_number[12:])
match payment:
case CardPayment(card_number=number):
print("Und mit dem Feldnamen: " + number[-4:])
Ausgabe
Die Karte endet auf 4444
Und mit dem Feldnamen: 4444
  • Muster lassen sich verschachteln: case Order(Customer(name), total).

Probier es aus

Welcher Fall gewinnt?

Die Fälle werden von oben nach unten geprüft. Der erste, der passt, gewinnt.

Was gibt das Programm aus?

def label(payment, amount):
match payment:
case CashOnDelivery() if amount > 500:
return "Nachnahme: über 500 € nicht möglich"
case CashOnDelivery():
return "Nachnahme"
case CardPayment() if amount > 1000:
return "Karte: Bestätigung nötig"
case CardPayment():
return "Karte"
case BankTransfer():
return "Überweisung"
print(label(CashOnDelivery(), 650))
print(label(CashOnDelivery(), 80))
print(label(CardPayment("4111222233334444"), 650))
Antwort
Genauer, für Neugierige

Der Fall mit Guard muss vor dem allgemeinen für denselben Typ stehen. Andersherum ist es kein Fehler: Der allgemeine Fall fängt alles, und der mit dem Guard wird einfach nie erreicht:

def label(payment, amount):
match payment:
case CashOnDelivery():
return "Nachnahme"
case CashOnDelivery() if amount > 500:
return "Nachnahme: über 500 € nicht möglich"
print(label(CashOnDelivery(), 650))
Ausgabe
Nachnahme

Python stoppt nur bei einem case _, der nicht der letzte ist. Er fängt alles, und der Code startet gar nicht erst:

code = """
match payment:
case _:
print("alles")
case CashOnDelivery():
print("Nachnahme")
"""
try:
compile(code, "shop.py", "exec")
except SyntaxError as e:
print("SyntaxError:", e.msg)
Ausgabe
SyntaxError: wildcard makes remaining patterns unreachable

Die Idee

Text über mehrere Zeilen

Text über mehrere Zeilen, wie ein Kassenbon oder JSON, ist mit " und \n in jeder Zeile schwer zu lesen. In Python kann der Text zwischen """ und """ über mehrere Zeilen gehen:

receipt = """\
Der Eckladen
Kaffeebohnen 18.50 €
Honig 12.00 €
Summe: 30.50 €
"""
print(receipt, end="")
name = "Maria"
total = 75.5
data = f'{{"customer": "{name}", "total": {total:.2f}}}'
print(data)
Ausgabe
Der Eckladen
Kaffeebohnen 18.50 €
Honig 12.00 €
Summe: 30.50 €
{"customer": "Maria", "total": 75.50}
  • Ein \ direkt nach den öffnenden """ entfernt dort den Zeilenumbruch, und der Text beginnt in der nächsten Zeile.
  • Die Einrückung bleibt, wie sie geschrieben ist. Deshalb stehen die Zeilen hier ganz links.
  • Anführungszeichen darin werden nicht maskiert.
  • Der f-String füllt {name} und {total:.2f} aus. Die geschweiften Klammern des Textes selbst werden doppelt geschrieben: {{ und }}.
Genauer, für Neugierige

Im Rumpf einer Funktion sind die Zeilen eingerückt. dedent aus dem Modul textwrap entfernt die gemeinsame Einrückung links, wie der Textblock in Java:

from textwrap import dedent
def receipt():
return dedent("""\
Kaffeebohnen 18.50 €
Honig 12.00 €
""")
print(receipt(), end="")
Ausgabe
Kaffeebohnen 18.50 €
Honig 12.00 €

In Java lässt var den Compiler den Typ der Variable ermitteln. In Python schreibst du den Typ einer Variable nicht: Jede Variable ist wie var, und der Typ gehört zum Wert, nicht zum Namen.

prices = {"Kaffee": 18.50, "Honig": 12.00}
for name, price in prices.items():
print(f"{name}: {price} €")
Ausgabe
Kaffee: 18.5 €
Honig: 12.0 €

Übung

Modernes Python

Löse die Aufgaben in deinem Editor.

1. Formen. Drei Klassen mit @dataclass(frozen=True): Circle mit radius, Rectangle mit width und height und Triangle mit base und height, dazu der Hinweis Shape = Circle | Rectangle | Triangle. Schreib area(shape) mit match und Klassenmustern, mit einem case _, der einen TypeError auslöst. Schreib auch name(shape), das für ein Rechteck mit gleichen Seiten „Quadrat“ sagt (mit einem if-Guard). Gib einen Kreis mit Radius 1, ein Rechteck 2 mal 3, ein Quadrat 4 mal 4, ein Dreieck mit Grundseite 3 und Höhe 5 und die Gesamtfläche aus.
Ausgabe
Kreis: 3.14
Rechteck: 6.00
Quadrat: 16.00
Dreieck: 7.50
Gesamt: 32.64
2. Lieferpreis. Drei Klassen mit @dataclass(frozen=True): ToOffice mit office, ToAddress mit city und weight_kg und Pickup ohne Felder. Der Preis: in eine Filiale 4,50 €; an eine Adresse 5,90 € in Berlin und 6,90 € anderswo, plus 1 € für jedes Kilogramm über 5; Abholung im Laden 0 €. Gib jede Lieferung (mit dem __repr__ der Dataclass) und ihren Preis aus.
Ausgabe
ToOffice(office='Hamburg Zentrum') -> 4.50 €
ToAddress(city='Berlin', weight_kg=2) -> 5.90 €
ToAddress(city='Hamburg', weight_kg=8) -> 9.90 €
Pickup() -> 0.00 €
3. Ein Punkt als Schlüssel. Der Kurier fährt Punkte auf der Karte ab: (2, 3), (5, 1), (2, 3), (0, 0), (5, 1), (2, 3). Mach Point mit @dataclass(frozen=True) und den Feldern x und y, und zähle in einem Dictionary mit visits.get(p, 0) + 1, wie oft er an jedem Punkt war. Schlag am Ende Point(2, 3) im Dictionary nach.
Ausgabe
Point(x=2, y=3): 3
Point(x=5, y=1): 2
Point(x=0, y=0): 1
Besuche bei (2, 3): 3

Weitere Aufgaben

  • Ein Schüler als Dataclass. Student mit name und grade und einem __post_init__, das eine Note außerhalb von 2 bis 6 ablehnt. Sortiere eine Liste von Schülern mit sorted(students, key=lambda s: s.grade).
  • Wochentag. Schreib die Aufgabe „Wochentag“ aus der Lektion „Bedingungen“ mit match neu: case 1 | 2 | 3 | 4 | 5: return "Werktag".
  • Ein Brief. Mach mit Text zwischen """ und einem f-String einen Brief, der eine Reservierung bestätigt: Name, Datum, Anzahl der Gäste und Preis in Euro.

Test

Teste dich

1. Was bekommst du mit @dataclass(frozen=True) über class Seat mit den Feldern row: int und number: int fertig dazu?
2. Warum funktioniert ein Seat mit @dataclass(frozen=True) als Schlüssel in einem Dictionary, eine normale Klasse ohne Zusätze aber nicht?
3. Was macht case "kurier" | "adresse": in einem match?
4. Eine Funktion mit match hat keinen Fall für CashOnDelivery und bekommt CashOnDelivery(). Was passiert?
5. Was macht case BankTransfer(iban) if iban.startswith("DE"):?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·