dataclasses : classes de données compactes en Python

1. Aperçu

Quand vous écrivez une classe juste pour contenir des données (comme Order, Customer, Point…), vous devez généralement écrire vous-même __init__, __repr__, __eq__ — tout cela est du code répétitif. Le module dataclasses (disponible depuis Python 3.7) vous aide à générer automatiquement ces méthodes via un seul décorateur. Après avoir lu cet article, vous saurez comment utiliser @dataclass pour écrire des classes de données compactes, valider les données avec __post_init__, rendre une classe immuable, et convertir vers dict/JSON.

2. Qu’est-ce qu’une dataclass basique

@dataclass est un décorateur qui lit les type hints d’une classe, puis génère automatiquement __init__, __repr__, __eq__ basés sur ces fields.

from dataclasses import dataclass

@dataclass
class Customer:
    name: str
    email: str
    age: int = 0
  • @dataclass parcourt les attributs avec type hint (name, email, age) et les traite comme des fields.
  • age: int = 0 a une valeur par défaut, comme un paramètre optionnel dans une fonction.
  • Vous n’avez pas besoin d’écrire __init__ — il est généré automatiquement dans le bon ordre.
c1 = Customer("An", "an@example.com")
print(c1)
Customer(name='An', email='an@example.com', age=0)

__repr__ est généré automatiquement pour afficher le nom complet de chaque field et sa valeur, ce qui est très pratique pour déboguer — une classe ordinaire n’a pas cela gratuitement.

3. Comparaison avec une classe normale et namedtuple

Écrire manuellement la classe équivalente serait beaucoup plus long :

class CustomerPlain:
    def __init__(self, name, email, age=0):
        self.name = name
        self.email = email
        self.age = age

    def __repr__(self):
        return f"CustomerPlain(name={self.name!r}, email={self.email!r}, age={self.age!r})"

    def __eq__(self, other):
        if not isinstance(other, CustomerPlain):
            return NotImplemented
        return (self.name, self.email, self.age) == (other.name, other.email, other.age)

@dataclass vous épargne tout cela. Par rapport à namedtuple, une dataclass diffère sur :

  • Les objets sont mutables par défaut (vous pouvez faire c1.age = 20), namedtuple ne l’est pas.
  • Support explicite des type hints, facile à intégrer avec des vérificateurs de type comme mypy.
  • Support de __post_init__, des valeurs par défaut complexes, de l’héritage — des choses que namedtuple ne gère pas bien.

Remarque : Si vous avez besoin d’un tuple immuable simple et léger sans méthodes supplémentaires, namedtuple ou typing.NamedTuple restent un choix plus concis.

4. field() et valeurs par défaut mutables

Une erreur très courante : utiliser une liste/dict directement comme valeur par défaut.

from dataclasses import dataclass, field

@dataclass
class Order:
    order_id: str
    items: list[str] = field(default_factory=list)
  • Si vous écrivez items: list[str] = [], Python lève ValueError: mutable default ... is not allowed — car la liste par défaut serait partagée entre toutes les instances, causant un bug difficile à trouver.
  • field(default_factory=list) demande à dataclass d’appeler list() nouvellement pour chaque instance.
o1 = Order("A1")
o2 = Order("A2")
o1.items.append("Sách")

print(o1.items, o2.items)
['Sách'] []

Les deux objets ont des items indépendants, exactement comme attendu.

5. Valider les données avec __post_init__

L’__init__ généré par dataclass ne vous permet pas d’injecter de la logique de validation directement — mais il y a un hook __post_init__ qui s’exécute immédiatement après que __init__ soit terminé.

from dataclasses import dataclass

@dataclass
class PaymentRequest:
    amount: float
    currency: str = "VND"

    def __post_init__(self):
        if self.amount <= 0:
            raise ValueError("amount doit être supérieur à 0")
        self.currency = self.currency.upper()
  • __post_init__ est appelé automatiquement après l’assignation de tous les fields.
  • Ici nous validons amount et normalisons currency en majuscules.
try:
    PaymentRequest(amount=-100)
except ValueError as e:
    print(e)
amount doit être supérieur à 0

6. Classe immuable avec frozen=True

Si vous voulez que l’objet ne puisse pas être modifié après sa création (comme un record en Java), utilisez frozen=True.

from dataclasses import dataclass

@dataclass(frozen=True)
class Point:
    x: float
    y: float
  • frozen=True bloque __setattr__ — toute assignation après l’initialisation lève une erreur.
  • Grâce à l’immuabilité, un objet frozen peut servir de clé dans un dict ou d’élément dans un set (car dataclass génère automatiquement __hash__ quand frozen=True et eq=True).
p = Point(1.0, 2.0)
try:
    p.x = 5.0
except Exception as e:
    print(type(e).__name__, e)
FrozenInstanceError cannot assign to field 'x'

Remarque : frozen=True bloque seulement l’assignation d’attributs de niveau 1. Si un field est une liste/dict (mutable), le contenu à l’intérieur peut toujours être modifié. Pour une véritable immuabilité, utilisez tuple ou un type immuable pour ce field.

7. Héritage avec dataclass

Les dataclasses supportent l’héritage normal comme les classes Python, les fields de la classe enfant viennent après ceux de la classe parent.

from dataclasses import dataclass

@dataclass
class Person:
    name: str
    age: int

@dataclass
class Employee(Person):
    salary: float = 0.0
  • Employee hérité de name, age de Person, puis ajoute salary.
  • L’ordre des paramètres dans l’__init__ généré est : name, age, salary — preservant l’ordre de déclaration.
e = Employee("Bình", 30, salary=15_000_000)
print(e)
Employee(name='Bình', age=30, salary=15000000.0)

Remarque : Les fields sans valeur par défaut doivent venir avant ceux avec une valeur par défaut dans toute la chaîne d’héritage. Si la classe parent a un field avec une valeur par défaut mais la classe enfant ajoute un field sans, Python lève TypeError lors de la définition de la classe.

8. Comparaison et tri avec order=True

Par défaut, dataclass génère seulement __eq__. Pour comparer <, >, <=, utilisez order=True.

from dataclasses import dataclass

@dataclass(order=True)
class Product:
    price: float
    name: str = field(compare=False)
  • order=True génère les méthodes __lt__, __le__, __gt__, __ge__, en comparant l’ordre des fields (comme comparer des tuples).
  • field(compare=False) exclut name de la comparaison — on compare seulement selon price.
products = [Product(50_000, "Bút"), Product(20_000, "Vở")]
print(sorted(products))
[Product(price=20000, name='Vở'), Product(price=50000, name='Bút')]

sorted() utilise __lt__ généré par dataclass, pas besoin d’écrire manuellement key=lambda p: p.price.

9. Conversion vers dict et tuple

dataclasses fournit les fonctions utilitaires asdict et astuple pour sérialiser les objets.

from dataclasses import dataclass, asdict, astuple

@dataclass
class Address:
    city: str
    country: str

@dataclass
class Customer2:
    name: str
    address: Address
cust = Customer2("Chi", Address("Hà Nội", "Việt Nam"))
print(asdict(cust))
print(astuple(cust))
{'name': 'Chi', 'address': {'city': 'Hà Nội', 'country': 'Việt Nam'}}
('Chi', ('Hà Nội', 'Việt Nam'))
  • asdict convertit récursivement même les dataclasses imbriquées (Address) en dicts imbriqués — très pratique pour retourner du JSON via une API.
  • astuple fonctionne pareil mais retourne un tuple imbriqué.

Remarque : asdict/astuple font une copie profonde de toutes les données. Avec des objets larges ou contenant des fields non-copiables (handles de fichiers, connexions…), appeler cette fonction sera lent ou causera une erreur.

10. Tester une dataclass

Puisque __eq__ est généré automatiquement selon les valeurs des fields, tester la comparaison d’objets est très naturel avec pytest.

import pytest
from dataclasses import FrozenInstanceError

def test_customer_equality():
    c1 = Customer("An", "an@example.com", 20)
    c2 = Customer("An", "an@example.com", 20)
    assert c1 == c2

def test_payment_request_rejects_non_positive_amount():
    with pytest.raises(ValueError):
        PaymentRequest(amount=0)

def test_point_is_frozen():
    p = Point(1.0, 2.0)
    with pytest.raises(FrozenInstanceError):
        p.x = 10.0
  • test_customer_equality vérifie que deux objets d’instances différentes mais avec les mêmes valeurs de fields sont == — c’est correct grâce à __eq__ généré.
  • test_payment_request_rejects_non_positive_amount vérifie que __post_init__ valide correctement.
  • test_point_is_frozen confirme que frozen=True bloque les assignations d’attributs.

11. Erreurs courantes et quand ne pas l’utiliser

  • Oublier default_factory pour une valeur par défaut mutable : utiliser = [] ou = {} directement causera une erreur immédiatement lors de la déclaration de la classe, il faut utiliser field(default_factory=...).
  • Mélanger les fields avec et sans valeur par défaut dans le mauvais ordre lors de l’héritage : cause TypeError lors du chargement du module, difficile à déboguer si la classe est dispersée dans plusieurs fichiers.
  • Penser que frozen=True est profondément immuable : les fields de type liste/dict peuvent toujours avoir leur contenu modifié, seul l’attribut lui-même est protégé.
  • Utiliser dataclass pour de la logique métier complexe : si la classe a beaucoup de méthodes, des relations complexes avec d’autres objets, dataclass aide seulement pour l’initialisation/comparaison — la logique vous incombe toujours, n’en abusez pas pour “prétendre” que la classe est plus simple qu’elle ne l’est.
  • Besoin de compatibilité avec Python < 3.7 : dataclasses n’existe pas nativement, vous devez installer le package dataclasses depuis PyPI (backport) ou utiliser attrs.

Remarque : Pour une validation complexe, une sérialisation personnalisée, ou une intégration profonde avec JSON schema, les bibliothèques comme pydantic sont souvent plus puissantes que les dataclasses pures — à considérer pour les grands projets.

12. Conclusion

dataclasses vous aide à éliminer la plupart du boilerplate quand vous écrivez des classes de données : génération automatique de __init__, __repr__, __eq__, support de la validation via __post_init__, immuabilité via frozen=True, et sérialisation facile via asdict/astuple. Pour les classes de données simples à moyennes, c’est un choix beaucoup plus concis et lisible que l’écriture manuelle. Quand vos besoins en validation ou sérialisation deviennent plus complexes, vous pouvez considérer pydantic ou attrs comme alternatives.

pythondataclassesoopclean-code