Dataclasses et attrs#

Hide code cell source

import matplotlib.pyplot as plt
import matplotlib.patches as patches
import numpy as np
import seaborn as sns

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)

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 :

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

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

  3. 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) : si True, génère __lt__, __le__, __gt__, __ge__.

  • frozen=False (défaut) : si True, 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+) : si True, utilise __slots__ pour une empreinte mémoire réduite et un accès aux attributs plus rapide.

  • kw_only=False (défaut) : si True, 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=int convertit "42" en 42).

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.

  • @dataclass génère automatiquement ces méthodes à partir des annotations de type. La fonction field() configure finement chaque champ (valeur par défaut, inclusion dans repr, compare, etc.).

  • Les options de @dataclass offrent des comportements avancés : frozen=True pour l’immuabilité et le hashage, order=True pour les opérateurs de comparaison, slots=True pour l’efficacité mémoire, kw_only=True pour forcer les arguments nommés.

  • __post_init__ permet d’ajouter de la validation et des calculs dérivés après l’initialisation automatique. InitVar permet de passer des arguments à __post_init__ sans les stocker comme attributs.

  • NamedTuple est 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.