Les Dataclasses Python
Apprenez les dataclasses Python : le décorateur @dataclass, les valeurs par défaut avec field(), l'ordre, l'immuabilité et l'héritage avec des exemples concrets.
Une dataclass est une classe Python ordinaire dont le code répétitif — __init__, __repr__ et __eq__ — est généré automatiquement par le décorateur @dataclass. Le résultat est moins de code, moins d'erreurs, et des classes qui sont immédiatement lisibles.
Ce chapitre couvre :
- Pourquoi les dataclasses existent et quand les utiliser
- Le décorateur
@dataclass - Les valeurs par défaut des champs et le helper
field() - Contrôle de l'égalité et de l'ordre
- Les dataclasses immuables avec
frozen=True - La logique de post-initialisation avec
__post_init__ - L'héritage avec les dataclasses
- Dataclasses vs.
NamedTuplevs. classes ordinaires
Avant de lire ce chapitre, assurez-vous d'être à l'aise avec les classes et objets Python et l'héritage Python.
Pourquoi les Dataclasses ?
Considérez une classe qui stocke un produit dans une boutique en ligne. Sans dataclasses, vous écrivez les mêmes affectations d'attributs trois fois — une fois dans __init__, une fois dans __repr__, et une fois dans __eq__ :
class Product:
def __init__(self, name, price, stock):
self.name = name
self.price = price
self.stock = stock
def __repr__(self):
return f"Product(name={self.name!r}, price={self.price}, stock={self.stock})"
def __eq__(self, other):
if not isinstance(other, Product):
return NotImplemented
return (self.name, self.price, self.stock) == (other.name, other.price, other.stock)Le décorateur @dataclass génère tout ce qui précède à partir d'une seule liste annotée de champs :
from dataclasses import dataclass
@dataclass
class Product:
name: str
price: float
stock: intLes deux versions se comportent de façon identique. La version dataclass est plus courte, moins sujette aux erreurs, et communique instantanément que cette classe est avant tout un conteneur de données.
Le Décorateur @dataclass
Importez dataclass depuis le module standard dataclasses et appliquez-le à votre classe. Chaque champ est déclaré comme une variable de classe avec annotation de type :
from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float
p = Point(1.5, 2.0)
print(p) # Point(x=1.5, y=2.0)
print(p.x) # 1.5
p2 = Point(1.5, 2.0)
print(p == p2) # True — __eq__ compares field by fieldLe décorateur génère :
| Méthode | Ce qu'elle fait |
|---|---|
__init__ | Accepte chaque champ comme paramètre et l'assigne à self |
__repr__ | Retourne une chaîne lisible comme Point(x=1.5, y=2.0) |
__eq__ | Compare deux instances champ par champ |
Les annotations de type sont obligatoires mais pas vérifiées à l'exécution
Les déclarations de champ nécessitent une annotation de type (x: float). Python ne vérifie pas le type à l'exécution — vous pouvez toujours passer une chaîne là où un float est attendu. L'annotation est une métadonnée utilisée par les vérificateurs de types tels que mypy et par le mécanisme dataclasses lui-même. Pour la validation de type à l'exécution, voir Python Type Hints.
Valeurs par Défaut
Assignez une valeur par défaut directement sur le champ pour le rendre optionnel dans __init__ :
from dataclasses import dataclass
@dataclass
class Config:
host: str = "localhost"
port: int = 8080
debug: bool = False
c1 = Config()
print(c1) # Config(host='localhost', port=8080, debug=False)
c2 = Config(host="example.com", port=443)
print(c2) # Config(host='example.com', port=443, debug=False)Les champs avec des valeurs par défaut doivent apparaître après les champs sans valeurs par défaut — exactement la même règle que pour les paramètres de fonctions ordinaires.
Valeurs par défaut mutables et field()
Vous ne pouvez pas utiliser un objet mutable (une liste, un dict, ou un ensemble) comme valeur par défaut simple. Python partagerait une seule liste entre toutes les instances, ce qui entraîne des bogues subtils :
from dataclasses import dataclass
# This raises a ValueError at class definition time:
# @dataclass
# class Bag:
# items: list = [] # ValueError: mutable default is not allowedUtilisez plutôt field(default_factory=...) pour créer un nouvel objet pour chaque instance :
from dataclasses import dataclass, field
@dataclass
class Bag:
items: list = field(default_factory=list)
b1 = Bag()
b2 = Bag()
b1.items.append("apple")
print(b1.items) # ['apple']
print(b2.items) # [] — b2 has its own separate listdefault_factory accepte tout callable sans argument, y compris les lambdas et vos propres fonctions.
Le Helper field()
field() vous donne un contrôle précis sur les champs individuels. Ses paramètres les plus utiles sont :
| Paramètre | Rôle |
|---|---|
default | Une valeur par défaut simple (scalaire uniquement) |
default_factory | Un callable qui produit la valeur par défaut |
repr | False pour exclure ce champ de __repr__ |
compare | False pour exclure ce champ de __eq__ (et de l'ordre) |
init | False pour exclure ce champ de __init__ |
from dataclasses import dataclass, field
import time
@dataclass
class LogEntry:
message: str
level: str = "INFO"
timestamp: float = field(default_factory=time.time, repr=False, compare=False)
entry = LogEntry("Server started")
print(entry) # LogEntry(message='Server started', level='INFO')
# timestamp exists but is hidden from repr and ignored in comparisons
print(entry.timestamp > 0) # TrueOrdre
Par défaut, les dataclasses prennent en charge l'égalité (==, !=) mais pas l'ordre (<, >, <=, >=). Activez l'ordre en passant order=True au décorateur :
from dataclasses import dataclass
@dataclass(order=True)
class Version:
major: int
minor: int
patch: int
v1 = Version(1, 2, 0)
v2 = Version(1, 3, 0)
v3 = Version(1, 2, 0)
print(v1 < v2) # True
print(v1 == v3) # True
print(v2 > v1) # True
versions = [Version(2, 0, 0), Version(1, 9, 1), Version(1, 2, 3)]
print(sorted(versions))
# [Version(major=1, minor=2, patch=3),
# Version(major=1, minor=9, patch=1),
# Version(major=2, minor=0, patch=0)]Python génère les méthodes de comparaison en comparant les champs dans l'ordre où ils sont déclarés, à la manière des tuples. Vous pouvez exclure un champ des comparaisons avec field(compare=False).
Dataclasses Immuables avec frozen=True
Passez frozen=True pour rendre tous les champs en lecture seule après la création. Toute tentative de modifier un champ lève une FrozenInstanceError :
from dataclasses import dataclass
@dataclass(frozen=True)
class Coordinate:
lat: float
lon: float
london = Coordinate(51.5074, -0.1278)
print(london) # Coordinate(lat=51.5074, lon=-0.1278)
# london.lat = 0.0 # FrozenInstanceError: cannot assign to field 'lat'Les dataclasses gelées sont aussi hachables (elles implémentent __hash__), donc vous pouvez les utiliser comme clés de dictionnaire ou membres d'ensemble :
from dataclasses import dataclass
@dataclass(frozen=True)
class Coordinate:
lat: float
lon: float
cities = {
Coordinate(51.5074, -0.1278): "London",
Coordinate(48.8566, 2.3522): "Paris",
}
print(cities[Coordinate(51.5074, -0.1278)]) # LondonLes dataclasses ordinaires (mutables) ne sont pas hachables par défaut — Python met __hash__ à None lorsque __eq__ est défini sans frozen=True.
Logique de Post-Initialisation avec __post_init__
Parfois, vous avez besoin de dériver la valeur d'un champ à partir d'autres champs, ou de valider l'entrée après l'exécution de __init__. Définissez une méthode __post_init__ — elle est appelée automatiquement à la fin du __init__ généré :
from dataclasses import dataclass, field
import math
@dataclass
class Circle:
radius: float
def __post_init__(self):
if self.radius <= 0:
raise ValueError(f"radius must be positive, got {self.radius}")
@property
def area(self):
return math.pi * self.radius ** 2
c = Circle(5)
print(round(c.area, 4)) # 78.5398
# Circle(-1) # ValueError: radius must be positive, got -1Vous pouvez également calculer un champ dérivé. Marquez-le avec field(init=False) pour qu'il n'apparaisse pas dans __init__, puis définissez-le à l'intérieur de __post_init__ :
from dataclasses import dataclass, field
@dataclass
class Rectangle:
width: float
height: float
area: float = field(init=False, repr=True)
def __post_init__(self):
self.area = self.width * self.height
r = Rectangle(4, 6)
print(r) # Rectangle(width=4, height=6, area=24)
print(r.area) # 24Héritage avec les Dataclasses
Une dataclass peut hériter d'une autre dataclass. Le __init__ de la classe enfant inclut les champs des deux classes — les champs du parent en premier, dans l'ordre où ils ont été déclarés :
from dataclasses import dataclass
@dataclass
class Animal:
name: str
age: int
@dataclass
class Dog(Animal):
breed: str
rex = Dog(name="Rex", age=3, breed="Labrador")
print(rex) # Dog(name='Rex', age=3, breed='Labrador')Point d'attention : si une classe parente a un champ avec une valeur par défaut, tous les champs de la classe enfant doivent également avoir des valeurs par défaut. C'est la même règle qui s'applique aux signatures de fonctions Python ordinaires — un paramètre sans valeur par défaut ne peut pas suivre un paramètre avec valeur par défaut.
from dataclasses import dataclass
@dataclass
class Animal:
name: str
age: int = 0 # has a default
# @dataclass
# class Dog(Animal):
# breed: str # TypeError: non-default argument 'breed' follows default argumentContournez cela en donnant également une valeur par défaut au champ de la classe enfant, ou en restructurant la hiérarchie de sorte que les champs avec valeurs par défaut viennent en dernier.
Paramètres du Décorateur en un Coup d'œil
@dataclass(
init=True, # generate __init__ (default True)
repr=True, # generate __repr__ (default True)
eq=True, # generate __eq__ (default True)
order=False, # generate <, >, <=, >= (default False)
frozen=False, # make fields immutable (default False)
)
class MyClass:
...Vous avez rarement besoin de toucher la plupart de ces paramètres. Les plus courants sont order=True et frozen=True.
Fonctions Utilitaires
Le module dataclasses fournit également trois fonctions pratiques :
fields()
Retourne un tuple d'objets Field décrivant chaque champ de la classe :
from dataclasses import dataclass, fields
@dataclass
class Point:
x: float
y: float
for f in fields(Point):
print(f.name, f.type)
# x <class 'float'>
# y <class 'float'>asdict()
Convertit une instance de dataclass en dictionnaire simple (de manière récursive) :
from dataclasses import dataclass, asdict
@dataclass
class Address:
street: str
city: str
@dataclass
class Person:
name: str
address: Address
p = Person("Alice", Address("10 Downing St", "London"))
print(asdict(p))
# {'name': 'Alice', 'address': {'street': '10 Downing St', 'city': 'London'}}Cela est utile lors de la sérialisation vers JSON ou de l'envoi de données à une API.
astuple()
Convertit en tuple (de manière récursive) :
from dataclasses import dataclass, astuple
@dataclass
class Point:
x: float
y: float
p = Point(3.0, 4.0)
print(astuple(p)) # (3.0, 4.0)Dataclasses vs. NamedTuple vs. Classes Ordinaires
| Fonctionnalité | Classe ordinaire | NamedTuple | dataclass |
|---|---|---|---|
__init__ automatique | Non | Oui | Oui |
__repr__ automatique | Non | Oui | Oui |
__eq__ automatique | Non | Oui (par valeur) | Oui (par valeur) |
| Mutable | Oui | Non | Oui (par défaut) |
| Hachable | Non (si __eq__ défini) | Oui | Uniquement avec frozen=True |
| Ordre | Manuel | Oui | order=True |
| Héritage | Oui | Limité | Oui |
Vérification isinstance | Oui | Oui (aussi tuple) | Oui |
Déstructuration (a, b = obj) | Non | Oui | Non |
Utilisez une dataclass quand :
- Vous voulez des données mutables avec une immuabilité optionnelle.
- Vous avez besoin de l'héritage ou d'une logique post-init.
- Vous voulez un contrôle précis des champs (
field()).
Utilisez NamedTuple quand :
- Vous voulez un enregistrement immuable qui se comporte également comme un tuple (déstructuration positionnelle, lignes CSV).
- Vous avez besoin de compatibilité avec du code qui attend des tuples.
Utilisez une classe ordinaire quand :
- La classe a un comportement significatif et très peu de données brutes.
- Vous avez besoin d'un
__init__personnalisé qui ne peut pas être exprimé via__post_init__.
Pièges Courants
Valeurs par défaut mutables. Utiliser une liste ou un dict comme valeur par défaut simple lève une ValueError au moment de la définition de la classe. Utilisez toujours field(default_factory=...).
Hachage. Les dataclasses ordinaires ne sont pas hachables. Si vous avez besoin de les utiliser comme clés de dict ou dans des ensembles, utilisez frozen=True ou passez unsafe_hash=True (rarement recommandé).
eq=False. Si vous désactivez la génération de l'égalité (eq=False), Python retombe sur la comparaison d'identité (is), ce qui n'est presque jamais ce que vous voulez pour des objets de données.
Ordre des valeurs par défaut hérités. Si un champ parent a une valeur par défaut et qu'un champ enfant n'en a pas, Python lève une TypeError. Planifiez soigneusement l'ordre des champs dans votre hiérarchie.
Résumé
| Concept | Ce qu'il fait |
|---|---|
@dataclass | Génère __init__, __repr__, __eq__ automatiquement |
field() | Contrôle précis des champs : valeurs par défaut, repr, compare, init |
default_factory | Fournit une valeur par défaut mutable fraîche pour chaque instance |
order=True | Ajoute <, >, <=, >= basé sur l'ordre des champs |
frozen=True | Rend les champs en lecture seule et l'instance hachable |
__post_init__ | S'exécute après __init__ pour la validation ou les champs dérivés |
fields() | Retourne les métadonnées sur chaque champ |
asdict() | Convertit l'instance en dict simple (de manière récursive) |
astuple() | Convertit l'instance en tuple simple (de manière récursive) |
Pour les sujets connexes, voir les classes et objets Python, l'héritage Python, et les classes de base abstraites Python.