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
@dataclassparcourt les attributs avec type hint (name,email,age) et les traite comme des fields.age: int = 0a 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 quenamedtuplene gère pas bien.
Remarque : Si vous avez besoin d’un tuple immuable simple et léger sans méthodes supplémentaires,
namedtupleoutyping.NamedTuplerestent 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èveValueError: 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’appelerlist()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
amountet normalisonscurrencyen 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=Truebloque__setattr__— toute assignation après l’initialisation lève une erreur.- Grâce à l’immuabilité, un objet
frozenpeut servir de clé dans undictou d’élément dans unset(car dataclass génère automatiquement__hash__quandfrozen=Trueeteq=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=Truebloque 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é, utiliseztupleou 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
Employeehérité dename,agedePerson, puis ajoutesalary.- 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
TypeErrorlors 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=Truegénère les méthodes__lt__,__le__,__gt__,__ge__, en comparant l’ordre des fields (comme comparer des tuples).field(compare=False)exclutnamede la comparaison — on compare seulement selonprice.
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'))
asdictconvertit récursivement même les dataclasses imbriquées (Address) en dicts imbriqués — très pratique pour retourner du JSON via une API.astuplefonctionne pareil mais retourne un tuple imbriqué.
Remarque :
asdict/astuplefont 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_equalityvé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_amountvérifie que__post_init__valide correctement.test_point_is_frozenconfirme quefrozen=Truebloque les assignations d’attributs.
11. Erreurs courantes et quand ne pas l’utiliser
- Oublier
default_factorypour une valeur par défaut mutable : utiliser= []ou= {}directement causera une erreur immédiatement lors de la déclaration de la classe, il faut utiliserfield(default_factory=...). - Mélanger les fields avec et sans valeur par défaut dans le mauvais ordre lors de l’héritage : cause
TypeErrorlors du chargement du module, difficile à déboguer si la classe est dispersée dans plusieurs fichiers. - Penser que
frozen=Trueest 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 :
dataclassesn’existe pas nativement, vous devez installer le packagedataclassesdepuis PyPI (backport) ou utiliserattrs.
Remarque : Pour une validation complexe, une sérialisation personnalisée, ou une intégration profonde avec JSON schema, les bibliothèques comme
pydanticsont souvent plus puissantes que lesdataclassespures — à 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.