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

URL: https://skillaria.top/fr/guides/python/classes-poo-python

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

| Erreur | Symptôme | Solution |
| --- | --- | --- |

| `self` oublié dans une méthode | `TypeError: ... takes 0 positional arguments but 1 was given` | Le 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 classe | Les données « circulent » entre les objets | Créer les listes dans `__init__` ou avec `default_factory` |

| `super().__init__()` absent | `AttributeError` sur les champs du parent | Appeler l'initialiseur du parent |

| Propriété appelée avec des parenthèses | `TypeError: 'int' object is not callable` | Lire 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.

## FAQ

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


---
Source: Skillaria.Top (https://skillaria.top)
