33. Les classes de données pour des données structurées simples
Dans le chapitre 30, nous avons appris à créer des classes pour définir nos propres types. Nous avons écrit des méthodes __init__ pour initialiser des instances, des méthodes __repr__ pour les afficher, et des méthodes __eq__ pour les comparer. Même si cette approche fonctionne parfaitement, elle implique d’écrire beaucoup de code répétitif, surtout lorsqu’une classe existe principalement pour stocker des données.
Les classes de données(data classes) de Python offrent une façon plus propre et plus concise de créer des classes qui servent principalement de conteneurs de données. En utilisant le décorateur @dataclass, Python génère automatiquement des méthodes courantes comme __init__, __repr__ et __eq__ à partir des attributs de classe que vous définissez. Cela réduit le code passe-partout (boilerplate) et rend vos intentions plus claires.
33.1) Ce que sont les classes de données et quand les utiliser
Une classe de données(data class) est une classe conçue principalement pour stocker des valeurs de données. Au lieu d’écrire manuellement des méthodes d’initialisation et de comparaison, vous définissez les attributs que votre classe doit avoir, et Python génère automatiquement les méthodes nécessaires.
Pourquoi les classes de données sont importantes
Considérez une classe classique pour représenter un livre :
class Book:
def __init__(self, title, author, year):
self.title = title
self.author = author
self.year = year
def __repr__(self):
return f"Book(title={self.title!r}, author={self.author!r}, year={self.year})"
def __eq__(self, other):
if not isinstance(other, Book):
return False
return (self.title == other.title and
self.author == other.author and
self.year == other.year)
book1 = Book("1984", "George Orwell", 1949)
print(book1) # Output: Book(title='1984', author='George Orwell', year=1949)
book2 = Book("1984", "George Orwell", 1949)
print(book1 == book2) # Output: TrueCela fonctionne, mais remarquez combien de code nous avons écrit juste pour stocker trois informations. Les méthodes __init__, __repr__ et __eq__ suivent des schémas prévisibles — elles ne font que gérer les attributs que nous avons définis.
Les classes de données éliminent cette répétition. Elles sont particulièrement utiles lorsque :
- Votre classe stocke principalement des données plutôt que d’implémenter un comportement complexe
- Vous avez besoin de méthodes standard comme l’initialisation, la représentation sous forme de chaîne, et la comparaison d’égalité
- Vous voulez un code plus clair et plus facile à maintenir avec moins de code passe-partout
- Vous créez des objets de configuration, des objets de transfert de données, ou des enregistrements simples
Les classes de données ne remplacent pas les classes classiques — elles les complètent. Utilisez des classes classiques lorsque vous avez besoin d’une logique d’initialisation personnalisée, de méthodes complexes, ou de hiérarchies d’héritage. Utilisez des classes de données lorsque vous avez surtout besoin d’un conteneur structuré pour des données liées.
La relation entre les classes de données et les classes classiques
Les classes de données restent des classes Python normales. Elles prennent en charge toutes les fonctionnalités que nous avons apprises dans les chapitres 30 à 32 : méthodes, propriétés, héritage et méthodes spéciales. Le décorateur @dataclass automatise simplement la création de méthodes courantes, ce qui vous évite d’écrire du code répétitif.
33.2) Créer des classes de données avec @dataclass
Pour créer une classe de données, vous importez le décorateur dataclass depuis le module dataclasses et vous l’appliquez à la définition de votre classe. À l’intérieur de la classe, vous définissez des attributs de classe avec des annotations de type qui précisent quelles données la classe doit contenir.
Syntaxe de base d’une classe de données
from dataclasses import dataclass
@dataclass
class Student:
name: str
student_id: int
gpa: float
# Créer des instances
alice = Student("Alice Johnson", 12345, 3.8)
bob = Student("Bob Smith", 12346, 3.5)
print(alice) # Output: Student(name='Alice Johnson', student_id=12345, gpa=3.8)
print(bob) # Output: Student(name='Bob Smith', student_id=12346, gpa=3.5)Décomposons ce que fait le décorateur @dataclass :
-
@dataclass: Appliquer ce décorateur fait que Python écrit automatiquement pour vous les méthodes__init__,__repr__et__eq__ -
__init__automatique : Python crée une méthode d’initialisation qui accepte ces trois paramètres dans l’ordre où ils sont définis et les assigne à des attributs d’instance -
__repr__automatique : Python crée une représentation sous forme de chaîne qui affiche le nom de la classe et les valeurs de tous les attributs -
__eq__automatique : Python crée une méthode de comparaison d’égalité qui compare tous les attributs -
Convertit les annotations de type en attributs d’instance : Dans une classe classique, écrire
name: strdans le corps de la classe crée un attribut de classe. Mais le décorateur@dataclasschange ce comportement — il utilise ces annotations de type pour définir des attributs d’instance à la place. Chaque instance obtient ses propres attributsname,student_idetgpa.
La différence clé par rapport aux classes classiques :
# Classe classique - ce sont des attributs de classe (partagés par toutes les instances)
class RegularStudent:
name: str
student_id: int
# Classe de données - ceux-ci deviennent des attributs d'instance (chaque instance a les siens)
@dataclass
class DataStudent:
name: str
student_id: intComprendre les annotations de type dans les classes de données
Dans les classes de données, les annotations de type définissent les attributs et documentent les types attendus :
from dataclasses import dataclass
@dataclass
class Product:
name: str
price: float
in_stock: bool
# Utiliser les bons types comme documenté
laptop = Product("Laptop", 999.99, True)
print(laptop) # Output: Product(name='Laptop', price=999.99, in_stock=True)
# Python n'impose pas les types - ceci s'exécute sans erreur
macbook = Product("Macbook", "expensive", True)
print(macbook) # Output: Product(name='Macbook', price='expensive', in_stock=True)
# Mais utiliser les mauvais types causera des problèmes plus tard :
discounted = laptop.price * 0.9 # Works: 899.991
discounted = macbook.price * 0.9 # TypeError: can't multiply sequence by non-int of type 'float'
tax = laptop.price + 50 # Works: 1049.99
tax = macbook.price + 50 # TypeError: can only concatenate str (not "int") to strPython ne vous empêchera pas de passer les mauvais types lors de la création d’une instance de classe de données. Les annotations de type servent principalement de documentation — elles indiquent aux autres programmeurs (et à des outils de vérification de types comme mypy) quels types vous attendez, mais Python ne les impose pas à l’exécution. C’est cohérent avec la philosophie du typage dynamique de Python.
Cependant, respecter les annotations de type rend votre code plus prévisible et plus facile à déboguer. Lorsque vous utilisez les mauvais types, des erreurs apparaîtront plus tard quand vous essaierez d’utiliser les données, ce qui rend les bugs plus difficiles à retracer. Les outils de vérification de types peuvent détecter ces incompatibilités avant que vous n’exécutiez votre code, ce qui vous aide à trouver les problèmes tôt.
Accéder aux attributs et les modifier
Les instances de classes de données fonctionnent exactement comme les instances de classes classiques. Vous accédez aux attributs et vous les modifiez en utilisant la notation par point :
from dataclasses import dataclass
@dataclass
class Employee:
name: str
position: str
salary: float
emp = Employee("Sarah Chen", "Software Engineer", 95000.0)
# Accéder aux attributs
print(emp.name) # Output: Sarah Chen
print(emp.position) # Output: Software Engineer
# Modifier les attributs
emp.salary = 100000.0
emp.position = "Senior Software Engineer"
print(emp) # Output: Employee(name='Sarah Chen', position='Senior Software Engineer', salary=100000.0)Les classes de données sont mutables(mutable) par défaut — vous pouvez changer leurs attributs après création. Cela diffère des tuples ou des tuples nommés (named tuples), qui sont immuables. Si vous avez besoin d’immuabilité, vous pouvez configurer la classe de données avec frozen=True (nous explorerons cela dans la section 33.4).
33.3) Méthodes générées : __init__, __repr__ et __eq__
Le décorateur @dataclass génère automatiquement trois méthodes essentielles. Comprendre ce que ces méthodes font vous aide à utiliser efficacement les classes de données et à savoir quand les personnaliser.
La méthode __init__ générée
La méthode __init__ initialise une nouvelle instance avec les valeurs fournies. Python la génère en fonction de l’ordre de vos définitions d’attributs :
from dataclasses import dataclass
@dataclass
class Rectangle:
width: float
height: float
# Le __init__ généré accepte width et height dans cet ordre
rect = Rectangle(10.5, 5.0)
print(rect.width) # Output: 10.5
print(rect.height) # Output: 5.0
# Vous pouvez aussi utiliser des arguments nommés
rect2 = Rectangle(height=8.0, width=12.0)
print(rect2.width) # Output: 12.0
print(rect2.height) # Output: 8.0Le __init__ généré équivaut à écrire :
def __init__(self, width: float, height: float):
self.width = width
self.height = heightCette génération automatique vous évite d’écrire du code d’initialisation répétitif, en particulier pour des classes avec beaucoup d’attributs.
La méthode __repr__ générée
La méthode __repr__ fournit une représentation sous forme de chaîne de l’instance qui affiche toutes les valeurs d’attributs. C’est précieux pour le débogage(debugging) et la journalisation(logging) :
from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float
label: str
point = Point(3.5, 7.2, "A")
print(point) # Output: Point(x=3.5, y=7.2, label='A')
print(repr(point)) # Output: Point(x=3.5, y=7.2, label='A')Le __repr__ généré suit la convention consistant à afficher le nom de la classe et tous les attributs dans un format qui pourrait servir à recréer l’objet. C’est bien plus utile que la représentation par défaut que vous obtiendriez sans __repr__ : <__main__.Point object at 0x...>.
La méthode __eq__ générée
La méthode __eq__ permet la comparaison d’égalité entre instances. Deux instances de classe de données sont considérées comme égales si tous leurs attributs correspondants sont égaux :
from dataclasses import dataclass
@dataclass
class Color:
red: int
green: int
blue: int
color1 = Color(255, 0, 0)
color2 = Color(255, 0, 0)
color3 = Color(0, 255, 0)
print(color1 == color2) # Output: True (same RGB values)
print(color1 == color3) # Output: False (different RGB values)
print(color1 is color2) # Output: False (different objects in memory)Cette comparaison d’égalité automatique est basée sur l’égalité de valeur(value equality), pas sur l’identité. Même si color1 et color2 sont des objets différents en mémoire (comme le montre is), ils sont considérés comme égaux parce que leurs attributs correspondent.
La méthode __eq__ générée compare les attributs dans l’ordre où ils sont définis :
from dataclasses import dataclass
@dataclass
class Book:
title: str
author: str
year: int
book1 = Book("1984", "George Orwell", 1949)
book2 = Book("1984", "George Orwell", 1949)
book3 = Book("Animal Farm", "George Orwell", 1945)
print(book1 == book2) # Output: True (all attributes match)
print(book1 == book3) # Output: False (title and year differ)
# La comparaison avec des objets non-Book renvoie False
print(book1 == "1984") # Output: False
print(book1 == None) # Output: FalseComparer les méthodes générées à une implémentation manuelle
Pour apprécier ce que fournissent les classes de données, comparons la version en classe de données avec une implémentation manuelle :
from dataclasses import dataclass
# Version classe de données (concise)
@dataclass
class PersonData:
first_name: str
last_name: str
age: int
# Version manuelle équivalente (verbeuse)
class PersonManual:
def __init__(self, first_name: str, last_name: str, age: int):
self.first_name = first_name
self.last_name = last_name
self.age = age
def __repr__(self):
return f"PersonManual(first_name={self.first_name!r}, last_name={self.last_name!r}, age={self.age})"
def __eq__(self, other):
if not isinstance(other, PersonManual):
return False
return (self.first_name == other.first_name and
self.last_name == other.last_name and
self.age == other.age)
# Les deux fonctionnent de manière identique
p1 = PersonData("Alice", "Johnson", 30)
p2 = PersonManual("Alice", "Johnson", 30)
print(p1) # Output: PersonData(first_name='Alice', last_name='Johnson', age=30)
print(p2) # Output: PersonManual(first_name='Alice', last_name='Johnson', age=30)La version en classe de données obtient la même fonctionnalité avec nettement moins de code. Cette réduction du code passe-partout rend votre code plus facile à lire, à maintenir et à modifier.
Ajouter des méthodes personnalisées aux classes de données
Les classes de données peuvent avoir des méthodes personnalisées comme des classes classiques. Le décorateur @dataclass ne génère que les méthodes d’initialisation, de représentation et d’égalité — vous êtes libre d’ajouter toute autre fonctionnalité :
from dataclasses import dataclass
@dataclass
class Temperature:
celsius: float
def to_fahrenheit(self):
"""Convert temperature to Fahrenheit."""
return (self.celsius * 9/5) + 32
def to_kelvin(self):
"""Convert temperature to Kelvin."""
return self.celsius + 273.15
def is_freezing(self):
"""Check if temperature is at or below freezing point."""
return self.celsius <= 0
temp = Temperature(25.0)
print(temp) # Output: Temperature(celsius=25.0)
print(f"{temp.celsius}°C = {temp.to_fahrenheit()}°F") # Output: 25.0°C = 77.0°F
print(f"Kelvin: {temp.to_kelvin()}") # Output: Kelvin: 298.15
print(f"Freezing: {temp.is_freezing()}") # Output: Freezing: False
cold_temp = Temperature(-5.0)
print(f"Freezing: {cold_temp.is_freezing()}") # Output: Freezing: TrueLes classes de données gèrent les parties répétitives (initialisation, représentation et comparaison) tout en vous permettant d’ajouter des méthodes personnalisées pour vos besoins spécifiques, comme montré avec les méthodes de conversion de température ci-dessus.
33.4) Valeurs par défaut et options des champs
Les classes de données prennent en charge des valeurs par défaut pour les attributs, ce qui vous permet de créer des instances sans spécifier chaque paramètre. Vous pouvez aussi utiliser la fonction field() pour configurer des comportements avancés comme exclure des attributs des comparaisons ou contrôler la façon dont ils apparaissent dans la représentation sous forme de chaîne.
Fournir des valeurs par défaut
Vous pouvez attribuer des valeurs par défaut aux attributs directement dans la définition de la classe. Les attributs avec des valeurs par défaut doivent venir après les attributs sans valeurs par défaut :
from dataclasses import dataclass
@dataclass
class User:
username: str
email: str
is_active: bool = True # Valeur par défaut
role: str = "user" # Valeur par défaut
# Créer des instances avec et sans valeurs par défaut
user1 = User("alice", "alice@example.com")
print(user1) # Output: User(username='alice', email='alice@example.com', is_active=True, role='user')
user2 = User("bob", "bob@example.com", False, "admin")
print(user2) # Output: User(username='bob', email='bob@example.com', is_active=False, role='admin')
# Utiliser des arguments nommés pour remplacer des valeurs par défaut spécifiques
user3 = User("charlie", "charlie@example.com", role="moderator")
print(user3) # Output: User(username='charlie', email='charlie@example.com', is_active=True, role='moderator')La règle d’ordre (les attributs sans valeurs par défaut avant les attributs avec valeurs par défaut) évite toute ambiguïté dans la méthode __init__ générée. C’est la même exigence que pour les paramètres de fonction avec des valeurs par défaut, que nous avons apprise au chapitre 20.
Valeurs par défaut mutables et pourquoi elles ne sont pas autorisées
Les classes de données vous protègent d’une erreur fréquente avec les valeurs par défaut mutables. Si vous essayez d’utiliser directement un objet mutable comme une liste ou un dictionnaire comme valeur par défaut, vous obtiendrez une erreur :
from dataclasses import dataclass
# Ceci lèvera une erreur
@dataclass
class ShoppingCart:
customer: str
items: list = [] # ValueError: mutable default <class 'list'> for field items is not allowed: use default_factoryCette erreur empêche le même problème que nous avons vu avec les arguments par défaut des fonctions au chapitre 20, où toutes les instances partageraient le même objet mutable.
Utiliser field() avec default_factory pour des valeurs par défaut mutables
La solution consiste à utiliser la fonction field() avec default_factory, qui crée une nouvelle valeur par défaut pour chaque instance :
from dataclasses import dataclass, field
@dataclass
class ShoppingCart:
customer: str
items: list = field(default_factory=list) # Correct : nouvelle liste par instance
# Maintenant chaque instance obtient sa propre liste
cart1 = ShoppingCart("Alice")
cart1.items.append("Book")
print(cart1.items) # Output: ['Book']
cart2 = ShoppingCart("Bob")
print(cart2.items) # Output: [] - Bob has an empty list
cart2.items.append("Laptop")
print(cart1.items) # Output: ['Book'] - Alice's cart unchanged
print(cart2.items) # Output: ['Laptop'] - Bob's cart independentLe paramètre default_factory prend une fonction (comme list, dict ou set) qui sera appelée pour créer une nouvelle valeur par défaut chaque fois que vous créez une instance sans fournir cet attribut. Par exemple, default_factory=list signifie que Python appellera list() pour créer une nouvelle liste vide pour chaque instance.
Exclure des champs de la comparaison
Parfois, vous voulez que certains attributs soient exclus des comparaisons d’égalité. Utilisez field(compare=False) pour cela :
from dataclasses import dataclass, field
from datetime import datetime
@dataclass
class LogEntry:
message: str
level: str
timestamp: datetime = field(compare=False) # Ne pas comparer les horodatages
# Créer deux entrées de journal avec le même message mais des heures différentes
entry1 = LogEntry("User logged in", "INFO", datetime(2024, 1, 15, 10, 30))
entry2 = LogEntry("User logged in", "INFO", datetime(2024, 1, 15, 10, 35))
# Elles sont égales parce que timestamp est exclu de la comparaison
print(entry1 == entry2) # Output: True
# Mais elles ont des horodatages différents
print(entry1.timestamp) # Output: 2024-01-15 10:30:00
print(entry2.timestamp) # Output: 2024-01-15 10:35:00C’est utile lorsque vous avez des champs de métadonnées (comme des horodatages, des IDs, ou des compteurs internes) qui ne devraient pas influencer le fait que deux instances soient considérées égales.
Exclure des champs de la représentation
Vous pouvez aussi exclure des champs de la représentation sous forme de chaîne en utilisant field(repr=False) :
from dataclasses import dataclass, field
@dataclass
class Account:
username: str
email: str
password: str = field(repr=False) # Ne pas afficher le mot de passe dans repr
account = Account("alice", "alice@example.com", "secret123")
print(account) # Output: Account(username='alice', email='alice@example.com')
# Le mot de passe n’est pas affiché, mais il est toujours stocké
print(account.password) # Output: secret123C’est particulièrement utile pour des données sensibles comme des mots de passe, des clés API, ou de grosses structures de données qui encombreraient la représentation.
Rendre les classes de données immuables avec frozen=True
Par défaut, les instances de classes de données sont mutables — vous pouvez changer leurs attributs après création. Si vous voulez des instances immuables (comme les tuples), utilisez frozen=True :
from dataclasses import dataclass
@dataclass(frozen=True)
class Point:
x: float
y: float
point = Point(3.0, 4.0)
print(point) # Output: Point(x=3.0, y=4.0)
# Tenter de modifier déclenche une erreur
try:
point.x = 5.0
except AttributeError as e:
print(f"Error: {e}") # Output: Error: cannot assign to field 'x'Les classes de données gelées(frozen) sont utiles lorsque vous voulez garantir l’intégrité des données ou utiliser des instances comme clés de dictionnaire (puisque les clés de dictionnaire doivent être immuables). Lorsqu’une classe de données est gelée, Python génère aussi une méthode __hash__, rendant les instances hachables(hashable) :
from dataclasses import dataclass
@dataclass(frozen=True)
class Coordinate:
latitude: float
longitude: float
# Les instances gelées peuvent être des clés de dictionnaire
locations = {
Coordinate(40.7128, -74.0060): "New York",
Coordinate(51.5074, -0.1278): "London",
Coordinate(35.6762, 139.6503): "Tokyo"
}
nyc = Coordinate(40.7128, -74.0060)
print(locations[nyc]) # Output: New York33.5) Initialisation personnalisée avec __post_init__
Parfois, vous devez effectuer une configuration supplémentaire après l’exécution de la méthode __init__ générée. La méthode __post_init__ est appelée automatiquement après l’initialisation, ce qui vous permet de valider des données, de calculer des attributs dérivés, ou d’effectuer d’autres tâches de configuration.
Utilisation de base de __post_init__
La méthode __post_init__ est appelée après que tous les attributs ont été définis par le __init__ généré :
from dataclasses import dataclass
@dataclass
class Rectangle:
width: float
height: float
area: float = 0.0 # Sera calculée dans __post_init__
def __post_init__(self):
"""Calculate area after initialization."""
self.area = self.width * self.height
rect = Rectangle(5.0, 3.0)
print(rect) # Output: Rectangle(width=5.0, height=3.0, area=15.0)
print(f"Area: {rect.area}") # Output: Area: 15.0La méthode __post_init__ a accès à tous les attributs d’instance qui ont été définis pendant l’initialisation. C’est utile pour calculer des valeurs dérivées qui dépendent de plusieurs attributs.
Valider des données dans post_init
Un usage courant de __post_init__ consiste à valider que les données fournies respectent certaines exigences :
from dataclasses import dataclass
@dataclass
class BankAccount:
account_number: str
balance: float
def __post_init__(self):
"""Validate account data."""
if self.balance < 0:
raise ValueError("Balance cannot be negative")
# Output: Valid account
account1 = BankAccount("ACC001", 1000.0)
print(account1) # Output: BankAccount(account_number='ACC001', balance=1000.0)
# Output: Invalid account - negative balance
try:
account2 = BankAccount("ACC002", -500.0)
except ValueError as e:
print(f"Error: {e}") # Output: Error: Balance cannot be negativeCette validation garantit que les instances sont toujours dans un état valide. Si les données ne respectent pas les exigences, l’instance n’est jamais créée, ce qui empêche l’existence d’objets invalides dans votre programme.
Utiliser post_init avec field(init=False)
Parfois, vous voulez un attribut calculé dans __post_init__ mais qui ne devrait pas être un paramètre dans __init__. Utilisez field(init=False) pour cela :
from dataclasses import dataclass, field
import math
@dataclass
class Circle:
radius: float
area: float = field(init=False) # N'est pas un paramètre de __init__
circumference: float = field(init=False)
def __post_init__(self):
"""Compute area and circumference from radius."""
self.area = math.pi * self.radius ** 2
self.circumference = 2 * math.pi * self.radius
# Seul radius est requis lors de l'initialisation
circle = Circle(5.0)
print(circle) # Output: Circle(radius=5.0, area=78.53981633974483, circumference=31.41592653589793)
print(f"Area: {circle.area:.2f}") # Output: Area: 78.54
print(f"Circumference: {circle.circumference:.2f}") # Output: Circumference: 31.42Ce schéma est utile lorsque vous avez des attributs qui sont toujours calculés à partir d’autres attributs et qui ne devraient jamais être définis directement lors de l’initialisation.
Les classes de données représentent une fonctionnalité moderne de Python qui réduit le code passe-partout tout en conservant toute la puissance des classes. Elles sont particulièrement précieuses pour créer un code propre et lisible lorsque vous travaillez avec des données structurées. Au fur et à mesure que vous continuez à apprendre Python, vous constaterez que les classes de données deviennent un choix naturel pour de nombreuses tâches de programmation centrées sur les données, en complément des classes classiques que vous avez apprises dans les chapitres 30 à 32.