Retour au cours

backend / python

Dataclasses et attrs en profondeur

Leçon 311 exercice

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) et attrs (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éeRôleActivée par
__init__Constructeur à partir des champs déclarésToujours (par défaut)
__repr__Affichage lisible en debugToujours (par défaut)
__eq__Comparaison champ par champToujours (par défaut)
__hash__Utilisable comme clé de dict/setfrozen=True
__lt__, __le__, __gt__, __ge__Tri d'une liste d'instancesorder=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.

python
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
'''
Besoindataclassattrs
Stdlib, zero dependanceouinon (paquet externe)
Validateurs de champ natifsnon (via __post_init__)oui (validator=)
Converters automatiquesnonoui (converter=)
__slots__ par defautnon (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=True rend l'instance immuable ET hashable ; order=True genere les comparateurs de tri.
  • __post_init__ valide/derive des champs apres construction ; InitVar passe une valeur sans la stocker.
  • dataclass (stdlib) suffit à la plupart des besoins ; attrs ajoute validateurs/converters natifs.

Exercices pratiques

1 disponible
1

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__.

Résoudre l’exercice →