Dataclass e hint di tipo
Cosa scrive per te `@dataclass`, perché serve `field(default_factory=...)`, cosa ti dà `frozen=True`, gli hint di tipo sulle funzioni, e cosa significa che gli hint non fanno nulla mentre il programma gira ma tutto per un type checker.
Come vuoi imparare?
Il problema che stiamo risolvendo
Scritta per intero, la classe Book dell'ultimo capitolo diventa questa:
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)
TrueTredici righe, e nessuna vera decisione dentro. Il nome di ogni campo è scritto più e più volte — come parametro di __init__, nella riga self., in __repr__ e in __eq__.
Aggiungere un quarto campo vuol dire aggiungerlo in tutti questi posti. Se ne dimentichi uno, non viene sollevato nulla — __repr__ o __eq__ restano semplicemente incompleti, in silenzio.
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)
TrueSette righe, e ogni campo nominato una volta sola.
Alla fine di questo capitolo sarai in grado di
- Scrivere un
@dataclass, e dire cosa genera - Usare
field(default_factory=...), e dire perché serve - Dire cosa ti dà
frozen=True - Scrivere hint di tipo sulle funzioni —
list[str],dict[str, float],int | None - Dire che gli hint non fanno nulla mentre il programma gira, ma moltissimo per un type checker
- Mandare un dataclass in JSON con
asdict
Prerequisiti: Classi.
Cosa scrive per te @dataclass
In cambio di quelle sette righe ottieni:
- Un
__init__i cui parametri seguono i campi, nello stesso ordine - Un
__repr__che mostra ogni campo con il suo nome - Un
__eq__che considera uguali due oggetti quando tutti i loro campi coincidono
Tre dei problemi dell'ultimo capitolo — <object at 0x...>, a == b che dà False, e scrivere sempre gli stessi nomi — si risolvono insieme.
I metodi si scrivono esattamente come prima:
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.75Anche i default funzionano. La regola è la stessa del capitolo 20: i campi con un default vanno per ultimi.
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)Gli hint non fanno nulla mentre il programma gira
Conviene chiarirlo subito, perché il nome fa pensare a qualcos'altro.
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 è scritto, eppure una stringa è entrata dritta dentro. Python non controlla gli hint. Li memorizza soltanto; usarli è compito di qualcun altro.
L'errore è arrivato molto dopo, quando qualcosa ha provato a fare un calcolo con quel valore. Nel frattempo è stato stampato — in un programma vero avrebbe anche potuto essere registrato in un log, o scritto su un file.
Qui un type checker ti aiuta. mypy è un programma separato che legge il codice senza eseguirlo. Salva il codice qui sopra come main.py ed esegui mypy main.py:
main.py:10: error: Argument 2 to "Book" has incompatible type "str"; expected "int" [arg-type]Il numero di riga, quale argomento, cosa è stato passato e cosa era atteso — e il programma non è mai stato eseguito.
L'idea intera in due frasi: gli hint non fanno nulla mentre il programma gira; per un type checker sono tutto. Scrivere un hint è una promessa al type checker e a chi legge, non all'interprete.
Gli hint memorizzati si possono guardare:
def shout(text: str) -> str:
return text.upper()
print(shout("pen"))
print(shout.__annotations__)PEN
{'text': <class 'str'>, 'return': <class 'str'>}Il default mutabile, per la terza volta
Nel capitolo 21 era l'argomento di default di una funzione. Nel capitolo 26 era un attributo a livello di classe. Ora è un campo di dataclass — e stavolta Python lo ferma da solo:
from dataclasses import dataclass
@dataclass
class Shelf:
books: list = []ValueError: mutable default <class 'list'> for field books is not allowed: use default_factoryNota che non è stato creato alcun oggetto — l'errore è arrivato mentre la classe veniva definita. Python ha visto questo errore così tante volte che qui semplicemente si rifiuta.
Il modo giusto è passare una factory — non l'oggetto già costruito, ma il modo di costruirlo:
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) significa "chiama list() una volta per ogni nuovo oggetto". Fa lo stesso lavoro dell'if basket is None: basket = [] del capitolo 21, in una riga sola e senza margine di errore.
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'Qui sono successe insieme due cose, e sono collegate.
L'oggetto non può più essere modificato. p.x = 5 si è fermato con un errore — proprio come una tupla.
E ora l'oggetto può finire in un insieme. Ricordi l'ultimo problema del capitolo scorso: scrivere __eq__ toglie l'hash. Qui due punti "uguali" sono diventati uno solo nell'insieme, senza alcun errore.
Senza frozen:
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
print({Point(1, 2)})TypeError: unhashable type: 'Point'Il motivo viene dal capitolo 16: ciò che può cambiare non è sicuro da usare per calcolarne l'hash. Se cambiasse dopo essere stato messo in un insieme, non potrebbe più essere ritrovato.
frozen=True dice "questo non cambierà", quindi Python è disposto a fornire l'hash.
La regola è semplice: congela ogni oggetto che non dovrebbe cambiare dopo essere stato creato. Ottieni una garanzia e, con essa, la possibilità di usare l'oggetto in un insieme o come chiave di dizionario.
Hint sulle funzioni
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] — così puoi anche dire cosa c'è dentro, ed è questo che rende utili gli hint.
Un list semplice dice solo "una lista". list[tuple[str, float]] dice cosa riceverà ogni giro del ciclo.
Se gli dai la cosa sbagliata — diciamo che la riga 9 di main.py è totals("pen") — mypy dice:
main.py:9: error: Argument 1 to "totals" has incompatible type "str"; expected "list[tuple[str, float]]" [arg-type]Per "può esserci o non esserci", usa |:
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
NoneQui gli hint aiutano di più. Usa il risultato senza controllare None — diciamo che la riga 8 diventi print(find(["pen"], "pen").upper()) — e mypy dice:
main.py:8: error: Item "None" of "str | None" has no attribute "upper" [union-attr]Questo è in pratica AttributeError: 'NoneType' object has no attribute 'upper' — uno degli errori più frequenti nella pratica. Ma qui viene catturato prima che il programma giri, senza mai incontrare l'input che produrrebbe None.
Da dataclass a 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 serializableRicordi l'elenco del capitolo 25: gli oggetti che definisci tu non finiscono direttamente in JSON. asdict è il ponte. Trasforma in dizionari anche i dataclass annidati al loro interno.
Un esempio completo
L'ordine dell'ultimo capitolo, ricostruito con dataclass e hint.
"""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: 0Cinque cose da notare.
__post_init__ è il posto della validazione. @dataclass scrive da solo __init__, quindi lì non c'è spazio per un controllo. __post_init__ riempie questo vuoto: gira subito dopo che i campi sono stati impostati.
Quindi la garanzia dell'ultimo capitolo vale ancora: avere un OrderLine significa che la sua quantità è almeno uno.
Uno è frozen e l'altro no — di proposito. Una riga non dovrebbe cambiare una volta creata. Ma un ordine riceve sempre nuove righe. La domanda, ogni volta, è la stessa: questa cosa cambia dopo essere stata creata?
Quell'1 dimostra che il congelamento funziona. Due OrderLine identiche sono diventate una sola nell'insieme.
Eseguendo la stessa riga con la OrderLine dell'ultimo capitolo si stamperebbe 2. Nessun errore: quella classe non aveva __eq__, quindi teneva l'hash di default, ma due oggetti separati non erano mai uguali — l'insieme semplicemente teneva entrambi.
list[OrderLine] non è decorazione. È così che il type checker sa che line.total() è valido. Se scrivessi line.totl(), lo segnalerebbe — senza eseguire il codice.
"Order" è tra virgolette. Quando il nome Order compare nella firma di add, la classe non è ancora finita di essere definita — il nome non esiste ancora. Dentro le virgolette Python non cerca il nome in quel momento.
In Python 3.12, la versione usata da questo corso, ci sono due modi per usare il nome di una classe in un hint dentro la classe stessa: mettere il nome tra virgolette, oppure scrivere from __future__ import annotations in cima al file.
Quando si rompe
ValueError: mutable default ... use default_factory Hai scritto = [] o = {}. Usa field(default_factory=list).
TypeError: non-default argument follows default argument Un campo con un default è messo prima di uno obbligatorio. È la regola del capitolo 20 di nuovo: prima i campi obbligatori.
dataclasses.FrozenInstanceError Hai assegnato un valore a un oggetto frozen=True. Quando serve un cambiamento, dataclasses.replace(obj, x=5) dà un oggetto nuovo.
TypeError: unhashable type Un dataclass senza frozen=True non può andare in un insieme.
È entrato un tipo sbagliato e non è successo nulla Come previsto — gli hint non fanno nulla mentre il programma gira. Esegui mypy.
mypy non guarda il mio file Dagli il percorso — mypy main.py. E se la prima esecuzione mostra un diluvio di messaggi, comincia con mypy --ignore-missing-imports.
TypeError: Object of type X is not JSON serializable Passa asdict(obj).
NameError per il nome della mia classe dentro la classe stessa Metti il nome tra virgolette: -> "Order". Oppure scrivi from __future__ import annotations in cima al file.
Passo 4 di 6 — Prevedi
Verifica cosa hai capito
Una dataclass di sette righe. Cosa stampano le due righe?
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
Nessun oggetto viene creato, viene scritta solo la classe. Cosa succede?
from dataclasses import dataclass
@dataclass
class Shelf:
books: list = []- ANiente; il problema compare quando viene creato il primo oggetto
- BUn `ValueError` — mentre la classe viene definita
- CUn `TypeError`
- DNiente; ogni istanza condividerà una sola lista
È impostato frozen=True. Cosa succede?
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- AStampa un insieme con un elemento, poi un `FrozenInstanceError`
- BStampa un insieme con due elementi, poi un `FrozenInstanceError`
- C`TypeError: unhashable type` — sull'insieme
- DStampa un insieme con un elemento, poi `p.x` diventa `5`
Per rispondere serve un account
Accedi per verificare le tue risposte
Le domande sono qui sopra, e ragionarci a mente è la parte che conta. Accedi per vedere le risposte, le spiegazioni e i suggerimenti a tre livelli.
Tocca a te
Riscrivi Book e Shelf del capitolo 26 come dataclass.
Book è frozen=True con title: str, author: str, pages: int, e un __post_init__ che solleva un ValueError quando pages < 1. Shelf ha name: str e books: list[Book] = field(default_factory=list), più add, total_pages() -> int, longest() -> Book | None e by_author(name: str) -> list[Book].
Poi pip install mypy, esegui mypy library.py, e correggi finché non segnala nulla.
Poi sei esperimenti:
- Passa una stringa per
pages—Book("one", "rafi", "300"). Il programma gira? E cosa dicemypy? - Sostituisci
field(default_factory=list)dibookscon= []. Quando arriva l'errore — quando viene creato un oggetto, oppure prima? - Crea due
Bookidentici e mettili in un insieme. Quanti ne restano? Poi toglifrozen=Truee riprova. - Usa direttamente il risultato di
longest()—shelf.longest().title— su uno scaffale vuoto. Cosa dice il programma, e cosa aveva dettomypyprima? asdict(shelf), scrivilo conjson.dumps, e rileggilo conjson.loads. Quello che torna indietro è unoShelf?- Leggi un attributo scritto male —
book.pagse.mypylo trova?
Gli ultimi due punti mostrano insieme il limite di questo capitolo e la sua forza. Quello che torna da JSON è un dizionario, non un dataclass — la traduzione va in un solo senso, e il ritorno lo devi scrivere tu. Ma il vuoto che il capitolo 26 non riusciva a chiudere — un nome sbagliato catturato solo quando il codice gira — il type checker lo chiude prima ancora che il codice giri.
Passo 6 di 6
Sfida — il quiz del capitolo
Dieci domande dalla più facile alla più difficile. Le ultime sono difficili di proposito.
Accedi per fare il quiz