Dataclasses en type hints
Wat `@dataclass` voor je schrijft, waarom `field(default_factory=...)` nodig is, wat `frozen=True` geeft, type hints op functies, en wat het betekent dat hints niets doen tijdens het draaien van het programma maar alles voor een type checker.
Hoe wil je leren?
Het probleem dat we oplossen
Volledig uitgewerkt, komt de Book-klasse van het vorige hoofdstuk op dit neer:
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)
TrueDertien regels, en geen enkele echte beslissing erin. De naam van elk veld staat er herhaald in — als parameter van __init__, op de self.-regel, in __repr__ en in __eq__.
Een vierde veld toevoegen betekent het op al die plekken toevoegen. Vergeet er één en er komt geen fout — __repr__ of __eq__ is gewoon stilzwijgend incompleet.
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)
TrueZeven regels, en elk veld genoemd een keer.
Aan het einde van dit hoofdstuk kun je
- Een
@dataclassschrijven, en zeggen wat het genereert field(default_factory=...)gebruiken, en uitleggen waarom het nodig is- Zeggen wat
frozen=Trueje geeft - Type hints op functies schrijven —
list[str],dict[str, float],int | None - Zeggen dat hints niets doen tijdens het draaien van het programma, maar veel voor een type checker
- Een dataclass naar JSON sturen met
asdict
Wat je eerst nodig hebt: Klassen.
Wat @dataclass voor je schrijft
In ruil voor die zeven regels krijg je:
- Een
__init__wiens parameters de velden volgen, in volgorde - Een
__repr__dat elk veld met zijn naam toont - Een
__eq__dat twee objecten gelijk noemt wanneer al hun velden overeenkomen
Drie problemen uit het vorige hoofdstuk — <object at 0x...>, a == b dat False geeft, en dezelfde namen herhaaldelijk schrijven — worden samen opgelost.
Methodes schrijf je precies zoals eerder:
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.75Standaardwaarden werken ook. De regel is dezelfde als in hoofdstuk 20: velden met standaardwaarden komen laatst.
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)Hints doen niets tijdens het draaien van het programma
Dit moet vroeg duidelijk zijn, want de naam suggereert iets anders.
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 staat er, en toch ging een string er gewoon in. Python controleert hints niet. Het bewaart ze alleen; ze gebruiken is andermans taak.
De fout kwam veel later, toen er iets probeerde te rekenen met die waarde. Tussenin werd het geprint — in een echt programma zou het ook gelogd of naar een bestand geschreven kunnen zijn.
Dit is waar een type checker helpt. mypy is een apart programma dat de code leest zonder hem te draaien. Sla de bovenstaande code op als main.py en draai mypy main.py:
main.py:10: error: Argument 2 to "Book" has incompatible type "str"; expected "int" [arg-type]Het regelnummer, welk argument, wat er gegeven werd en wat verwacht werd — en het programma werd nooit gedraaid.
Het hele idee in twee zinnen: hints doen niets tijdens het draaien van het programma; voor een type checker zijn ze alles. Een hint schrijven is een belofte aan de type checker en aan de lezer, niet aan de interpreter.
De opgeslagen hints zijn te bekijken:
def shout(text: str) -> str:
return text.upper()
print(shout("pen"))
print(shout.__annotations__)PEN
{'text': <class 'str'>, 'return': <class 'str'>}De veranderlijke standaard, een derde keer
In hoofdstuk 21 was het een standaardargument van een functie. In hoofdstuk 26 was het een attribuut op klasseniveau. Nu is het een dataclass-veld — en deze keer stopt Python het zelf:
from dataclasses import dataclass
@dataclass
class Shelf:
books: list = []ValueError: mutable default <class 'list'> for field books is not allowed: use default_factoryLet op dat er geen object gemaakt werd — de fout kwam terwijl de klasse gedefinieerd werd. Python heeft deze fout zo vaak gezien dat het hier gewoon weigert.
De juiste manier is een factory geven — niet het gebouwde ding, maar wat er gebouwd moet worden:
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) betekent "roep list() één keer aan voor elk nieuw object". Het doet hetzelfde als hoofdstuk 21 zijn if basket is None: basket = [], in één regel en zonder ruimte om het mis te doen.
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'Twee dingen gebeurden hier samen, en ze hangen samen.
Het object kan niet meer gewijzigd worden. p.x = 5 werd gestopt met een fout — net als een tuple.
En het object kan nu in een set. Onthoud het laatste probleem uit het vorige hoofdstuk: __eq__ schrijven nam de hash weg. Hier werden twee "gelijke" punten één in de set, zonder enige fout.
Zonder frozen:
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
print({Point(1, 2)})TypeError: unhashable type: 'Point'De reden komt uit hoofdstuk 16: wat kan veranderen is niet veilig om te hashen. Als het veranderde nadat het in een set was gezet, kon het nooit meer gevonden worden.
frozen=True zegt "dit zal niet veranderen", dus Python is bereid de hash te leveren.
De regel is simpel: bevries elk object dat niet meer hoeft te veranderen nadat het gemaakt is. Je krijgt een garantie, en daarmee de mogelijkheid om het object in een set of als dictionary-sleutel te gebruiken.
Hints op functies
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] — zo kun je ook zeggen wat erin zit, en dat maakt hints nuttig.
Gewoon list zegt alleen "een lijst". list[tuple[str, float]] zegt wat elke doorloop van de lus krijgt.
Geef het het verkeerde ding — zeg regel 9 van main.py is totals("pen") — en mypy zegt:
main.py:9: error: Argument 1 to "totals" has incompatible type "str"; expected "list[tuple[str, float]]" [arg-type]Voor 'er kan een zijn of niet', gebruik |:
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
NoneDit is waar hints het meest helpen. Gebruik het resultaat zonder op None te checken — zeg regel 8 wordt print(find(["pen"], "pen").upper()) — en mypy zegt:
main.py:8: error: Item "None" of "str | None" has no attribute "upper" [union-attr]Dat is eigenlijk AttributeError: 'NoneType' object has no attribute 'upper' — een van de fouten die in de praktijk het vaakst voorkomt. Maar hier wordt het gevangen vóór het programma draait, en zonder ooit de input te ontmoeten die None zou produceren.
Van dataclass naar 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 serializableOnthoud de lijst uit hoofdstuk 25: eigen objecten gaan niet rechtstreeks in JSON. asdict is de brug. Het zet ook geneste dataclasses om in dictionaries.
Een compleet voorbeeld
De order uit het vorige hoofdstuk, herbouwd met dataclasses en hints.
"""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: 0Vijf dingen om op te letten.
__post_init__ is waar validatie hoort. @dataclass schrijft __init__ zelf, dus is er geen plek daar voor een check. __post_init__ vult die kloof — het draait net nadat de velden gezet zijn.
Dus de garantie uit het vorige hoofdstuk geldt nog steeds: een OrderLine hebben betekent dat zijn quantity minstens één is.
Één is bevroren en de ander niet — opzet. Een regel mag niet meer veranderen nadat hij gemaakt is. Maar een order krijgt steeds nieuwe regels. De vraag is steeds dezelfde: verandert dit ding nadat het gemaakt is?
De 1 bewijst dat bevriezen werkte. Twee identieke OrderLines werden één in de set.
Drie dezelfde regels met de OrderLine uit het vorige hoofdstuk zouden 2 printen. Er zou geen fout zijn: die klasse had geen __eq__, dus hield hij de standaardhash, maar twee aparte objecten waren nooit gelijk — de set bewaarde ze gewoon allebei.
list[OrderLine] is geen decoratie. Het is hoe de type checker weet dat line.total() geldig is. Als je line.totl() schreef, zou hij het vangen — zonder de code te draaien.
"Order" staat tussen aanhalingstekens. Wanneer de naam Order in de handtekening van add staat, is de klasse nog niet klaar met definiëren — de naam bestaat nog niet. Tussen aanhalingstekens zoekt Python de naam niet op op dat moment.
In Python 3.12, de versie die deze cursus gebruikt, zijn er twee manieren om een klasse zijn eigen naam in een hint binnen die klasse te gebruiken: zet de naam tussen aanhalingstekens, of schrijf from __future__ import annotations helemaal bovenaan het bestand.
Wanneer het breekt
ValueError: mutable default ... use default_factory = [] of = {} was geschreven. Gebruik field(default_factory=list).
TypeError: non-default argument follows default argument Een veld met een standaardwaarde staat vóór een verplicht veld. De regel uit hoofdstuk 20 weer: eerst verplicht, dan standaard.
dataclasses.FrozenInstanceError Er werd iets toegewezen aan een frozen=True object. Als een wijziging nodig is, geeft dataclasses.replace(obj, x=5) een nieuw object.
TypeError: unhashable type Een dataclass zonder frozen=True kan niet in een set.
Er ging een verkeerd type in en er gebeurde niets Zoals verwacht — hints doen niets tijdens het draaien van het programma. Draai mypy.
mypy kijkt niet naar mijn bestand Geef het het pad — mypy main.py. En als de eerste run een vloed aan meldingen geeft, begin met mypy --ignore-missing-imports.
TypeError: Object of type X is not JSON serializable Stuur asdict(obj).
NameError voor mijn eigen klassennaam binnenin zichzelf Zet de naam tussen aanhalingstekens: -> "Order". Of schrijf from __future__ import annotations bovenaan het bestand.
Stap 4 van 6 — Voorspellen
Test je begrip
Een zevenregelige dataclass. Wat printen de twee regels?
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
Geen object wordt gemaakt, alleen de klasse wordt geschreven. Wat gebeurt er?
from dataclasses import dataclass
@dataclass
class Shelf:
books: list = []- ANiets; het probleem verschijnt wanneer het eerste object gemaakt wordt
- BEen `ValueError` — terwijl de klasse gedefinieerd wordt
- CEen `TypeError`
- DNiets; elke instantie zal één lijst delen
frozen=True is ingesteld. Wat gebeurt er?
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- AEen set met één element wordt geprint, daarna een `FrozenInstanceError`
- BEen set met twee elementen wordt geprint, daarna een `FrozenInstanceError`
- C`TypeError: unhashable type` — bij de set
- DEen set met één element wordt geprint, daarna wordt `p.x` `5`
Voor antwoorden heb je een account nodig
Log in om je antwoorden te controleren
De vragen staan hierboven, en ze in je hoofd uitwerken is waar het om draait. Log in om de antwoorden, de uitleg en de hints in drie niveaus te zien.
Jouw beurt
Herschrijf de Book en Shelf uit hoofdstuk 26 als dataclasses.
Book is frozen=True met title: str, author: str, pages: int, en een __post_init__ die ValueError gooit wanneer pages < 1. Shelf heeft name: str en books: list[Book] = field(default_factory=list), plus add, total_pages() -> int, longest() -> Book | None en by_author(name: str) -> list[Book].
Dan pip install mypy, draai mypy library.py, en fix dingen tot het niets meer rapporteert.
Dan zes experimenten:
- Geef een string door voor
pages—Book("one", "rafi", "300"). Draait het programma? En wat zegtmypy? - Vervang
bookszijnfield(default_factory=list)door= []. Wanneer komt de fout — wanneer een object gemaakt wordt, of daarvoor? - Maak twee identieke
Books en zet ze in een set. Hoeveel zitten erin? Haal danfrozen=Trueweg en probeer opnieuw. - Gebruik
longest()zijn resultaat direct —shelf.longest().title— op een lege plank. Wat zegt het programma, en wat hadmypyvooraf gezegd? asdict(shelf), schrijf het metjson.dumps, en lees het terug metjson.loads. Is wat terugkomt eenShelf?- Lees een verkeerd gespeld attribuut —
book.pagse. Vangtmypyhet op?
Die laatste twee laten dit hoofdstuk zijn limiet en kracht samen zien. Wat uit JSON terugkomt is een dictionary, geen dataclass — de vertaling loopt één kant op, en de weg terug moet je zelf schrijven. Maar de kloof die hoofdstuk 26 niet kon dichten — een verkeerde naam die pas gevangen werd toen de code draaide — sluit de type checker vóór de code ooit draait.
Stap 6 van 6
Uitdaging — de hoofdstukquiz
Tien vragen van makkelijk naar moeilijk. De laatste zijn met opzet lastig.
Log in om de quiz te maken