Dataclasses und Typ-Hinweise
Was `@dataclass` für dich schreibt, warum `field(default_factory=...)` gebraucht wird, was `frozen=True` gibt, Typ-Hinweise auf Funktionen, und was es heißt, dass Typ-Hinweise nichts tun während das Programm läuft aber alles für einen Typ-Prüfer.
Wie möchtest du lernen?
Das Problem, das wir lösen
Ausgeschrieben kommt das letzte Kapitels Book-Klasse darauf:
class Book:
def __init__(self, title, author, pages):
self.title = title
self.author = author
self.pages = pages
def __repr__(self):
return f"Book({self.title!r}, {self.author!r}, {self.pages!r})"
def __eq__(self, other):
return (self.title, self.author, self.pages) == (other.title, other.author, other.pages)
a = Book("one", "rafi", 300)
print(a)
print(a == Book("one", "rafi", 300))Book('one', 'rafi', 300)
TrueDreizehn Zeilen, und keine einzige echte Entscheidung darin. Jeder Feldname steht wieder und wieder da — als Parameter von __init__, in der self.-Zeile, in __repr__ und in __eq__.
Ein viertes Feld hinzuzufügen bedeutet, es an all diesen Stellen einzutragen. Vergisst du eins, meckert nichts — __repr__ oder __eq__ ist einfach stillschweigend unvollständig.
from dataclasses import dataclass
@dataclass
class Book:
title: str
author: str
pages: int
a = Book("one", "rafi", 300)
print(a)
print(a == Book("one", "rafi", 300))Book(title='one', author='rafi', pages=300)
TrueSieben Zeilen, und jeder Feldname steht genau einmal.
Am Ende dieses Kapitels kannst du
- Ein
@dataclassschreiben und sagen, was es erzeugt field(default_factory=...)benutzen und sagen, warum es gebraucht wird- Sagen, was
frozen=Truedir gibt - Typ-Hinweise auf Funktionen schreiben —
list[str],dict[str, float],int | None - Sagen, dass Hinweise nichts tun, während das Programm läuft, aber viel für einen Typ-Prüfer
- Ein Dataclass mit
asdictnach JSON schicken
Was du vorher brauchst: Klassen.
Was @dataclass für dich schreibt
Im Austausch für diese sieben Zeilen bekommst du:
- Ein
__init__, dessen Parameter den Feldern folgen, in der Reihenfolge - Ein
__repr__, das jedes Feld mit seinem Namen zeigt - Ein
__eq__, das zwei Objekte gleich nennt, wenn all ihre Felder übereinstimmen
Drei der Probleme vom letzten Kapitel — <object at 0x...>, a == b gibt False, und die gleichen Namen wieder und wieder schreiben — werden zusammen gelöst.
Methoden schreibt man genau wie vorher:
from dataclasses import dataclass
@dataclass
class OrderLine:
name: str
price: float
quantity: int
def total(self) -> float:
return round(self.price * self.quantity * 1.15, 2)
line = OrderLine("pen", 15.0, 3)
print(line)
print(line.total())OrderLine(name='pen', price=15.0, quantity=3)
51.75Standardwerte funktionieren auch. Die Regel ist dieselbe wie in Kapitel 20: Felder mit Standardwerten kommen zum Schluss.
from dataclasses import dataclass
@dataclass
class Book:
title: str
pages: int = 1
print(Book("one"))
print(Book("one", 300))Book(title='one', pages=1)
Book(title='one', pages=300)Typ-Hinweise tun nichts, während das Programm läuft
Das muss früh klar sein, weil der Name etwas anderes vermuten lässt.
from dataclasses import dataclass
@dataclass
class Book:
title: str
pages: int
b = Book("one", "three hundred")
print(b)
print(b.pages + 1)Book(title='one', pages='three hundred')
TypeError: can only concatenate str (not "int") to strpages: int steht da, und trotzdem ging ein String einfach rein. Python prüft Typ-Hinweise nicht. Es speichert sie nur; sie zu benutzen ist die Aufgabe von jemand anderem.
Der Fehler kam viel später, als etwas versuchte, mit diesem Wert zu rechnen. Dazwischen wurde er gedruckt — in einem echten Programm könnte er auch geloggt oder in eine Datei geschrieben worden sein.
Hier hilft ein Typ-Prüfer. mypy ist ein separates Programm, das den Code liest, ohne ihn auszuführen. Speichere den Code oben als main.py und führe mypy main.py aus:
main.py:10: error: Argument 2 to "Book" has incompatible type "str"; expected "int" [arg-type]Die Zeilennummer, welches Argument, was gegeben war und was erwartet wurde — und das Programm wurde nie ausgeführt.
Die ganze Idee in zwei Sätzen: Typ-Hinweise tun nichts, während das Programm läuft; für einen Typ-Prüfer sind sie alles. Einen Hinweis zu schreiben ist ein Versprechen an den Typ-Prüfer und den Leser, nicht an den Interpreter.
Die gespeicherten Hinweise kann man sich ansehen:
def shout(text: str) -> str:
return text.upper()
print(shout("pen"))
print(shout.__annotations__)PEN
{'text': <class 'str'>, 'return': <class 'str'>}Der veränderliche Standardwert, zum dritten Mal
In Kapitel 21 war es ein Funktions-Standardargument. In Kapitel 26 war es ein Klassenattribut. Jetzt ist es ein Dataclass-Feld — und diesmal stoppt Python es selbst:
from dataclasses import dataclass
@dataclass
class Shelf:
books: list = []ValueError: mutable default <class 'list'> for field books is not allowed: use default_factoryAuffällig ist, dass gar kein Objekt erstellt wurde — der Fehler kam während die Klasse definiert wurde. Python hat diesen Fehler so oft gesehen, dass es ihn hier einfach verweigert.
Der richtige Weg ist, eine Fabrik anzugeben — nicht das Gebaute, sondern was gebaut werden soll:
from dataclasses import dataclass, field
@dataclass
class Shelf:
name: str
books: list[str] = field(default_factory=list)
a = Shelf("a")
b = Shelf("b")
a.books.append("one")
print(a)
print(b)Shelf(name='a', books=['one'])
Shelf(name='b', books=[])field(default_factory=list) heißt „rufe list() einmal für jedes neue Objekt auf“. Es macht denselben Job wie in Kap. 21 if basket is None: basket = [], in einer Zeile und ohne Möglichkeit, es falsch zu machen.
frozen=True
from dataclasses import dataclass
@dataclass(frozen=True)
class Point:
x: int
y: int
p = Point(1, 2)
print(p)
print({p, Point(1, 2)})
p.x = 5Point(x=1, y=2)
{Point(x=1, y=2)}
dataclasses.FrozenInstanceError: cannot assign to field 'x'Hier passierten zwei Dinge zusammen, und sie hängen zusammen.
Das Objekt kann nicht mehr geändert werden. p.x = 5 wurde mit einem Fehler gestoppt — genau wie bei einem Tupel.
Und das Objekt kann jetzt in ein Set. Erinnerst du dich an das letzte Problem vom letzten Kapitel: __eq__ zu schreiben nimmt den Hash weg. Hier wurden zwei „gleiche“ Punkte zu einem im Set, ganz ohne Fehler.
Ohne frozen:
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
print({Point(1, 2)})TypeError: unhashable type: 'Point'Der Grund kommt aus Kapitel 16: Was sich ändern kann, ist nicht sicher zu hashen. Wenn es sich ändert, nachdem es in ein Set gesteckt wurde, könnte es nie wieder gefunden werden.
frozen=True sagt „das wird sich nicht ändern“, also ist Python bereit, den Hash zu liefern.
Die Regel ist einfach: Friere jedes Objekt ein, das nach seiner Erstellung nicht ändern soll. Du bekommst eine Garantie, und damit die Fähigkeit, das Objekt in einem Set oder als Dictionary-Schlüssel zu benutzen.
Typ-Hinweise auf Funktionen
def totals(rows: list[tuple[str, float]]) -> dict[str, float]:
result: dict[str, float] = {}
for name, amount in rows:
result[name] = result.get(name, 0.0) + amount
return resultlist[str], dict[str, float], tuple[str, float] — so kannst du auch sagen, was drin ist, und das ist es, was Typ-Hinweise nützlich macht.
Ein bloßes list sagt nur „eine Liste“. list[tuple[str, float]] sagt, was jeder Durchlauf der Schleife bekommt.
Gib ihm das Falsche — sag, Zeile 9 von main.py sei totals("pen") — und mypy sagt:
main.py:9: error: Argument 1 to "totals" has incompatible type "str"; expected "list[tuple[str, float]]" [arg-type]Für „da mag eins sein oder auch nicht“ nutzt man |:
def find(names: list[str], wanted: str) -> str | None:
for name in names:
if name == wanted:
return name
return None
print(find(["pen"], "pen"))
print(find(["pen"], "bag"))pen
NoneDas ist der Ort, wo Typ-Hinweise am meisten helfen. Benutze das Ergebnis ohne auf None zu prüfen — sag, Zeile 8 werde print(find(["pen"], "pen").upper()) — und mypy sagt:
main.py:8: error: Item "None" of "str | None" has no attribute "upper" [union-attr]Das ist wirklich AttributeError: 'NoneType' object has no attribute 'upper' — einer der Fehler, die in der Praxis am häufigsten vorkommen. Aber hier wird er abgefangen, bevor das Programm läuft, und ohne jemals auf die Eingabe zu treffen, die None produzieren würde.
Vom Dataclass zu JSON
import json
from dataclasses import dataclass, asdict
@dataclass
class Book:
title: str
pages: int
print(asdict(Book("one", 300)))
print(json.dumps(asdict(Book("one", 300))))
print(json.dumps(Book("one", 300))){'title': 'one', 'pages': 300}
{"title": "one", "pages": 300}
TypeError: Object of type Book is not JSON serializableErinnerst du dich an die Liste aus Kapitel 25: Objekte deiner eigenen gehen nicht direkt in JSON. asdict ist die Brücke. Es wandelt auch verschachtelte Dataclasses in Dictionaries um.
Ein vollständiges Beispiel
Die Bestellung vom letzten Kapitel, neu gebaut mit Dataclasses und Hinweisen.
"""The order of chapter twenty-six, rebuilt with dataclasses and hints."""
import json
from dataclasses import dataclass, field, asdict
TAX_RATE = 0.15
@dataclass(frozen=True)
class OrderLine:
"""Frozen: a line never changes once made, so it can live in a set."""
name: str
price: float
quantity: int
def __post_init__(self) -> None:
if self.quantity < 1:
raise ValueError(f"quantity must be at least 1: {self.quantity}")
def total(self) -> float:
return round(self.price * self.quantity * (1 + TAX_RATE), 2)
@dataclass
class Order:
"""Not frozen: lines are added over time."""
customer: str
lines: list[OrderLine] = field(default_factory=list)
def add(self, name: str, price: float, quantity: int) -> "Order":
self.lines.append(OrderLine(name, price, quantity))
return self
def total(self) -> float:
return round(sum(line.total() for line in self.lines), 2)
def largest(self) -> OrderLine | None:
if not self.lines:
return None
return max(self.lines, key=lambda line: line.total())
def main() -> None:
order = Order("rafi")
order.add("pen", 15.0, 3)
order.add("bag", 850.0, 1)
order.add("ink", 120.0, 2)
for line in order.lines:
print(f"{line.name:<6} {line.total():>9.2f}")
print(f"{'total':<6} {order.total():>9.2f}")
print()
print(order.largest())
print(json.dumps(asdict(order)))
print(len({OrderLine("pen", 15.0, 3), OrderLine("pen", 15.0, 3)}))
try:
order.add("clip", 5.0, 0)
except ValueError as err:
print("rejected:", err)
if __name__ == "__main__":
main()pen 51.75
bag 977.50
ink 276.00
total 1305.25
OrderLine(name='bag', price=850.0, quantity=1)
{"customer": "rafi", "lines": [{"name": "pen", "price": 15.0, "quantity": 3}, {"name": "bag", "price": 850.0, "quantity": 1}, {"name": "ink", "price": 120.0, "quantity": 2}]}
1
rejected: quantity must be at least 1: 0Fünf Dinge, die einen Blick wert sind.
__post_init__ ist der Ort für Validierung. @dataclass schreibt __init__ selbst, also gibt es dort keinen Platz für eine Prüfung. __post_init__ schließt diese Lücke — es läuft direkt nachdem die Felder gesetzt wurden.
So hält die Garantie vom letzten Kapitel immer noch: Ein OrderLine zu haben heißt, dass seine Menge mindestens eins ist.
Eins ist frozen, das andere nicht — mit Absicht. Eine Zeile soll nach der Erstellung nicht ändern. Aber eine Bestellung bekommt immer neue Zeilen. Die Frage ist dieselbe jedes Mal: ändert sich dieses Ding nachdem es erstellt wurde?
Die 1 beweist, dass das Einfrieren funktionierte. Zwei identische OrderLines wurden zu einer im Set.
Derselbe Code mit dem OrderLine des letzten Kapitels würde 2 drucken. Es gäbe keinen Fehler: jene Klasse hatte kein __eq__, also behielt sie den Standard-Hash, aber zwei getrennte Objekte waren nie gleich — das Set behielt einfach beide.
list[OrderLine] ist keine Dekoration. So weiß der Typ-Prüfer, dass line.total() gültig ist. Wenn du line.totl() schreibst, fängt er es — ohne den Code auszuführen.
"Order" steht in Anführungszeichen. Wenn der Name Order in der Signatur von add steht, ist die Klasse noch nicht fertig definiert — der Name existiert noch nicht. In Anführungszeichen schaut Python den Namen zu dem Zeitpunkt nicht nach.
In Python 3.12, der Version, die dieser Kurs nutzt, gibt es zwei Wege, den eigenen Klassennamen in einem Hinweis innerhalb der Klasse zu benutzen: den Namen in Anführungszeichen setzen, oder from __future__ import annotations ganz oben in die Datei schreiben.
Wenn es schiefgeht
ValueError: mutable default ... use default_factory = [] oder = {} wurde geschrieben. Nutze field(default_factory=list).
TypeError: non-default argument follows default argument Ein Feld mit Standardwert steht vor einem erforderlichen. Die Regel aus Kapitel 20 wieder: erforderliche Felder zuerst.
dataclasses.FrozenInstanceError Auf einem frozen=True-Objekt wurde etwas zugewiesen. Wenn eine Änderung nötig ist, gibt dataclasses.replace(obj, x=5) ein neues Objekt.
TypeError: unhashable type Eine Dataclass ohne frozen=True kann nicht in ein Set.
Ein falscher Typ ging rein und nichts passierte Wie erwartet — Typ-Hinweise tun nichts, während das Programm läuft. Führe mypy aus.
mypy schaut nicht auf meine Datei Gib ihr den Pfad — mypy main.py. Und wenn der erste Lauf eine Flut von Meldungen zeigt, fang mit mypy --ignore-missing-imports an.
TypeError: Object of type X is not JSON serializable Schick asdict(obj).
NameError für meinen eigenen Klassennamen innerhalb der Klasse selbst Setz den Namen in Anführungszeichen: -> "Order". Oder schreib from __future__ import annotations ganz oben in die Datei.
Schritt 4 von 6 — Vorhersagen
Prüfe dein Verständnis
Eine siebenzeilige Dataclass. Was drucken die zwei Zeilen?
from dataclasses import dataclass
@dataclass
class Book:
title: str
pages: int
print(Book("one", 300))
print(Book("one", 300) == Book("one", 300))- A<__main__.Book object at 0x...> False
- BBook(title='one', pages=300) True
- CBook(title='one', pages=300) False
- DBook('one', 300) True
Kein Objekt wird erstellt, nur die Klasse wird geschrieben. Was passiert?
from dataclasses import dataclass
@dataclass
class Shelf:
books: list = []- ANichts; das Problem taucht auf, wenn das erste Objekt erstellt wird
- BEin `ValueError` — während die Klasse definiert wird
- CEin `TypeError`
- DNichts; jede Instanz wird dieselbe Liste teilen
frozen=True ist gesetzt. Was passiert?
from dataclasses import dataclass
@dataclass(frozen=True)
class Point:
x: int
y: int
p = Point(1, 2)
print({p, Point(1, 2)})
p.x = 5- AEin Set mit einem Element wird gedruckt, dann ein `FrozenInstanceError`
- BEin Set mit zwei Elementen wird gedruckt, dann ein `FrozenInstanceError`
- C`TypeError: unhashable type` — beim Set
- DEin Set mit einem Element wird gedruckt, dann wird `p.x` zu `5`
Zum Antworten brauchst du ein Konto
Melde dich an, um deine Antworten zu prüfen
Die Fragen stehen oben, und sie im Kopf durchzuarbeiten ist der wichtige Teil. Melde dich an, um die Antworten, die Erklärungen und die dreistufigen Hinweise zu sehen.
Jetzt bist du dran
Schreib Book und Shelf aus Kapitel 26 als Dataclasses neu.
Book ist frozen=True mit title: str, author: str, pages: int, und einem __post_init__, das ValueError wirft, wenn pages < 1. Shelf hat name: str und books: list[Book] = field(default_factory=list), plus add, total_pages() -> int, longest() -> Book | None und by_author(name: str) -> list[Book].
Dann pip install mypy, führe mypy library.py aus, und korrigiere Dinge, bis es nichts mehr meldet.
Dann sechs Experimente:
- Übergib einen String für
pages—Book("one", "rafi", "300"). Läuft das Programm? Und was sagtmypy? - Ersetze das
field(default_factory=list)vonbooksmit= []. Wann kommt der Fehler — wenn ein Objekt erstellt wird, oder davor? - Mache zwei identische
Book-Objekte und steck sie in ein Set. Wie viele sind da? Dann nimmfrozen=Trueweg und versuch es wieder. - Benutze das Ergebnis von
longest()direkt —shelf.longest().title— auf einem leeren Regal. Was sagt das Programm, und was hattemypyvorher gesagt? asdict(shelf), schreib es mitjson.dumps, und lies es mitjson.loadszurück. Ist das, was zurückkommt, einShelf?- Lies ein falsch geschriebenes Attribut —
book.pagse. Fängtmypyes?
Diese letzten beiden zeigen die Grenze dieses Kapitels und seine Kraft zusammen. Was von JSON zurückkommt, ist ein Dictionary, keine Dataclass — die Übersetzung läuft nur eine Richtung, und den Weg zurück musst du selbst schreiben. Aber die Lücke, die Kapitel 26 nicht schließen konnte — ein falscher Name, der nur aufgefangen wird, wenn der Code läuft — schließt der Typ-Prüfer bevor der Code überhaupt läuft.
Schritt 6 von 6
Herausforderung — das Kapitel-Quiz
Zehn Fragen von leicht bis schwer. Die letzten sind absichtlich knifflig.
Melde dich an, um das Quiz zu machen