---
jupytext:
  text_representation:
    extension: .md
    format_name: myst
    format_version: 0.13
    jupytext_version: 1.16.0
kernelspec:
  name: python3
  display_name: Python 3
---

# Dataclasses et attrs

```{code-cell} python
:tags: [hide-input]

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 :

```{code-cell} python
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"))
```

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

```{prf:definition} Classe de données (*data class*)
:label: definition-09-01
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.

```{code-cell} python
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
```

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.

```{code-cell} python
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
```

```{prf:remark}
:label: remark-09-01
**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.

```{code-cell} python
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
```

```{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.

```{code-cell} python
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}")
```

## `dataclasses` vs `NamedTuple`

Python offre une alternative aux dataclasses pour les données immuables : `typing.NamedTuple`. Voici une comparaison des deux approches.

```{code-cell} python
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)}")
```

```{prf:remark}
:label: remark-09-02
**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.

```{code-cell} python
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")
```

```{code-cell} python
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}")
```

```{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()
```

```{prf:example} Même exemple avec les quatre approches
:label: example-09-01
Un point 3D avec coordonnées et étiquette, implémenté avec chaque approche, illustre concrètement les différences.
```

```{code-cell} python
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}")
```

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