Dataclasses et annotations de type
Ce que `@dataclass` écrit pour vous, pourquoi `field(default_factory=...)` est nécessaire, ce que `frozen=True` donne, annotations de type sur les fonctions, et ce que veut dire que les annotations ne font rien pendant que le programme tourne mais tout pour un vérificateur de types.
Comment voulez-vous apprendre ?
Le problème que nous résolvons
Écrite en entier, la classe Book du dernier chapitre donne ceci :
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)
TrueTreize lignes, et pas une seule vraie décision dedans. Le nom de chaque champ est écrit encore et encore — comme paramètre de __init__, dans la ligne self., dans __repr__ et dans __eq__.
Ajouter un quatrième champ veut dire l'ajouter dans tous ces endroits. Si on en oublie un, rien ne signale l'erreur — __repr__ ou __eq__ est juste silencieusement incomplet.
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)
TrueSept lignes, et chaque champ nommé une seule fois.
À la fin de ce chapitre vous pourrez
- Écrire un
@dataclass, et dire ce qu'il génère - Utiliser
field(default_factory=...), et dire pourquoi c'est nécessaire - Dire ce que
frozen=Truevous donne - Écrire des annotations de type sur les fonctions —
list[str],dict[str, float],int | None - Dire que les annotations ne font rien pendant que le programme tourne, mais beaucoup pour un vérificateur de types
- Envoyer un dataclass vers JSON avec
asdict
Ce qu'il vous faut d'abord : Classes.
Ce que @dataclass écrit pour vous
En échange de ces sept lignes vous obtenez :
- Un
__init__dont les paramètres suivent les champs, dans l'ordre - Un
__repr__montrant chaque champ avec son nom - Un
__eq__disant que deux objets sont égaux quand tous leurs champs correspondent
Trois des problèmes du dernier chapitre — <object at 0x...>, a == b qui renvoient False, et écrire les mêmes noms encore et encore — sont résolus ensemble.
Les méthodes s'écrivent exactement comme avant :
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.75Les valeurs par défaut fonctionnent aussi. La règle est la même qu'au chapitre 20 : les champs avec défaut viennent en dernier.
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)Les annotations ne font rien pendant que le programme tourne
Il faut que ce soit clair dès le début, parce que le nom suggère autre chose.
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 est déclaré, et pourtant une chaîne passe directement. Python ne vérifie pas les annotations. Il les stocke seulement ; les utiliser est le travail de quelqu'un d'autre.
L'erreur est arrivée bien plus tard, quand quelque chose a essayé de calculer avec cette valeur. Entre-temps elle a été imprimée — dans un vrai programme elle aurait aussi pu être journalisée, ou écrite dans un fichier.
C'est là qu'un vérificateur de types aide. mypy est un programme séparé qui lit le code sans l'exécuter. Sauvegardez le code ci-dessus comme main.py et lancez mypy main.py :
main.py:10: error: Argument 2 to "Book" has incompatible type "str"; expected "int" [arg-type]Le numéro de ligne, quel argument, ce qui a été donné et ce qui était attendu — et le programme n'a jamais été exécuté.
Toute l'idée en deux phrases : les annotations ne font rien pendant que le programme tourne ; pour un vérificateur de types elles sont tout. Écrire une annotation est une promesse au vérificateur de types et au lecteur, pas à l'interpréteur.
Les annotations stockées peuvent être regardées :
def shout(text: str) -> str:
return text.upper()
print(shout("pen"))
print(shout.__annotations__)PEN
{'text': <class 'str'>, 'return': <class 'str'>}Le piège du défaut mutable, une troisième fois
Au chapitre 21 c'était un argument par défaut de fonction. Au chapitre 26 c'était un attribut de niveau classe. Maintenant c'est un champ de dataclass — et cette fois Python l'arrête lui-même :
from dataclasses import dataclass
@dataclass
class Shelf:
books: list = []ValueError: mutable default <class 'list'> for field books is not allowed: use default_factoryRemarquez qu'aucun objet n'a été créé du tout — l'erreur est survenue lors de la définition de la classe. Python a vu cette erreur si souvent que là il refuse simplement.
La bonne façon est de donner une fabrique — pas la chose construite, mais ce qu'il faut construire :
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) veut dire « appelle list() une fois pour chaque nouvel objet ». Ça fait le même travail que le if basket is None: basket = [] du chapitre 21, en une ligne et sans place pour se tromper.
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'Deux choses se sont passées ici ensemble, et elles sont liées.
L'objet ne peut plus être changé. p.x = 5 a été bloqué avec une erreur — juste comme un tuple.
Et l'objet peut maintenant aller dans un ensemble. Rappelez-vous le problème final du dernier chapitre : écrire __eq__ enlève le hachage. Ici deux points « égaux » sont devenus un dans l'ensemble, sans aucune erreur.
Sans frozen :
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
print({Point(1, 2)})TypeError: unhashable type: 'Point'La raison vient du chapitre 16 : ce qui peut changer n'est pas sûr à hacher. Si ça changeait après avoir été mis dans un ensemble, on ne pourrait plus jamais le retrouver.
frozen=True dit « ceci ne changera pas », donc Python consent à fournir le hachage.
La règle est simple : gelez tout objet qui n'est pas censé changer après sa création. Vous obtenez une garantie, et avec elle la possibilité d'utiliser l'objet dans un ensemble ou comme clé de dictionnaire.
Annotations sur les fonctions
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] — comme ça vous pouvez aussi dire ce qu'il y a dedans, et c'est ce qui rend les annotations utiles.
Un list seul dit seulement « une liste ». list[tuple[str, float]] dit ce que chaque tour de boucle va recevoir.
Donnez-lui la mauvaise chose — disons la ligne 9 de main.py est totals("pen") — et mypy dit :
main.py:9: error: Argument 1 to "totals" has incompatible type "str"; expected "list[tuple[str, float]]" [arg-type]Pour « il y en a peut-être un, peut-être pas », utilisez | :
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
NoneC'est là que les annotations aident le plus. Utilisez le résultat sans vérifier None — disons la ligne 8 devient print(find(["pen"], "pen").upper()) — et mypy dit :
main.py:8: error: Item "None" of "str | None" has no attribute "upper" [union-attr]C'est vraiment AttributeError: 'NoneType' object has no attribute 'upper' — une des erreurs vues le plus souvent en pratique. Mais là elle est attrapée avant que le programme ne tourne, et sans jamais rencontrer l'entrée qui produirait None.
Du dataclass au 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 serializableRappelez-vous la liste du chapitre 25 : vos propres objets ne vont pas directement dans JSON. asdict est le pont. Il transforme aussi les dataclasses imbriqués en dictionnaires.
Un exemple complet
La commande du dernier chapitre, reconstruite avec des dataclasses et des annotations.
"""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: 0Cinq choses à remarquer.
__post_init__ est l'endroit pour la validation. @dataclass écrit __init__ lui-même, donc il n'y a pas de place là pour un contrôle. __post_init__ comble ce trou — il s'exécute juste après que les champs sont définis.
Donc la garantie du dernier chapitre tient toujours : avoir un OrderLine veut dire que sa quantité est au moins un.
L'un est gelé et l'autre pas — volontairement. Une ligne n'est pas censée changer une fois faite. Mais une commande continue d'avoir de nouvelles lignes. La question est la même à chaque fois : est-ce que cette chose change après avoir été créée ?
Le 1 prouve que le gel a fonctionné. Deux OrderLine identiques sont devenus un dans l'ensemble.
Lancer la même ligne avec le OrderLine du dernier chapitre imprimerait 2. Il n'y aurait pas d'erreur : cette classe n'avait pas de __eq__, donc elle a gardé le hachage par défaut, mais deux objets séparés n'étaient jamais égaux — l'ensemble a simplement gardé les deux.
list[OrderLine] n'est pas seulement décoratif. C'est comme ça que le vérificateur de types sait que line.total() est valide. Si vous écriviez line.totl(), il le repérerait — sans exécuter le code.
"Order" est entre guillemets. Quand le nom Order est écrit dans la signature de add, la classe n'est pas finie d'être définie — le nom n'existe pas encore. Entre guillemets, Python ne cherche pas le nom à ce moment-là.
En Python 3.12, la version qu'utilise ce cours, il y a deux façons d'utiliser le nom de sa propre classe dans une annotation à l'intérieur de cette classe : mettre le nom entre guillemets, ou écrire from __future__ import annotations tout en haut du fichier.
Quand ça casse
ValueError: mutable default ... use default_factory = [] ou = {} a été écrit. Utilisez field(default_factory=list).
TypeError: non-default argument follows default argument Un champ avec un défaut est placé avant un requis. La règle du chapitre 20 encore : champs requis d'abord.
dataclasses.FrozenInstanceError Quelque chose a été assigné sur un objet frozen=True. Quand un changement est nécessaire, dataclasses.replace(obj, x=5) donne un nouvel objet.
TypeError: unhashable type Un dataclass sans frozen=True ne peut pas aller dans un ensemble.
Un mauvais type est passé et rien ne s'est passé Comme prévu — les annotations ne font rien pendant que le programme tourne. Lancez mypy.
mypy ne regarde pas mon fichier Donnez-lui le chemin — mypy main.py. Et si le premier coup montre une inondation de messages, commencez avec mypy --ignore-missing-imports.
TypeError: Object of type X is not JSON serializable Envoyez asdict(obj).
NameError pour le nom de ma propre classe à l'intérieur d'elle-même Mettez le nom entre guillemets : -> "Order". Ou écrivez from __future__ import annotations au début du fichier.
Étape 4 sur 6 — Prédire
Vérifiez votre compréhension
Un dataclass en sept lignes. Que les deux lignes impriment-elles ?
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
Aucun objet n'est créé, seule la classe est définie. Que se passe-t-il ?
from dataclasses import dataclass
@dataclass
class Shelf:
books: list = []- ARien ; le problème apparaît quand le premier objet est fait
- BUne `ValueError` — pendant que la classe est en train d'être définie
- CUne `TypeError`
- DRien ; chaque instance partagera une liste
frozen=True est activé. Que se passe-t-il ?
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- AUn ensemble d'un s'imprime, puis une `FrozenInstanceError`
- BUn ensemble de deux s'imprime, puis une `FrozenInstanceError`
- C`TypeError: unhashable type` — à l'ensemble
- DUn ensemble d'un s'imprime, puis `p.x` devient `5`
Répondre nécessite un compte
Connectez-vous pour vérifier vos réponses
Les questions sont ci-dessus, et les résoudre de tête est ce qui compte vraiment. Connectez-vous pour voir les réponses, les explications et les indices à trois niveaux.
À vous de jouer
Réécrivez le Book et le Shelf du chapitre 26 en dataclasses.
Book est frozen=True avec title: str, author: str, pages: int, et un __post_init__ levant une ValueError quand pages < 1. Shelf a name: str et books: list[Book] = field(default_factory=list), plus add, total_pages() -> int, longest() -> Book | None et by_author(name: str) -> list[Book].
Puis pip install mypy, lancez mypy library.py, et corrigez jusqu'à ce qu'il ne signale plus rien.
Puis six expériences :
- Passez une chaîne pour
pages—Book("one", "rafi", "300"). Le programme tourne-t-il ? Et que ditmypy? - Remplacez
field(default_factory=list)debookspar= []. Quand l'erreur arrive-t-elle — quand un objet est fait, ou avant ça ? - Faites deux
Bookidentiques et mettez-les dans un ensemble. Combien y en a-t-il ? Puis enlevezfrozen=Trueet réessayez. - Utilisez le résultat de
longest()directement —shelf.longest().title— sur une étagère vide. Que dit le programme, et que mypy avait signalé auparavant ? asdict(shelf), écrivez-le avecjson.dumps, et relisez-le avecjson.loads. Ce qui revient est-ce unShelf?- Lisez un attribut mal orthographié —
book.pagse.mypyl'attrape-t-il ?
Ces deux dernières montrent la limite et la puissance de ce chapitre ensemble. Ce qui revient de JSON est un dictionnaire, pas un dataclass — la conversion ne va que dans un sens, et il faut écrire le chemin inverse vous-même. Mais l'écart que le chapitre 26 ne pouvait pas combler — un mauvais nom détecté seulement à l'exécution — le vérificateur de types le comble avant que le code ne tourne du tout.
Étape 6 sur 6
Défi — le quiz du chapitre
Dix questions, de la plus facile à la plus difficile. Les dernières sont difficiles exprès.
Connectez-vous pour faire le quiz