Bases

Classes et POO en Python : __init__, self, méthodes, héritage et dataclass

Mis à jour: · Skillaria.Top

La programmation orientée objet en Python repose sur les classes : une classe décrit les champs et les méthodes, les objets (instances) se créent en appelant la classe, __init__ remplit les champs, et self désigne l'objet courant. L'héritage permet de reprendre le comportement du parent et de redéfinir certaines méthodes, et le décorateur @dataclass génère __init__, __repr__ et __eq__ pour les classes qui ne font que stocker des données.

Une classe en Python est un plan : elle décrit les données que stocke un objet et les opérations possibles sur elles. L'objet (l'instance) est un exemplaire concret construit à partir de ce plan. La programmation orientée objet en Python repose sur trois notions : les classes avec leurs méthodes, l'héritage, et les méthodes spéciales comme __init__ et __repr__. Voyons tout cela pas à pas, sur un même exemple : le suivi des dépenses.

À quoi servent les classes, si l'on a les dictionnaires

On peut stocker des données dans un dictionnaire : {"titre": "Café", "montant": 250}. Le problème, c'est que le dictionnaire ne connaît pas les clés correctes. Une faute de frappe dans depense.get("montnat", 0) ne provoque aucune erreur : la valeur 0 est renvoyée, et le rapport commence silencieusement à donner des résultats faux.

Les champs d'un objet de classe sont déclarés à l'avance. Accéder à un attribut inexistant provoque immédiatement AttributeError, et Python moderne suggère même le bon nom. De plus, la classe rassemble les données et les actions qui les concernent : une dépense sait calculer son montant TTC, et le rapport sait calculer son total.

Créer une classe en Python

La classe se déclare avec le mot-clé class, et son nom suit la convention PEP 8 en CamelCase. Exemple minimal :

class Depense:
    def __init__(self, titre, montant):
        self.titre = titre
        self.montant = montant

cafe = Depense("Café", 250)
taxi = Depense("Taxi", 300)
print(cafe.titre, cafe.montant)   # Café 250

L'appel Depense("Café", 250) crée une nouvelle instance. cafe et taxi sont deux objets distincts du même type, chacun avec ses propres valeurs de champs. Pour vérifier l'appartenance, on utilise isinstance(cafe, Depense), qui renvoie True.

Constructeur __init__ : initialiser la classe

__init__ est souvent appelé constructeur, mais il s'agit plutôt d'un initialiseur. L'objet lui-même est créé par la méthode __new__ ; __init__ est appelé juste après et range les arguments reçus dans les champs. Il ne doit rien renvoyer (plus précisément, il doit renvoyer None).

Dans __init__, il est pratique de vérifier les données d'entrée pour qu'aucun objet incorrect n'existe dans le programme :

class Depense:
    def __init__(self, titre, montant):
        if montant < 0:
            raise ValueError("Le montant ne peut pas être négatif")
        self.titre = titre
        self.montant = montant

Ce qu'est self en Python

self désigne l'objet avec lequel la méthode travaille à cet instant. Lorsque vous écrivez cafe.avec_taxe(), Python appelle en réalité Depense.avec_taxe(cafe) et transmet l'objet en premier argument. C'est pourquoi self figure en tête de la déclaration de la méthode, et qu'on ne l'écrit pas lors de l'appel.

self n'est pas un mot-clé mais une convention : techniquement, le paramètre peut porter n'importe quel nom, mais personne ne le fait. Erreur fréquente chez les débutants : oublier self dans la déclaration d'une méthode. L'appel provoque alors TypeError: Depense.avec_taxe() takes 0 positional arguments but 1 was given.

Méthodes de classe et méthodes spéciales

Une méthode est une fonction déclarée dans la classe. Elle accède aux champs de l'objet par self :

class Depense:
    def __init__(self, titre, montant):
        self.titre = titre
        self.montant = montant

    def avec_taxe(self, taux=0.2):
        return self.montant * (1 + taux)

    def __repr__(self):
        return f"Depense({self.titre!r}, {self.montant})"

cafe = Depense("Café", 250)
print(cafe.avec_taxe())   # 300.0
print(cafe)               # Depense('Café', 250)

Sans __repr__, l'affichage ressemblerait à <__main__.Depense object at 0x7f...>, ce qui ne sert à rien pour le débogage. Les méthodes encadrées de deux soulignés sont dites spéciales (dunder) : Python les appelle lui-même. __str__ définit l'affichage « pour l'utilisateur », __eq__ la comparaison avec ==, __len__ le résultat de len(obj).

@classmethod et @staticmethod

Une méthode ordinaire travaille avec une instance. Une méthode décorée par @classmethod reçoit la classe elle-même (cls) comme premier argument ; on s'en sert souvent comme constructeur alternatif. Une méthode statique @staticmethod ne reçoit ni l'objet ni la classe : c'est une simple fonction qui, logiquement, appartient à la classe.

class Depense:
    def __init__(self, titre, montant):
        self.titre = titre
        self.montant = montant

    @classmethod
    def depuis_csv(cls, ligne):
        titre, montant = ligne.split(";")
        return cls(titre, float(montant))

    @staticmethod
    def montant_valide(valeur):
        return valeur >= 0

d = Depense.depuis_csv("Déjeuner;520")
print(d.montant)                       # 520.0
print(Depense.montant_valide(-1))      # False

Destructeur de classe : __del__

Python possède la méthode __del__, appelée avant la destruction de l'objet par le ramasse-miettes. Il ne faut pas compter dessus pour libérer des ressources : le moment de l'appel n'est pas garanti. Pour les fichiers et les connexions, utilisez un gestionnaire de contexte with ou une méthode explicite close().

Attributs de classe et attributs d'instance

Les champs définis avec self.nom = ... appartiennent à un objet précis. Les variables déclarées directement dans le corps de la classe sont des attributs de classe : ils sont communs à toutes les instances. C'est là qu'on range en général les constantes de classe.

class Depense:
    DEVISE = "EUR"            # attribut de classe, constante

    def __init__(self, titre, montant):
        self.titre = titre    # attributs d'instance
        self.montant = montant

a = Depense("Café", 250)
b = Depense("Thé", 150)
print(a.DEVISE, Depense.DEVISE)   # EUR EUR

Piège dangereux : un attribut de classe modifiable. Si l'on déclare lignes = [] dans le corps de la classe puis qu'on ajoute des éléments via self.lignes.append(...), la liste est partagée par tous les objets. Les champs modifiables se créent toujours dans __init__.

Pour voir les champs d'un objet, utilisez vars(obj) ou obj.__dict__ ; pour lire un attribut dont le nom est une chaîne, utilisez getattr(obj, "montant").

Propriétés de classe : @property

Une propriété est une méthode qui se lit de l'extérieur comme un champ ordinaire, sans parenthèses. C'est pratique pour les valeurs calculées : le total d'un rapport n'a pas besoin d'être stocké séparément, donc on ne risque pas d'oublier de le mettre à jour.

class Rapport:
    def __init__(self):
        self.lignes = []

    def ajouter(self, depense):
        self.lignes.append(depense)

    @property
    def total(self):
        return sum(d.montant for d in self.lignes)

r = Rapport()
r.ajouter(Depense("Café", 250))
r.ajouter(Depense("Taxi", 300))
print(r.total)   # 550

Pour contrôler l'écriture, on ajoute un setter avec @total.setter. Sans setter, l'affectation r.total = 0 provoque AttributeError.

Quelques mots sur l'encapsulation. Il n'existe pas de champs réellement privés en Python. Un seul souligné (_cache) est une convention signifiant « ne pas toucher de l'extérieur ». Deux soulignés (__secret) déclenchent le masquage de nom (name mangling), pour qu'un héritier ne remplace pas ce champ par accident.

Héritage de classes en Python

L'héritage permet de créer une classe fille à partir d'une classe parente : la fille reçoit tous les champs et méthodes, et redéfinit ceux qui doivent changer. La classe parente est indiquée entre parenthèses.

class RapportMensuel(Rapport):
    def __init__(self, mois):
        super().__init__()        # appeler __init__ du parent
        self.mois = mois

    def ajouter(self, depense):   # redéfinition de la méthode
        print(f"[{self.mois}] + {depense}")
        super().ajouter(depense)  # conserver le comportement du parent

m = RapportMensuel("octobre")
m.ajouter(Depense("Café", 250))   # [octobre] + Depense('Café', 250)
print(m.total)                    # 250 — la propriété est héritée

super() renvoie un intermédiaire qui permet d'appeler les méthodes du parent. Oublier super().__init__() dans le __init__ de la fille est une erreur classique : le champ lignes n'est pas créé, et le premier appel à ajouter échoue avec AttributeError.

On vérifie une parenté avec issubclass(RapportMensuel, Rapport). Toutes les classes en Python 3 héritent implicitement de object, d'où viennent les méthodes de base __repr__, __eq__ et les autres. Python accepte aussi l'héritage multiple (class C(A, B)) ; l'ordre de recherche des méthodes est donné par C.__mro__.

Classe abstraite en Python

Une classe abstraite définit une interface : les méthodes que les sous-classes doivent obligatoirement implémenter. On ne peut pas créer d'instance de cette classe. Pour cela, on utilise le module abc :

from abc import ABC, abstractmethod

class Exportateur(ABC):
    @abstractmethod
    def exporter(self, rapport): ...

class ExportateurCsv(Exportateur):
    def exporter(self, rapport):
        return "\n".join(f"{d.titre};{d.montant}" for d in rapport.lignes)

Exportateur()   # TypeError: Can't instantiate abstract class Exportateur ...

Une sous-classe qui n'implémente pas toutes les méthodes abstraites reste abstraite, et la création d'un objet provoque la même erreur.

La composition plutôt que les hiérarchies profondes

Après avoir découvert l'héritage, on a tendance à construire de longues chaînes : dépense de base, dépense monétaire, dépense en roubles, et ainsi de suite sur cinq niveaux. En Python, cela se fait rarement. L'héritage décrit une relation « est un », la composition une relation « contient » : un rapport n'est pas une liste de dépenses, il la contient dans son champ lignes.

Règle pratique : si vous avez seulement besoin d'utiliser une capacité d'un autre objet, placez-le dans un champ au lieu d'hériter. Pour les données, une dataclass suffit souvent, et la logique peut rester dans des fonctions ordinaires.

Dataclasses en Python : une classe sans code répétitif

Pour les classes qui stockent surtout des données, écrire à la main __init__, __repr__ et __eq__ est fastidieux. Le décorateur @dataclass du module dataclasses les génère à partir des annotations des champs :

from dataclasses import dataclass, field

@dataclass
class Depense:
    titre: str
    montant: float
    etiquettes: list[str] = field(default_factory=list)

    def __post_init__(self):
        if self.montant < 0:
            raise ValueError("Le montant ne peut pas être négatif")

d = Depense("Café", 250)
print(d)                             # Depense(titre='Café', montant=250, etiquettes=[])
print(d == Depense("Café", 250))     # True

La vérification des valeurs, qu'on écrivait auparavant dans __init__, passe dans __post_init__, appelé juste après l'initialiseur généré. Les valeurs par défaut modifiables se définissent uniquement avec field(default_factory=list) : l'écriture etiquettes: list = [] est refusée par dataclass, qui lève ValueError.

frozen et slots

Le paramètre frozen=True rend l'objet non modifiable, et slots=True (depuis Python 3.10) crée __slots__ et économise la mémoire. Tenter de modifier un champ d'un objet figé provoque FrozenInstanceError. Pour obtenir un objet avec une nouvelle valeur, on crée une copie :

from dataclasses import dataclass, replace

@dataclass(frozen=True, slots=True)
class Depense:
    titre: str
    montant: float

d = Depense("Café", 250)
# d.montant = 300   -> dataclasses.FrozenInstanceError
d2 = replace(d, montant=300)
print(d, d2)   # Depense(titre='Café', montant=250) Depense(titre='Café', montant=300)

Depuis Python 3.13, la fonction universelle copy.replace() fait la même chose. Les objets dataclass figés peuvent être placés dans des ensembles et utilisés comme clés de dictionnaire, puisqu'ils disposent de __hash__.

Nouveautés pour les classes : Self, @override, __slots__

Pour typer les classes, deux outils sont particulièrement utiles. typing.Self (Python 3.11, PEP 673) signifie « renvoie un objet de la même classe » — cela fonctionne aussi dans les méthodes héritées. Le décorateur @override (Python 3.12, PEP 698) marque une méthode qui doit redéfinir une méthode du parent : si vous vous trompez de nom, mypy ou pyright signalent qu'il n'existe aucune méthode de base de ce nom.

from typing import Self, override

class Rapport:
    def __init__(self):
        self.lignes = []

    def ajouter(self, d) -> Self:
        self.lignes.append(d)
        return self              # permet l'enchaînement d'appels

class RapportMensuel(Rapport):
    @override
    def ajouter(self, d) -> Self:
        return super().ajouter(d)

Dans Python 3.15, une limitation de niche a été levée : les sous-classes de tuple peuvent avoir un __slots__ non vide, et __dict__ ainsi que __weakref__ peuvent être déclarés dans le __slots__ de n'importe quelle classe. En 3.14, une telle déclaration dans un héritier de tuple provoquait TypeError: nonempty __slots__ not supported for subtype of 'tuple'.

Erreurs fréquentes avec les classes

ErreurSymptômeSolution
self oublié dans une méthodeTypeError: ... takes 0 positional arguments but 1 was givenLe premier paramètre de la méthode est toujours self
Champ sans self. dans __init__La valeur disparaît après __init__Écrire self.montant = montant
Liste en attribut de classeLes données « circulent » entre les objetsCréer les listes dans __init__ ou avec default_factory
super().__init__() absentAttributeError sur les champs du parentAppeler l'initialiseur du parent
Propriété appelée avec des parenthèsesTypeError: 'int' object is not callableLire la propriété sans parenthèses : r.total

En bref

La POO en Python commence simplement : une classe décrit les champs et les méthodes, __init__ remplit les champs, et self désigne l'objet courant. L'héritage et super() permettent d'étendre le comportement, les classes abstraites définissent une interface, et @property sert aux champs calculés. Pour les classes de données, prenez @dataclass, et préférez la composition aux hiérarchies profondes. Ces notions sont mises en pratique dans le cours sur Python, à travers le suivi des dépenses.

Questions fréquentes

Quelle est la différence entre une classe et un objet en Python ?

La classe est la description d'un type : quels champs et quelles méthodes il possède. L'objet, ou instance, est créé en appelant la classe, par exemple Depense("Café", 250), et contient des valeurs concrètes.

Faut-il obligatoirement écrire __init__ dans une classe ?

Non. Si __init__ n'est pas défini, celui hérité de object est utilisé, et il n'accepte aucun argument. Dans une dataclass, c'est le décorateur qui le génère.

Quelle est la différence entre __str__ et __repr__ ?

__repr__ est une représentation sans ambiguïté destinée au développeur et au débogueur, tandis que __str__ fournit un affichage lisible pour l'utilisateur dans print et les f-strings. Sans __str__, Python utilise __repr__.

Quand choisir une dataclass plutôt qu'une classe ordinaire ?

La dataclass convient lorsque la classe stocke surtout des données et qu'il faut un __init__, un __repr__ et une comparaison prêts à l'emploi. Une classe ordinaire est préférable lorsque l'initialisation est complexe ou que le comportement compte plus que les données.

Y a-t-il des champs privés en Python ?

Il n'existe pas de confidentialité stricte. Un seul souligné au début du nom est une convention « usage interne », tandis que deux soulignés déclenchent le masquage de nom, qui protège le champ d'un remplacement accidentel dans une sous-classe.

Apprenez ce sujet en vidéo

Ce sujet est traité en détail avec de la pratique dans le cours « Python 3.15: от новичка до профи ».

Voir le cours