Cours de Python / Créer des classes de données avec dataclass

Le décorateur dataclass génère notamment l’initialisation, la représentation et la comparaison d’une classe à partir de ses champs déclarés. Il simplifie les objets servant à conserver des données, mais ne transforme pas les annotations de type en validation automatique des entrées.

Déclarer un enregistrement avec ses champs

Une classe de données convient à un enregistrement dont les valeurs doivent être nommées, affichées et comparées. Dans Expense, les champs sont name, amount et tags. Le décorateur fournit par défaut les méthodes __init__, __repr__ et __eq__, ce qui évite de réécrire leur structure pour chaque petit enregistrement.

L’initialiseur reçoit les champs dans leur ordre de déclaration, sauf options particulières. Ici, name et amount sont obligatoires ; tags possède une valeur par défaut fabriquée à la demande. Avec field(default_factory=list), on transmet la fonction list sans parenthèses pour créer une nouvelle liste lorsque l’argument manque. Écrire list() à la place fournirait une liste au lieu d’une fonction à appeler.

Cette intention rejoint la distinction entre attributs d’instance et attributs de classe : deux dépenses doivent pouvoir recevoir des étiquettes indépendantes. Une valeur explicite fournie à tags reste cependant la valeur fournie par l’appelant ; default_factory ne copie pas tous les arguments.

Exécuter des étiquettes indépendantes et une comparaison

Ce programme utilise la bibliothèque standard et une annotation list[str] disponible à partir de Python 3.9. Enregistrez-le en UTF-8 dans data_records.py, dans un dossier d’exercice. Lancez python data_records.py depuis ce dossier avec l’environnement virtuel du projet sélectionné. Si votre commande d’interpréteur est python3 hors environnement virtuel sous macOS ou Ubuntu, utilisez python3 data_records.py.

from dataclasses import dataclass, field

@dataclass
class Expense:
    name: str
    amount: int
    tags: list[str] = field(default_factory=list)

    def __post_init__(self):
        if type(self.amount) is not int or self.amount < 0:
            raise ValueError("invalid amount")

a = Expense("lunch", 8000)
b = Expense("bus", 2500)
a.tags.append("work")
print(a)
print(b.tags)
print(a == Expense("lunch", 8000, ["work"]))

Résultat attendu :

Expense(name='lunch', amount=8000, tags=['work'])
[]
True

La première ligne utilise la représentation générée : elle montre la classe, les noms de champs et leurs valeurs. lunch désigne le déjeuner et work une étiquette professionnelle. La deuxième ligne confirme que l’ajout d’une étiquette à a n’a pas rempli la liste par défaut de b.

La dernière comparaison vaut True : le nouvel objet Expense possède les mêmes champs que a, y compris la liste ["work"]. Il reste pourtant un objet distinct. Pour le constater, conservez temporairement cette nouvelle dépense dans une variable other et comparez a == other, puis a is other : vous obtenez True puis False. Retirez cet essai pour retrouver le programme initial.

Contrôler la validation et les limites de frozen

La règle de montant vient du code écrit dans __post_init__, pas de l’annotation int. L’initialiseur généré appelle ce traitement après l’affectation des champs. Le test exige ici le type exact int et une valeur positive ou nulle : True, 3.5, "8000" et -1 sont refusés. Il ne vérifie ni le type de name ni chaque élément de tags. La documentation officielle de dataclasses décrit les méthodes générées et les options du décorateur.

Remplacez temporairement 8000 par -1 dans la création de a : l’exécution s’arrête avec ValueError: invalid amount avant les print. Rétablissez 8000, puis essayez 0 : la création réussit. Revenez ensuite à la valeur initiale. En revanche, ajouter a.amount = -1 après sa création n’appelle pas __post_init__ et conserve le montant invalide. Retirez cette ligne après observation ; un contrôle continu demande une conception supplémentaire, comme la validation par propriété.

Pour un essai séparé, remplacez le décorateur par @dataclass(frozen=True). Une affectation ordinaire telle que a.amount = 9000, ajoutée à la fin, lève FrozenInstanceError. Pourtant, le a.tags.append("work") déjà présent fonctionne encore : il modifie la liste référencée sans réaffecter le champ. Retirez l’affectation d’essai, puis rétablissez @dataclass.

Une conception visant des valeurs immuables doit donc examiner aussi les objets contenus, par exemple en utilisant un tuple d’étiquettes lui-même composé de valeurs immuables. Ne présentez pas frozen comme une protection profonde de n’importe quelle structure imbriquée.

Définir l’identité métier et la conversion des données

Les méthodes générées ne décident pas si deux dépenses représentent le même événement réel. Deux achats distincts peuvent partager nom, montant et étiquettes. Si votre application doit identifier une transaction, prévoyez un identifiant et une règle de comparaison adaptés au besoin, plutôt que de déduire un doublon du seul résultat de ==.

Pour les champs de cet exemple, ajoutez temporairement from dataclasses import asdict et examinez asdict(a). Vous obtenez un dictionnaire contenant name, amount et tags. La fonction transforme aussi les classes de données imbriquées et parcourt les conteneurs prévus ; les autres objets font l’objet d’une copie profonde. La liste obtenue ici n’est donc pas simplement un alias de a.tags.

Cette conversion ne rend pas tous les objets compatibles avec JSON. Un champ contenant une date ou un Decimal reste un objet de ce type après asdict ; l’encodeur JSON standard ne sait pas automatiquement l’écrire. Définissez alors une représentation explicite, telle qu’une date au format choisi ou une valeur décimale sous forme de chaîne, puis validez sa lecture. La documentation de l’encodeur JSON précise les conversions prises en charge ; le cours sur la lecture et la validation du JSON distingue structure et validité des données.

Avant d’utiliser cet enregistrement dans une application, vérifiez séparément la création, l’indépendance des valeurs par défaut, les modifications autorisées, l’identité métier et la conversion. Une représentation pratique d’un objet ne remplace aucune de ces décisions.