Dataclasses et attrs#
Le problème des classes de données#
La programmation orientée objet en Python encourage l’encapsulation des données dans des classes. Mais pour les classes qui servent essentiellement à regrouper des données — sans logique métier complexe —, le code nécessaire est étonnamment répétitif. Considérons une classe Employe écrite à la main :
class EmployeManuel:
"""Employé — version manuelle sans @dataclass."""
def __init__(self, nom: str, prenom: str, salaire: float,
departement: str = "Inconnu") -> None:
self.nom = nom
self.prenom = prenom
self.salaire = salaire
self.departement = departement
def __repr__(self) -> str:
return (f"EmployeManuel(nom={self.nom!r}, prenom={self.prenom!r}, "
f"salaire={self.salaire!r}, departement={self.departement!r})")
def __eq__(self, other: object) -> bool:
if not isinstance(other, EmployeManuel):
return NotImplemented
return (self.nom == other.nom and
self.prenom == other.prenom and
self.salaire == other.salaire and
self.departement == other.departement)
def __hash__(self) -> int:
return hash((self.nom, self.prenom, self.salaire, self.departement))
e = EmployeManuel("Dupont", "Alice", 42000.0, "Ingénierie")
print(e)
print(e == EmployeManuel("Dupont", "Alice", 42000.0, "Ingénierie"))
EmployeManuel(nom='Dupont', prenom='Alice', salaire=42000.0, departement='Ingénierie')
True
Ce code fonctionne parfaitement, mais il souffre de trois problèmes :
La répétition : chaque attribut apparaît trois fois — dans
__init__, dans__repr__et dans__eq__. Si on ajoute un attribut, il faut modifier trois méthodes. Si on en oublie une, des bogues subtils apparaissent.Le manque d’expressivité : la structure de la classe est noyée dans le code répétitif. Un lecteur doit analyser
__init__pour comprendre quels champs existent.La fragilité : il est facile d’oublier un champ dans
__eq__ou de faire une faute de frappe dans__repr__.
Définition 21 (Classe de données (data class))
Une classe de données est une classe dont le rôle principal est de stocker des données structurées, avec peu ou pas de logique métier. Elle a besoin au minimum de __init__, __repr__ et souvent __eq__. En Python, le module dataclasses (depuis Python 3.7) et la bibliothèque attrs permettent de générer automatiquement ces méthodes à partir d’une déclaration simple des champs.
@dataclass — la solution standard#
Le décorateur @dataclass du module dataclasses génère automatiquement __init__, __repr__ et __eq__ à partir des annotations de type définies dans le corps de la classe.
from dataclasses import dataclass, field
from typing import ClassVar
@dataclass
class Employe:
"""Employé — version @dataclass."""
nom: str
prenom: str
salaire: float
departement: str = "Inconnu"
# Attribut de classe (non inclus dans __init__)
nb_employes: ClassVar[int] = 0
e1 = Employe("Dupont", "Alice", 42000.0, "Ingénierie")
e2 = Employe("Martin", "Bob", 38500.0)
e3 = Employe("Dupont", "Alice", 42000.0, "Ingénierie")
print(e1) # __repr__ généré
print(e1 == e3) # __eq__ généré (True)
print(e1 == e2) # False
print(f"e1.departement : {e1.departement}")
print(f"e2.departement : {e2.departement}") # Valeur par défaut
Employe(nom='Dupont', prenom='Alice', salaire=42000.0, departement='Ingénierie')
True
False
e1.departement : Ingénierie
e2.departement : Inconnu
Avec @dataclass, le code est réduit à l’essentiel : les annotations de type décrivent les champs, et les valeurs par défaut sont définies directement. L’intention du code est immédiatement lisible.
La fonction field()#
Quand les valeurs par défaut nécessitent une expression (pas une valeur littérale), on utilise field() avec default_factory. Sans cela, une liste ou un dictionnaire partagé entre toutes les instances causerait des bogues classiques.
from dataclasses import dataclass, field
from datetime import date
import uuid
@dataclass
class Projet:
"""Projet avec des champs de types variés."""
titre: str
budget: float
date_debut: date = field(default_factory=date.today)
membres: list[str] = field(default_factory=list)
identifiant: str = field(default_factory=lambda: str(uuid.uuid4())[:8])
# Champ exclu du __repr__ (ex : données sensibles)
_notes_internes: str = field(default="", repr=False)
# Champ calculé : exclu de __init__ mais inclus dans __repr__
nb_membres: int = field(init=False, repr=True)
def __post_init__(self) -> None:
# Appelé juste après __init__ — voir section suivante
self.nb_membres = len(self.membres)
p1 = Projet("Refonte du site web", 15000.0, membres=["Alice", "Bob", "Charlie"])
p2 = Projet("Audit de sécurité", 8000.0)
print(p1)
print(p2)
print(f"p1.identifiant : {p1.identifiant}")
print(f"p2.identifiant : {p2.identifiant}") # Différent de p1
Projet(titre='Refonte du site web', budget=15000.0, date_debut=datetime.date(2026, 3, 19), membres=['Alice', 'Bob', 'Charlie'], identifiant='a8313603', nb_membres=3)
Projet(titre='Audit de sécurité', budget=8000.0, date_debut=datetime.date(2026, 3, 19), membres=[], identifiant='419b0a54', nb_membres=0)
p1.identifiant : a8313603
p2.identifiant : 419b0a54
Remarque 20
Attention aux valeurs par défaut mutables. En Python, utiliser une liste ou un dictionnaire comme valeur par défaut dans __init__ est une erreur classique : la même liste serait partagée entre toutes les instances. @dataclass détecte ce problème et lève une ValueError si vous essayez d’utiliser une liste directement comme default. Il faut obligatoirement passer par field(default_factory=list).
Options de @dataclass#
Le décorateur @dataclass accepte plusieurs paramètres qui modifient le comportement des classes générées.
from dataclasses import dataclass, field
import sys
# frozen=True : rend la dataclass immuable (hashable automatiquement)
@dataclass(frozen=True)
class Point:
x: float
y: float
def distance_origine(self) -> float:
return (self.x**2 + self.y**2) ** 0.5
p = Point(3.0, 4.0)
print(f"Point : {p}")
print(f"Distance : {p.distance_origine()}")
print(f"Hash : {hash(p)}") # Possible car frozen=True
try:
p.x = 10.0 # type: ignore
except Exception as e:
print(f"Erreur attendue : {type(e).__name__}: {e}")
# order=True : génère __lt__, __le__, __gt__, __ge__
@dataclass(order=True)
class Priorite:
niveau: int # Utilisé pour la comparaison (premier champ)
message: str = field(compare=False) # Exclu de la comparaison
taches = [
Priorite(3, "Documentation"),
Priorite(1, "Correction bogue critique"),
Priorite(2, "Refactoring"),
]
print(f"Tâches triées : {sorted(taches)}")
# slots=True (Python 3.10+) : utilise __slots__ pour moins de mémoire
@dataclass(slots=True)
class CapteurLeger:
temperature: float
humidite: float
pression: float
capteur = CapteurLeger(22.5, 65.0, 1013.25)
print(f"Capteur : {capteur}")
# Les instances avec __slots__ sont plus légères en mémoire
Point : Point(x=3.0, y=4.0)
Distance : 5.0
Hash : 1079245023883434373
Erreur attendue : FrozenInstanceError: cannot assign to field 'x'
Tâches triées : [Priorite(niveau=1, message='Correction bogue critique'), Priorite(niveau=2, message='Refactoring'), Priorite(niveau=3, message='Documentation')]
Capteur : CapteurLeger(temperature=22.5, humidite=65.0, pression=1013.25)
```{prf:definition} Options de @dataclass
:label: definition-09-02
Les paramètres les plus importants de @dataclass sont :
eq=True(défaut) : génère__eq__et__ne__.order=False(défaut) : siTrue, génère__lt__,__le__,__gt__,__ge__.frozen=False(défaut) : siTrue, rend l’instance immuable (pas d’assignation après création) et génère automatiquement__hash__.repr=True(défaut) : génère__repr__.slots=False(défaut, Python 3.10+) : siTrue, utilise__slots__pour une empreinte mémoire réduite et un accès aux attributs plus rapide.kw_only=False(défaut) : siTrue, tous les champs doivent être passés en arguments nommés.
```{code-cell} python
# kw_only=True : force l'utilisation de mots-clés
@dataclass(kw_only=True)
class Configuration:
hote: str
port: int = 8080
debug: bool = False
timeout: float = 30.0
cfg = Configuration(hote="localhost", port=9000, debug=True)
print(cfg)
# Sans kw_only, on pourrait écrire Configuration("localhost", 9000, True, 30.0)
# Avec kw_only, les noms de paramètres sont obligatoires → plus lisible
Post-initialisation#
La méthode __post_init__ est appelée automatiquement par le __init__ généré, après que tous les champs ont été initialisés. C’est l’endroit idéal pour effectuer des validations, des calculs dérivés, ou des transformations de données.
from dataclasses import dataclass, field, InitVar
from typing import Optional
import math
@dataclass
class Triangle:
"""Triangle défini par trois côtés, avec validation."""
a: float
b: float
c: float
# InitVar : paramètre passé à __post_init__ mais pas stocké comme attribut
valider: InitVar[bool] = True
# Champ calculé — pas dans __init__
aire: float = field(init=False, repr=True)
est_rectangle: bool = field(init=False, repr=False)
def __post_init__(self, valider: bool) -> None:
# Validation optionnelle
if valider:
if not (self.a + self.b > self.c and
self.b + self.c > self.a and
self.a + self.c > self.b):
raise ValueError(
f"Les côtés {self.a}, {self.b}, {self.c} "
f"ne forment pas un triangle valide"
)
# Calculs dérivés
s = (self.a + self.b + self.c) / 2
self.aire = math.sqrt(s * (s - self.a) * (s - self.b) * (s - self.c))
# Test du théorème de Pythagore (avec tolérance numérique)
cotes = sorted([self.a, self.b, self.c])
self.est_rectangle = abs(cotes[2]**2 - cotes[0]**2 - cotes[1]**2) < 1e-9
t1 = Triangle(3.0, 4.0, 5.0)
print(f"Triangle : {t1}")
print(f"Aire : {t1.aire:.4f}")
print(f"Rectangle : {t1.est_rectangle}")
t2 = Triangle(5.0, 5.0, 5.0)
print(f"\nTriangle équilatéral : {t2}")
print(f"Aire : {t2.aire:.4f}")
try:
Triangle(1.0, 2.0, 10.0)
except ValueError as e:
print(f"\nErreur attendue : {e}")
Triangle : Triangle(a=3.0, b=4.0, c=5.0, aire=6.0)
Aire : 6.0000
Rectangle : True
Triangle équilatéral : Triangle(a=5.0, b=5.0, c=5.0, aire=10.825317547305483)
Aire : 10.8253
Erreur attendue : Les côtés 1.0, 2.0, 10.0 ne forment pas un triangle valide
dataclasses vs NamedTuple#
Python offre une alternative aux dataclasses pour les données immuables : typing.NamedTuple. Voici une comparaison des deux approches.
from dataclasses import dataclass
from typing import NamedTuple
# ── NamedTuple ──────────────────────────────────────────
class PointNT(NamedTuple):
"""Point immuable — version NamedTuple."""
x: float
y: float
etiquette: str = ""
def distance_origine(self) -> float:
return (self.x**2 + self.y**2) ** 0.5
# ── dataclass frozen ─────────────────────────────────────
@dataclass(frozen=True)
class PointDC:
"""Point immuable — version @dataclass(frozen=True)."""
x: float
y: float
etiquette: str = ""
def distance_origine(self) -> float:
return (self.x**2 + self.y**2) ** 0.5
pnt = PointNT(3.0, 4.0, "A")
pdc = PointDC(3.0, 4.0, "A")
print(f"NamedTuple : {pnt}")
print(f"Dataclass : {pdc}")
# NamedTuple est un tuple → supporte l'accès par indice et le déballage
print(f"\npnt[0] = {pnt[0]}, pnt[1] = {pnt[1]}")
x, y, etq = pnt
print(f"Déballage : x={x}, y={y}, etq={etq!r}")
print(f"isinstance(pnt, tuple) = {isinstance(pnt, tuple)}")
# Dataclass (frozen) n'est pas un tuple
try:
_ = pdc[0]
except TypeError as e:
print(f"\npdc[0] → {e}")
# Les deux supportent __hash__
print(f"\nhash(pnt) = {hash(pnt)}")
print(f"hash(pdc) = {hash(pdc)}")
NamedTuple : PointNT(x=3.0, y=4.0, etiquette='A')
Dataclass : PointDC(x=3.0, y=4.0, etiquette='A')
pnt[0] = 3.0, pnt[1] = 4.0
Déballage : x=3.0, y=4.0, etq='A'
isinstance(pnt, tuple) = True
pdc[0] → 'PointDC' object is not subscriptable
hash(pnt) = 8404809226512588259
hash(pdc) = 8404809226512588259
Remarque 21
Quand choisir NamedTuple ? Quand vous avez besoin d’un tuple nommé interopérable avec du code qui attend des tuples (déballage, accès par indice, passage à des fonctions qui attendent un tuple), ou quand l’ordre des champs a une signification intrinsèque. NamedTuple est aussi légèrement plus efficace en mémoire car c’est un vrai tuple.
Quand choisir @dataclass ? Dans presque tous les autres cas. Les dataclasses sont plus flexibles : elles supportent l’héritage proprement, les propriétés, __post_init__, et ne vous imposent pas la sémantique des tuples. Si vous voulez l’immuabilité, utilisez frozen=True.
attrs — une bibliothèque plus puissante#
La bibliothèque attrs (et son API moderne @define) est l’ancêtre de @dataclass et reste populaire pour ses fonctionnalités avancées : validateurs, convertisseurs, et une syntaxe très concise.
try:
import attrs
from attrs import define, field as attrs_field, validators, converters
ATTRS_DISPONIBLE = True
except ImportError:
ATTRS_DISPONIBLE = False
print("La bibliothèque 'attrs' n'est pas installée.")
print("Pour l'installer : pip install attrs")
if ATTRS_DISPONIBLE:
@define
class Utilisateur:
"""Utilisateur avec validation intégrée via attrs."""
nom: str = attrs_field(validator=validators.min_len(2))
email: str = attrs_field(
validator=validators.matches_re(r"^[^@]+@[^@]+\.[^@]+$")
)
age: int = attrs_field(
converter=int, # Convertit automatiquement les str en int
validator=[
validators.instance_of(int),
validators.ge(0),
validators.le(150),
]
)
score: float = attrs_field(default=0.0)
try:
u = Utilisateur("Alice", "alice@exemple.fr", "28") # age=str → converti
print(f"Utilisateur : {u}")
except Exception as e:
print(f"Erreur : {e}")
try:
# Email invalide → validation échoue
Utilisateur("Bob", "bob-sans-at.com", 30)
except Exception as e:
print(f"Email invalide : {e}")
try:
# Nom trop court → validation échoue
Utilisateur("X", "x@exemple.fr", 25)
except Exception as e:
print(f"Nom trop court : {e}")
Utilisateur : Utilisateur(nom='Alice', email='alice@exemple.fr', age=28, score=0.0)
Email invalide : ("'email' must match regex '^[^@]+@[^@]+\\\\.[^@]+$' ('bob-sans-at.com' doesn't)", Attribute(name='email', default=NOTHING, validator=<matches_re validator for pattern re.compile('^[^@]+@[^@]+\\.[^@]+$')>, repr=True, eq=True, eq_key=None, order=True, order_key=None, hash=None, init=True, metadata=mappingproxy({}), type=<class 'str'>, converter=None, kw_only=False, inherited=False, on_setattr=None, alias='email'), re.compile('^[^@]+@[^@]+\\.[^@]+$'), 'bob-sans-at.com')
Nom trop court : Length of 'nom' must be >= 2: 1
```{prf:definition} attrs — validateurs et convertisseurs
:label: definition-09-03
La bibliothèque attrs (API moderne : from attrs import define) offre deux fonctionnalités absentes de @dataclass :
Les validateurs (
validator=) vérifient les valeurs à la construction et lèvent une exception si elles ne respectent pas les contraintes.Les convertisseurs (
converter=) transforment automatiquement les valeurs avant de les stocker (ex :converter=intconvertit"42"en42).
Ces fonctionnalités sont particulièrement utiles pour les interfaces publiques où les données viennent de sources non fiables (formulaires, fichiers JSON, APIs).
## Visualisation : comparaison des approches
```{code-cell} python
:tags: [hide-input]
fig, axes = plt.subplots(1, 4, figsize=(18, 9))
fig.suptitle("Comparaison des approches pour les classes de données",
fontsize=14, fontweight='bold', y=1.01)
palette = sns.color_palette("Set2", 4)
approches = [
{
"titre": "Classe manuelle",
"couleur": palette[0],
"lignes_code": 30,
"lignes_utiles": 4,
"sections": [
("def __init__", 8, "#e74c3c"),
("def __repr__", 7, "#e67e22"),
("def __eq__", 7, "#f39c12"),
("def __hash__", 3, "#f1c40f"),
("Logique métier", 5, "#2ecc71"),
],
"features": [
("✅", "Contrôle total"),
("✅", "Héritage facile"),
("❌", "Très répétitif"),
("❌", "Fragile à maintenir"),
("❌", "Peu lisible"),
]
},
{
"titre": "@dataclass",
"couleur": palette[1],
"lignes_code": 10,
"lignes_utiles": 4,
"sections": [
("@dataclass", 1, "#3498db"),
("Champs annotés", 4, "#9b59b6"),
("__post_init__", 3, "#1abc9c"),
("Logique métier", 2, "#2ecc71"),
],
"features": [
("✅", "Standard library"),
("✅", "frozen, order, slots"),
("✅", "__post_init__"),
("⚠️", "Pas de validateurs natifs"),
("✅", "Héritage supporté"),
]
},
{
"titre": "NamedTuple",
"couleur": palette[2],
"lignes_code": 7,
"lignes_utiles": 4,
"sections": [
("class Nom(NamedTuple):", 1, "#3498db"),
("Champs annotés", 3, "#9b59b6"),
("Méthodes", 3, "#2ecc71"),
],
"features": [
("✅", "Immuable (tuple)"),
("✅", "Déballage, indice"),
("✅", "Hash automatique"),
("❌", "Héritage limité"),
("❌", "Pas de mutabilité"),
]
},
{
"titre": "attrs @define",
"couleur": palette[3],
"lignes_code": 10,
"lignes_utiles": 4,
"sections": [
("@define", 1, "#e74c3c"),
("Champs + validators", 5, "#9b59b6"),
("Converters", 2, "#f39c12"),
("Logique métier", 2, "#2ecc71"),
],
"features": [
("✅", "Validateurs intégrés"),
("✅", "Convertisseurs auto"),
("✅", "Très configurable"),
("⚠️", "Dépendance externe"),
("✅", "Excellente perf."),
]
},
]
for ax, approche in zip(axes, approches):
col = approche["couleur"]
ax.set_xlim(0, 10)
ax.set_ylim(0, 10)
ax.axis('off')
# Titre
header = patches.FancyBboxPatch(
(0.3, 8.8), 9.4, 0.9,
boxstyle="round,pad=0.1", linewidth=2,
edgecolor=col, facecolor=col, alpha=0.85
)
ax.add_patch(header)
ax.text(5.0, 9.25, approche["titre"],
ha='center', va='center', fontsize=11,
fontweight='bold', color='white')
# Diagramme de code (barres empilées horizontalement)
sections = approche["sections"]
total_lignes = sum(s[1] for s in sections)
bar_y = 6.8
bar_h = 1.5
x_cursor = 0.3
bar_total_w = 9.4
for (label, lignes, couleur) in sections:
w = (lignes / total_lignes) * bar_total_w
rect = patches.FancyBboxPatch(
(x_cursor, bar_y), w - 0.05, bar_h,
boxstyle="square,pad=0.0", linewidth=0,
facecolor=couleur, alpha=0.75
)
ax.add_patch(rect)
if w > 1.2:
ax.text(x_cursor + w/2 - 0.025, bar_y + bar_h/2,
f"{label}\n({lignes}L)",
ha='center', va='center', fontsize=6.5,
color='white', fontweight='bold',
fontfamily='monospace')
x_cursor += w
ax.text(5.0, bar_y - 0.3,
f"~{total_lignes} lignes au total",
ha='center', va='center', fontsize=8, color='#555',
style='italic')
# Fonctionnalités
for i, (icone, texte) in enumerate(approche["features"]):
y = 5.8 - i * 0.9
ax.text(0.6, y, icone, ha='left', va='center', fontsize=11)
ax.text(1.5, y, texte, ha='left', va='center',
fontsize=8, color='#333')
# Barre du bas : nombre de lignes utiles (champs définis)
ax.text(5.0, 0.7,
f"{approche['lignes_utiles']} lignes pour définir les champs",
ha='center', va='center', fontsize=8,
color=col, fontweight='bold',
bbox=dict(boxstyle='round,pad=0.3', facecolor=col,
alpha=0.12, edgecolor=col))
plt.tight_layout()
plt.show()
Exemple 5 (Même exemple avec les quatre approches)
Un point 3D avec coordonnées et étiquette, implémenté avec chaque approche, illustre concrètement les différences.
import math
from dataclasses import dataclass
from typing import NamedTuple
# ── 1. Classe manuelle ──────────────────────────────────
class Point3DManuel:
def __init__(self, x: float, y: float, z: float, etiquette: str = "") -> None:
self.x, self.y, self.z = x, y, z
self.etiquette = etiquette
def norme(self) -> float:
return math.sqrt(self.x**2 + self.y**2 + self.z**2)
def __repr__(self) -> str:
return (f"Point3DManuel(x={self.x!r}, y={self.y!r}, "
f"z={self.z!r}, etiquette={self.etiquette!r})")
def __eq__(self, other: object) -> bool:
if not isinstance(other, Point3DManuel):
return NotImplemented
return (self.x == other.x and self.y == other.y and
self.z == other.z and self.etiquette == other.etiquette)
# ── 2. @dataclass ───────────────────────────────────────
@dataclass
class Point3DDC:
x: float
y: float
z: float
etiquette: str = ""
def norme(self) -> float:
return math.sqrt(self.x**2 + self.y**2 + self.z**2)
# ── 3. NamedTuple ────────────────────────────────────────
class Point3DNT(NamedTuple):
x: float
y: float
z: float
etiquette: str = ""
def norme(self) -> float:
return math.sqrt(self.x**2 + self.y**2 + self.z**2)
# ── Comparaison des résultats ─────────────────────────────
points = [
Point3DManuel(1.0, 2.0, 3.0, "A"),
Point3DDC(1.0, 2.0, 3.0, "A"),
Point3DNT(1.0, 2.0, 3.0, "A"),
]
for p in points:
print(f"{type(p).__name__:20s} | {p!r}")
print(f"{'':20s} | norme = {p.norme():.4f}")
# Déballage : uniquement pour NamedTuple
x, y, z, label = points[2]
print(f"\nDéballage NamedTuple : x={x}, y={y}, z={z}, label={label!r}")
Point3DManuel | Point3DManuel(x=1.0, y=2.0, z=3.0, etiquette='A')
| norme = 3.7417
Point3DDC | Point3DDC(x=1.0, y=2.0, z=3.0, etiquette='A')
| norme = 3.7417
Point3DNT | Point3DNT(x=1.0, y=2.0, z=3.0, etiquette='A')
| norme = 3.7417
Déballage NamedTuple : x=1.0, y=2.0, z=3.0, label='A'
Résumé#
Ce chapitre a présenté les outils modernes de Python pour créer des classes de données sans répétition :
Le problème des classes de données manuelles est la répétition :
__init__,__repr__et__eq__doivent être maintenus en synchronisation avec la liste des champs.@dataclassgénère automatiquement ces méthodes à partir des annotations de type. La fonctionfield()configure finement chaque champ (valeur par défaut, inclusion dansrepr,compare, etc.).Les options de
@dataclassoffrent des comportements avancés :frozen=Truepour l’immuabilité et le hashage,order=Truepour les opérateurs de comparaison,slots=Truepour l’efficacité mémoire,kw_only=Truepour forcer les arguments nommés.__post_init__permet d’ajouter de la validation et des calculs dérivés après l’initialisation automatique.InitVarpermet de passer des arguments à__post_init__sans les stocker comme attributs.NamedTupleest une alternative immuable qui produit de vrais tuples, avec déballage et accès par indice — idéale quand la sémantique de tuple est utile.attrs(@define) est une bibliothèque externe offrant des validateurs et convertisseurs intégrés, particulièrement précieux pour les données provenant de sources externes.
Le chapitre suivant explore les itérateurs et générateurs — le mécanisme qui sous-tend toutes les boucles for en Python, et qui permet de créer des séquences paresseuses de taille arbitraire.