34POO (2/2) : héritage, encapsulation et méthodes spéciales
Le chapitre précédent a posé les bases : classes, instances, attributs, méthodes. Ce chapitre approfondit les 3 grands piliers qui rendent la POO puissante : l’héritage (réutiliser du code), l’encapsulation (protéger les données), et les méthodes spéciales (faire dialoguer nos objets avec Python).
34.1 L’héritage
L’héritage permet de créer une nouvelle classe (fille) qui réutilise et étend une classe existante (mère). C’est le mécanisme clé de la réutilisation en POO.
Exemple introductif
Imaginons qu’après avoir créé Personnage, on veuille une classe Guerrier — un personnage spécialisé. Sans héritage, on devrait tout redupliquer. Avec héritage :
# Classe mèreclass Personnage:def__init__(self, nom, pv):self.nom = nomself.pv = pvdef attaquer(self, cible):print(f"{self.nom} attaque {cible.nom}")def__str__(self):returnf"{self.nom} ({self.pv} PV)"# Classe fille : Guerrier EST UN Personnageclass Guerrier(Personnage):def__init__(self, nom, pv, arme):super().__init__(nom, pv) # appelle le __init__ du parentself.arme = armedef charger(self, cible):print(f"{self.nom} charge {cible.nom} avec son {self.arme} !")# Utilisationaragorn = Guerrier("Aragorn", 100, "épée")legolas = Personnage("Legolas", 80)aragorn.attaquer(legolas) # hérité du parentaragorn.charger(legolas) # spécifique à Guerrierprint(aragorn) # __str__ hérité
Aragorn attaque Legolas
Aragorn charge Legolas avec son épée !
Aragorn (100 PV)
Relation conceptuelle : Guerrierest unPersonnage. On dit que Guerrierhérite de Personnage, ou qu’il en est une sous-classe.
Syntaxe
class ClasseFille(ClasseMere): ...
Dans le corps, on peut :
Ajouter de nouveaux attributs et méthodes.
Redéfinir (override) des méthodes existantes.
Appeler les méthodes du parent avec super().
34.2super() — accéder à la classe parente
super() permet d’appeler les méthodes du parent depuis la classe fille. Usage principal : dans __init__, pour initialiser d’abord ce que le parent fait, puis ajouter ses propres initialisations.
L’attribut solde devrait être modifié uniquement via deposer et retirer. Python propose des conventions pour signaler qu’un attribut est privé.
Convention : préfixe underscore _
Un attribut préfixé d’un underscore _ signale : « c’est privé, n’y touchez pas de l’extérieur ».
class CompteBancaire:def__init__(self, titulaire, solde=0):self.titulaire = titulaireself._solde = solde # convention : privédef consulter_solde(self):returnself._soldedef deposer(self, montant):self._solde += montantdef retirer(self, montant):if montant >self._solde:print("Fonds insuffisants")returnself._solde -= montantc = CompteBancaire("Alice", 100)c.deposer(50)print(c.consulter_solde())# c._solde = 999999 # Techniquement possible, mais le _ dit "ne fais pas ça"
150
⚠️ « Privé » en Python est conventionnel, pas strict
Contrairement à Java ou C++, Python ne bloque pas l’accès à _solde. Le underscore est une convention sociale : « si tu y touches, tu prends la responsabilité ».
C’est la philosophie « we’re all consenting adults » de Python — on fait confiance au développeur.
Il existe un préfixe double underscore __ qui déclenche le name mangling (le nom est transformé en coulisses) mais son usage est rare et pointu. En pratique, on s’en tient à _.
34.6 Les @property — getters et setters à la Pythonique
Comment faire pour que compte.solde soit lisible comme un attribut mais que l’écriture soit contrôlée ? C’est le rôle du décorateur @property.
class CompteBancaire:def__init__(self, titulaire, solde=0):self.titulaire = titulaireself._solde = solde@propertydef solde(self):"""Accès en lecture au solde."""returnself._solde@solde.setterdef solde(self, valeur):"""Validation à l'écriture."""if valeur <0:raiseValueError("Le solde ne peut pas être négatif")self._solde = valeurc = CompteBancaire("Alice", 100)# Lecture : comme un attribut classiqueprint(c.solde)# Écriture : appelle le setter, qui validec.solde =200print(c.solde)
100
200
c = CompteBancaire("Alice", 100)c.solde =-50# déclenche la validation
---------------------------------------------------------------------------ValueError Traceback (most recent call last)
CellIn[10], line 2 1 c = CompteBancaire("Alice", 100)
----> 2 c.solde = -50# déclenche la validationCellIn[9], line 15, in CompteBancaire.solde(self, valeur) 11 @solde.setter
12def solde(self, valeur):
13"""Validation à l'écriture.""" 14if valeur < 0:
---> 15raise ValueError("Le solde ne peut pas être négatif")
16 self._solde = valeur
ValueError: Le solde ne peut pas être négatif
Avantages :
L’utilisateur écrit c.solde = 200 — syntaxe simple.
La classe garde la main sur les règles de validation.
On peut ajouter de la logique après coup sans changer l’API.
Propriété en lecture seule
Si on ne définit pas de setter, l’attribut devient lisible mais pas modifiable directement :
class Cercle:def__init__(self, rayon):self.rayon = rayon@propertydef aire(self):"""Calculée à la demande à partir du rayon."""import mathreturn math.pi *self.rayon **2c = Cercle(5)print(f"Aire : {c.aire:.2f}")# c.aire = 99 # AttributeError : pas de setter
Aire : 78.54
34.7 Les méthodes spéciales (« dunders »)
Python possède un système unifié : toute opération de base (+, ==, len, print…) se traduit par un appel à une méthode spéciale préfixée et suffixée de doubles underscores : __add__, __eq__, __len__, __str__…
On les appelle « dunders » (pour double underscore). En les implémentant, vous rendez vos objets compatibles avec le langage.
34.8@dataclass — la classe en mode rapide (Python 3.7+)
Pour les classes qui sont surtout des conteneurs de données, Python propose le décorateur @dataclass. Il génère automatiquement__init__, __repr__, __eq__…
from dataclasses import dataclass, field@dataclassclass Utilisateur: nom: str age: int=0# avec valeur par défaut roles: list= field(default_factory=list) # liste vide par instanceu = Utilisateur("Alice", 30)u.roles.append("admin")print(u)u2 = Utilisateur("Bob")print(u2) # roles vide, pas partagé avec Alice
field(default_factory=list) résout le piège du mutable par défaut — chaque instance a sa propre liste.
Quand utiliser @dataclass ?
✅ Classes qui sont principalement des conteneurs de données (DTO, configurations, résultats…).
✅ Quand on veut gagner du temps sur le boilerplate.
❌ Classes avec beaucoup de logique métier : une classe classique est plus claire.
En pratique, @dataclass est très utilisé en data science et dans les API modernes.
🧩 Quiz 5.1 — POO avancée
Question 1
Quelle est la syntaxe correcte pour faire hériter B de A ?
class B inherits A:
class B extends A:
class B(A):
class B : A:
🔍 Réponse
c) class B(A): — la classe mère est indiquée entre parenthèses après le nom de la classe fille.
Question 2
Que fait super().__init__(nom) dans une classe fille ?
Crée un nouvel objet de la classe parente
Appelle le __init__ de la classe parente
Détruit l’objet courant
Renvoie le nom de la classe parente
🔍 Réponse
b) Appelle le __init__ de la classe parente — pour réutiliser l’initialisation du parent avant d’ajouter ses propres attributs.
Question 3
Que renvoie isinstance(rex, Animal) si rex est une instance de Chien, et Chien hérite de Mammifere qui hérite de Animal ?
True
False
Une erreur
None
🔍 Réponse
a) True — isinstance remonte toute la hiérarchie. Un Chien est aussi un Mammifere et un Animal.
Question 4
Quel est le sens de _solde en Python ?
Un mot-clé réservé
Un attribut protégé au niveau du langage (inaccessible depuis l’extérieur)
Une convention indiquant un attribut privé (accès déconseillé)
Une variable globale
🔍 Réponse
c) Une convention — le _ est un marqueur social : « c’est privé, ne touchez pas ». Python n’empêche pas techniquement l’accès.
Question 5
Que permet @property ?
Marquer un attribut comme obligatoire
Permettre d’appeler une méthode sans parenthèses, comme un attribut
Créer une méthode statique
Supprimer un attribut
🔍 Réponse
b) — une méthode décorée avec @property s’accède sans parenthèses : c.solde au lieu de c.solde(). Permet d’avoir l’apparence d’un attribut avec la logique d’une méthode.
Question 6
Quelle méthode spéciale est appelée quand on écrit print(obj) ?
__print__
__repr__
__str__
__display__
🔍 Réponse
c) __str__ — print appelle str(obj), qui appelle __str__. Si __str__ n’est pas définie, Python utilise __repr__ en secours.
Question 7
Que fournit automatiquement @dataclass ?
Un constructeur __init__
Une représentation __repr__
Une égalité __eq__
Tout ce qui précède
🔍 Réponse
d) — @dataclass génère __init__, __repr__ et __eq__ automatiquement à partir des annotations de classe. Évite beaucoup de boilerplate.
Question 8
Que s’affiche-t-il ?
class A:def salut(self):print("A")class B(A):def salut(self):super().salut()print("B")B().salut()
A
B
A puis B
Une erreur
🔍 Réponse
c) A puis B — B.salut() appelle d’abord super().salut() (qui est A.salut), puis exécute son propre code.
class A:def salut(self):print("A")class B(A):def salut(self):super().salut()print("B")B().salut()
A
B
✏️ Exercice 5.1 — Héritage Animal / Chien / Chat
Créez :
Une classe Animal avec nom, age, et une méthode se_presenter().
Une classe Chien qui hérite de Animal, ajoute race, et ajoute aboyer().
Une classe Chat qui hérite de Animal, ajoute couleur, et redéfinit se_presenter (pour mentionner la couleur).
class CompteBancaire: ...c = CompteBancaire("Alice", 100)print(c.solde)c.solde =500print(c.solde)c.solde =-50# doit lever une erreur
class CompteBancaire:def__init__(self, titulaire, solde=0):self.titulaire = titulaireself.solde = solde # passe par le setter@propertydef solde(self):returnself._solde@solde.setterdef solde(self, valeur):if valeur <0:raiseValueError("Le solde ne peut pas être négatif")self._solde = valeurc = CompteBancaire("Alice", 100)print(c.solde)c.solde =500print(c.solde)
100
500
c = CompteBancaire("Alice", 100)c.solde =-50
---------------------------------------------------------------------------ValueError Traceback (most recent call last)
CellIn[21], line 2 1 c = CompteBancaire("Alice", 100)
----> 2 c.solde = -50CellIn[20], line 13, in CompteBancaire.solde(self, valeur) 10 @solde.setter
11def solde(self, valeur):
12if valeur < 0:
---> 13raise ValueError("Le solde ne peut pas être négatif")
14 self._solde = valeur
ValueError: Le solde ne peut pas être négatif
Le setter est aussi appelé depuis __init__ quand on écrit self.solde = solde. Cela permet de valider dès la création (un CompteBancaire("Alice", -10) lèverait une erreur).