backend / python
Dataclasses et attrs en profondeur
Explication
Ce que vous allez apprendre
- Générer automatiquement
__init__,__repr__et__eq__avec@dataclass - Éviter le piège des valeurs par défaut mutables avec
field(default_factory=...) - Rendre une dataclass immuable et hashable avec
frozen=True - Valider ou calculer un champ après construction avec
__post_init__ - Choisir entre
dataclass(stdlib) etattrs(validateurs et converters natifs) selon le besoin
Dans quel contexte ?
Un développeur écrit une classe Commande avec un __init__ de quinze lignes qui ne fait que recopier chaque paramètre dans self, plus un __repr__ manuel pour le débogage, plus un __eq__ pour comparer deux commandes en test. À chaque ajout de champ, ces trois méthodes doivent être mises à jour en parallèle, une source d'oubli fréquente. @dataclass élimine ce risque : ajouter un champ à la liste des annotations suffit, tout le reste est régénéré automatiquement.
Le problème que les dataclasses résolvent
Écrire une classe qui ne fait que porter des données (un point, une commande, une configuration) demande normalement beaucoup de code répétitif : un __init__ qui recopie chaque paramètre en attribut, un __repr__ pour un affichage lisible en debug, un __eq__ pour comparer deux instances par leur contenu. Le décorateur @dataclass génère tout ce code à votre place à partir des simples annotations de type que vous déclarez.
Ce que ça change concrètement
Avec @dataclass, vous déclarez seulement les champs (nom et type), et Python construit automatiquement le constructeur et les méthodes de comparaison. C'est un gain de lisibilité important : le code exprime quelles données la classe porte, sans noyer cette information dans du boilerplate mécanique.
| Méthode générée | Rôle | Activée par |
|---|---|---|
__init__ | Constructeur à partir des champs déclarés | Toujours (par défaut) |
__repr__ | Affichage lisible en debug | Toujours (par défaut) |
__eq__ | Comparaison champ par champ | Toujours (par défaut) |
__hash__ | Utilisable comme clé de dict/set | frozen=True |
__lt__, __le__, __gt__, __ge__ | Tri d'une liste d'instances | order=True |
Le piège classique des valeurs par défaut mutables, résolu
Une liste ou un dictionnaire donné comme valeur par défaut à __init__ est normalement partagé entre toutes les instances (un bug très fréquent en Python). field(default_factory=list) corrige ce piège en demandant explicitement une nouvelle liste à chaque instanciation.
Piège fréquent
Écrire articles: list[str] = [] directement dans une dataclass lève en réalité une ValueError explicite à la définition de la classe (Python protège contre ce piège précis pour les dataclasses). Il faut toujours écrire field(default_factory=list).
frozen, order, __post_init__ : personnaliser sans tout réécrire
frozen=True rend les instances immuables (et donc utilisables comme clés de dictionnaire). order=True génère automatiquement les opérateurs de comparaison pour trier une liste d'objets. __post_init__ permet d'ajouter de la validation ou de calculer un champ dérivé juste après la construction automatique — sans renoncer aux avantages du décorateur.
dataclass vs attrs
attrs est la bibliothèque externe qui a inspiré dataclasses (intégrée depuis Python 3.7) ; elle va plus loin avec des validateurs et des convertisseurs natifs. Pour l'essentiel des besoins, dataclass (stdlib, zéro dépendance) suffit largement.
Commandes & code
Dataclasses et attrs en profondeur
Reduire le boilerplate des classes porteuses de donnees, sans perdre le controle.
from dataclasses import dataclass, field, asdict, astuple, replace, InitVar
from typing import ClassVar
# --- dataclass de base : genere __init__, __repr__, __eq__ automatiquement ---
@dataclass
class Point:
x: float
y: float
p1 = Point(1.0, 2.0)
p2 = Point(1.0, 2.0)
print(p1) # Point(x=1.0, y=2.0) -- __repr__ genere
print(p1 == p2) # True -- __eq__ genere, compare champ par champ
# --- field() : valeurs par defaut mutables et options avancees ---
@dataclass
class Commande:
id: int
articles: list[str] = field(default_factory=list) # JAMAIS un [] direct (piege mutable)
metadonnees: dict = field(default_factory=dict, repr=False) # exclu du __repr__
total: float = field(default=0.0, compare=False) # exclu du __eq__
c1 = Commande(1)
c1.articles.append("clavier")
c2 = Commande(1)
print(c2.articles) # [] -- chaque instance a sa PROPRE liste, contrairement au piege classique
# --- frozen=True : immuabilite, genere aussi __hash__ automatiquement ---
@dataclass(frozen=True)
class Coordonnees:
latitude: float
longitude: float
coord = Coordonnees(48.85, 2.35)
try:
coord.latitude = 0
except AttributeError as e:
print(f"Refuse : {e}")
cache_lieux = {Coordonnees(48.85, 2.35): "Paris"} # hashable grace a frozen=True
# --- order=True : genere __lt__, __le__, __gt__, __ge__ (tri par ordre des champs declares) ---
@dataclass(order=True)
class Version:
majeur: int
mineur: int
patch: int
versions = [Version(1, 2, 0), Version(1, 0, 5), Version(2, 0, 0)]
print(sorted(versions)) # tri lexicographique sur (majeur, mineur, patch)
# --- __post_init__ : validation/derivation apres la generation de __init__ ---
@dataclass
class Rectangle:
largeur: float
hauteur: float
aire: float = field(init=False) # calcule, pas fourni par l'appelant
def __post_init__(self):
if self.largeur <= 0 or self.hauteur <= 0:
raise ValueError("Dimensions invalides")
self.aire = self.largeur * self.hauteur
rect = Rectangle(4, 5)
print(rect.aire) # 20
# --- InitVar : parametre passe a __init__/__post_init__ mais PAS stocke comme champ ---
@dataclass
class Utilisateur:
nom: str
mot_de_passe_brut: InitVar[str]
hash_mdp: str = field(init=False, default="")
def __post_init__(self, mot_de_passe_brut: str):
import hashlib
self.hash_mdp = hashlib.sha256(mot_de_passe_brut.encode()).hexdigest()
u = Utilisateur("Alice", "secret123")
print(u.hash_mdp[:8])
# u.mot_de_passe_brut # AttributeError : InitVar n'est jamais stocke sur l'instance
# --- ClassVar : declare une annotation SANS en faire un champ de dataclass ---
@dataclass
class Compteur:
valeur: int = 0
total_instances: ClassVar[int] = 0 # partage entre instances, ignore par __init__
def __post_init__(self):
Compteur.total_instances += 1
# --- Heritage de dataclasses : les champs du parent viennent EN PREMIER ---
@dataclass
class Animal:
nom: str
espece: str = "inconnue"
@dataclass
class Chien(Animal):
race: str = "inconnue"
# ATTENTION : un champ sans defaut ne peut pas suivre un champ avec defaut dans la hierarchie
# (TypeError a la definition de la classe si l'ordre est incorrect)
# --- Utilitaires : asdict, astuple, replace (immuabilite fonctionnelle) ---
print(asdict(rect)) # {'largeur': 4, 'hauteur': 5, 'aire': 20}
print(astuple(coord)) # (48.85, 2.35)
coord2 = replace(coord, latitude=0.0) # nouvelle instance, un seul champ modifie
print(coord2)
# --- slots=True (Python 3.10+) : gain memoire, empeche les attributs dynamiques ---
@dataclass(slots=True)
class PointOptimise:
x: float
y: float
import sys
print(sys.getsizeof(PointOptimise(1, 2)) < sys.getsizeof(Point(1, 2))) # True
# --- attrs : la librairie qui a inspire dataclasses, avec plus de fonctionnalites ---
# pip install attrs
'''
import attrs
@attrs.define
class Produit:
nom: str
prix: float = attrs.field(validator=attrs.validators.gt(0)) # validation native
tags: list = attrs.field(factory=list)
@prix.validator
def _verifie_prix(self, attribut, valeur):
if valeur > 1_000_000:
raise ValueError("Prix suspect")
# attrs.define genere aussi __slots__ par defaut (contrairement a @dataclass)
# et propose des converters :
@attrs.define
class Produit2:
prix: float = attrs.field(converter=float) # convertit automatiquement a l'assignation
p = Produit2(prix="19.99") # converti en float automatiquement
'''| Besoin | dataclass | attrs |
|---|---|---|
| Stdlib, zero dependance | oui | non (paquet externe) |
| Validateurs de champ natifs | non (via __post_init__) | oui (validator=) |
| Converters automatiques | non | oui (converter=) |
__slots__ par defaut | non (slots=True explicite depuis 3.10) | oui (@attrs.define) |
Résumé
field(default_factory=...)evite le piege des valeurs par defaut mutables partagees entre instances.frozen=Truerend l'instance immuable ET hashable ;order=Truegenere les comparateurs de tri.__post_init__valide/derive des champs apres construction ;InitVarpasse une valeur sans la stocker.dataclass(stdlib) suffit à la plupart des besoins ;attrsajoute validateurs/converters natifs.
Exercices pratiques
Mission : dompter une dataclass Commande qui refuse de se laisser écrire
Objectif : Corriger une dataclass qui lève une erreur à la définition à cause d'une valeur par défaut mutable interdite, puis exploiter frozen et __post_init__ pour un objet de configuration fiable.
Contexte
Un développeur écrit @dataclass class Commande: id: int; articles: list[str] = [] et obtient immédiatement une ValueError au chargement du module, avant même d'avoir instancié une seule Commande. Il ne comprend pas pourquoi Python refuse une syntaxe qui fonctionnerait sans problème dans une fonction normale.
Tu dois corriger cette dataclass, créer une configuration immuable et hashable, puis valider un champ dérivé avec __post_init__.