Python & AI Tutorials Logo
Programmation Python

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 :

python
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: True

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

Classe classique

init manuel

repr manuel

eq manuel

Méthodes personnalisées

Classe de données

Décorateur @dataclass

init auto-généré

repr auto-généré

eq auto-généré

Méthodes personnalisées

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

python
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 :

  1. @dataclass : Appliquer ce décorateur fait que Python écrit automatiquement pour vous les méthodes __init__, __repr__ et __eq__

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

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

  4. __eq__ automatique : Python crée une méthode de comparaison d’égalité qui compare tous les attributs

  5. Convertit les annotations de type en attributs d’instance : Dans une classe classique, écrire name: str dans le corps de la classe crée un attribut de classe. Mais le décorateur @dataclass change ce comportement — il utilise ces annotations de type pour définir des attributs d’instance à la place. Chaque instance obtient ses propres attributs name, student_id et gpa.

La différence clé par rapport aux classes classiques :

python
# 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: int

Comprendre 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 :

python
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 str

Python 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 :

python
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 :

python
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.0

Le __init__ généré équivaut à écrire :

python
def __init__(self, width: float, height: float):
    self.width = width
    self.height = height

Cette 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) :

python
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 :

python
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 :

python
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: False

Comparer 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 :

python
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é :

python
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: True

Les 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 :

python
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 :

python
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_factory

Cette 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 :

python
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 independent

Le 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 :

python
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:00

C’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) :

python
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: secret123

C’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 :

python
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) :

python
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 York

33.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é :

python
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.0

La 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 :

python
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 negative

Cette 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 :

python
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.42

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


© 2025. Primesoft Co., Ltd.
support@primesoft.ai