Héritage et polymorphisme#
Héritage simple#
L”héritage est le mécanisme par lequel une classe (appelée classe fille, sous-classe ou classe enfant) acquiert automatiquement les attributs et méthodes d’une autre classe (appelée classe parente, superclasse ou classe de base). C’est l’un des outils fondamentaux pour éviter la répétition de code et modéliser des relations « est-un » entre concepts.
En Python, la syntaxe est concise : class Fille(Parente):. Si aucune classe parente n’est spécifiée, Python considère implicitement que la classe hérite de object — la racine de toute la hiérarchie de types en Python 3.
Définition 12 (Héritage)
L”héritage est une relation entre deux classes où la classe fille hérite de tous les attributs et méthodes de la classe parente. La classe fille peut redéfinir (override) des méthodes héritées pour modifier leur comportement, et ajouter de nouvelles méthodes et attributs spécifiques à elle. La relation d’héritage modélise la relation sémantique « est-un » : un Chien est un Animal.
Voici un exemple concret avec une hiérarchie de formes géométriques :
import math
class Forme:
"""Classe de base pour toutes les formes géométriques."""
def __init__(self, couleur: str = "blanc") -> None:
self.couleur = couleur
def aire(self) -> float:
raise NotImplementedError("La sous-classe doit implémenter aire()")
def perimetre(self) -> float:
raise NotImplementedError("La sous-classe doit implémenter perimetre()")
def description(self) -> str:
return (f"{type(self).__name__} de couleur {self.couleur} : "
f"aire={self.aire():.2f}, périmètre={self.perimetre():.2f}")
def __repr__(self) -> str:
return f"{type(self).__name__}(couleur={self.couleur!r})"
class Cercle(Forme):
"""Cercle défini par son rayon."""
def __init__(self, rayon: float, couleur: str = "blanc") -> None:
super().__init__(couleur) # Appel au constructeur de Forme
self.rayon = rayon
def aire(self) -> float:
return math.pi * self.rayon ** 2
def perimetre(self) -> float:
return 2 * math.pi * self.rayon
def __repr__(self) -> str:
return f"Cercle(rayon={self.rayon!r}, couleur={self.couleur!r})"
class Rectangle(Forme):
"""Rectangle défini par sa largeur et sa hauteur."""
def __init__(self, largeur: float, hauteur: float,
couleur: str = "blanc") -> None:
super().__init__(couleur)
self.largeur = largeur
self.hauteur = hauteur
def aire(self) -> float:
return self.largeur * self.hauteur
def perimetre(self) -> float:
return 2 * (self.largeur + self.hauteur)
def __repr__(self) -> str:
return (f"Rectangle(largeur={self.largeur!r}, "
f"hauteur={self.hauteur!r}, couleur={self.couleur!r})")
class Carre(Rectangle):
"""Carré : cas particulier de Rectangle."""
def __init__(self, cote: float, couleur: str = "blanc") -> None:
super().__init__(cote, cote, couleur)
def __repr__(self) -> str:
return f"Carre(cote={self.largeur!r}, couleur={self.couleur!r})"
# Utilisation
formes = [
Cercle(5.0, "rouge"),
Rectangle(4.0, 6.0, "bleu"),
Carre(3.0, "vert"),
]
for f in formes:
print(f.description())
Cercle de couleur rouge : aire=78.54, périmètre=31.42
Rectangle de couleur bleu : aire=24.00, périmètre=20.00
Carre de couleur vert : aire=9.00, périmètre=12.00
La fonction super() mérite une attention particulière. Elle retourne un objet proxy qui délègue les appels de méthode à la classe parente dans le MRO (voir section suivante). Son usage est préférable à appeler directement Forme.__init__(self, ...), car il fonctionne correctement en cas d’héritage multiple.
Redéfinition de méthodes#
Lorsqu’une sous-classe redéfinit une méthode, la nouvelle méthode remplace la méthode parente pour les instances de la sous-classe. On peut toujours accéder à la méthode parente via super().
class Triangle(Forme):
"""Triangle défini par ses trois côtés."""
def __init__(self, a: float, b: float, c: float,
couleur: str = "blanc") -> None:
if not self._est_valide(a, b, c):
raise ValueError(f"Les côtés {a}, {b}, {c} ne forment pas un triangle")
super().__init__(couleur)
self.a, self.b, self.c = a, b, c
@staticmethod
def _est_valide(a: float, b: float, c: float) -> bool:
return a + b > c and b + c > a and a + c > b
def perimetre(self) -> float:
return self.a + self.b + self.c
def aire(self) -> float:
# Formule de Héron
s = self.perimetre() / 2
return math.sqrt(s * (s - self.a) * (s - self.b) * (s - self.c))
def description(self) -> str:
# Appel à la méthode parente + information supplémentaire
base = super().description()
return f"{base} [côtés: {self.a}, {self.b}, {self.c}]"
t = Triangle(3.0, 4.0, 5.0, "jaune")
print(t.description())
print(f"Est un triangle rectangle ? {abs(t.aire() - 6.0) < 1e-9}")
Triangle de couleur jaune : aire=6.00, périmètre=12.00 [côtés: 3.0, 4.0, 5.0]
Est un triangle rectangle ? True
Héritage multiple#
Python, contrairement à Java, supporte l”héritage multiple : une classe peut hériter de plusieurs classes parentes simultanément. C’est une fonctionnalité puissante mais qui soulève une question fondamentale : en cas de conflit de méthodes, laquelle utiliser ?
Définition 13 (Héritage multiple)
L”héritage multiple permet à une classe d’hériter de plusieurs classes parentes : class C(A, B):. Python résout les conflits de méthodes grâce à l’algorithme C3 de linéarisation, qui produit un ordre de résolution de méthodes (Method Resolution Order, MRO) déterministe et cohérent.
class Volant:
def deplacer(self) -> str:
return "Je vole"
def capacite(self) -> str:
return "Je peux voler"
class Nageant:
def deplacer(self) -> str:
return "Je nage"
def capacite(self) -> str:
return "Je peux nager"
class Canard(Volant, Nageant):
"""Le canard peut voler ET nager."""
pass
class SuperCanard(Volant, Nageant):
def deplacer(self) -> str:
# Combiner les capacités en appelant super() avec le MRO
return f"{super().deplacer()} et je nage aussi"
donald = Canard()
print(f"Canard.deplacer() = {donald.deplacer()}")
# Volant est en premier dans la liste des parents → sa méthode gagne
print(f"MRO de Canard : {[c.__name__ for c in Canard.__mro__]}")
Canard.deplacer() = Je vole
MRO de Canard : ['Canard', 'Volant', 'Nageant', 'object']
L’algorithme C3 et le MRO#
L”algorithme C3 calcule un ordre linéaire des classes qui respecte deux invariants :
Une classe apparaît avant ses parentes dans le MRO.
L’ordre relatif des parentes déclaré par le programmeur est préservé.
class A:
def methode(self) -> str:
return "A"
class B(A):
def methode(self) -> str:
return f"B → {super().methode()}"
class C(A):
def methode(self) -> str:
return f"C → {super().methode()}"
class D(B, C):
def methode(self) -> str:
return f"D → {super().methode()}"
d = D()
print(f"Résultat : {d.methode()}")
print(f"MRO de D : {[cls.__name__ for cls in D.__mro__]}")
# MRO : D → B → C → A → object
# Chaque super() suit le MRO, pas seulement la classe parente directe
Résultat : D → B → C → A
MRO de D : ['D', 'B', 'C', 'A', 'object']
Remarque 17
La clé pour comprendre super() avec l’héritage multiple est que super() ne signifie pas « la classe parente directe » mais « la prochaine classe dans le MRO de l’instance courante ». Ainsi, quand B.methode appelle super().methode(), et que l’instance est de type D (dont le MRO est D→B→C→A→object), super() depuis B fait référence à C, pas à A. C’est pourquoi le résultat est D → B → C → A et non D → B → A.
Le patron mixin#
L’héritage multiple est particulièrement utile avec les mixins : des classes légères qui apportent une fonctionnalité spécifique sans être destinées à être instanciées seules.
class ReprMixin:
"""Mixin qui fournit un __repr__ automatique."""
def __repr__(self) -> str:
attrs = ", ".join(
f"{k}={v!r}"
for k, v in vars(self).items()
if not k.startswith('_')
)
return f"{type(self).__name__}({attrs})"
class JsonMixin:
"""Mixin qui permet la sérialisation JSON basique."""
def to_dict(self) -> dict:
return {k: v for k, v in vars(self).items() if not k.startswith('_')}
class Produit(ReprMixin, JsonMixin):
def __init__(self, nom: str, prix: float, stock: int) -> None:
self.nom = nom
self.prix = prix
self.stock = stock
p = Produit("Clavier mécanique", 89.99, 42)
print(repr(p))
print(p.to_dict())
Produit(nom='Clavier mécanique', prix=89.99, stock=42)
{'nom': 'Clavier mécanique', 'prix': 89.99, 'stock': 42}
Classes abstraites#
Une classe abstraite est une classe qui ne peut pas être instanciée directement et qui définit une interface contractuelle que ses sous-classes doivent respecter. En Python, elles s’implémentent avec le module abc (Abstract Base Classes).
Définition 14 (Classe abstraite)
Une classe abstraite est une classe qui contient au moins une méthode abstraite (décorée avec @abstractmethod). Elle ne peut pas être instanciée. Ses sous-classes concrètes doivent implémenter toutes les méthodes abstraites, sinon elles restent elles-mêmes abstraites. Les classes abstraites servent à définir des contrats d’interface — ce qu’une famille de classes doit être capable de faire.
from abc import ABC, abstractmethod
class Serialisable(ABC):
"""Interface pour les objets qui peuvent être sérialisés."""
@abstractmethod
def serialiser(self) -> str:
"""Convertit l'objet en chaîne de caractères."""
...
@abstractmethod
def deserialiser(self, data: str) -> None:
"""Restaure l'état de l'objet depuis une chaîne."""
...
def sauvegarder(self, chemin: str) -> None:
"""Méthode concrète qui utilise l'interface abstraite."""
with open(chemin, 'w') as f:
f.write(self.serialiser())
print(f"Sauvegardé dans {chemin}")
class ConfigJSON(Serialisable):
def __init__(self, donnees: dict) -> None:
self.donnees = donnees
def serialiser(self) -> str:
import json
return json.dumps(self.donnees, indent=2, ensure_ascii=False)
def deserialiser(self, data: str) -> None:
import json
self.donnees = json.loads(data)
# Tenter d'instancier la classe abstraite lève une erreur
try:
s = Serialisable()
except TypeError as e:
print(f"Erreur attendue : {e}")
# La sous-classe concrète, elle, s'instancie normalement
cfg = ConfigJSON({"langue": "français", "version": 3})
print(cfg.serialiser())
Erreur attendue : Can't instantiate abstract class Serialisable without an implementation for abstract methods 'deserialiser', 'serialiser'
{
"langue": "français",
"version": 3
}
Les classes abstraites du module abc ont un autre rôle : définir des ABCs de la bibliothèque standard, comme collections.abc.Sequence, collections.abc.Mapping, collections.abc.Iterable, etc. Ces ABCs servent de base pour les vérifications isinstance et comme source d’inspiration pour implémenter vos propres conteneurs.
Polymorphisme#
Le polymorphisme est la capacité d’un même code à fonctionner avec des objets de types différents. C’est l’une des forces majeures de Python.
Définition 15 (Polymorphisme)
Le polymorphisme (du grec polys = plusieurs, morphê = forme) désigne la capacité d’un code à traiter des objets de types différents de façon uniforme, à condition que ces objets exposent l’interface attendue. En Python, le polymorphisme est principalement réalisé via le duck typing : si un objet possède les méthodes et attributs attendus, il peut être utilisé, quelle que soit sa classe réelle.
import math
# Hiérarchie de formes avec un protocole commun
class Ellipse(Forme):
def __init__(self, a: float, b: float, couleur: str = "blanc") -> None:
super().__init__(couleur)
self.a = a # Demi-grand axe
self.b = b # Demi-petit axe
def aire(self) -> float:
return math.pi * self.a * self.b
def perimetre(self) -> float:
# Approximation de Ramanujan
h = ((self.a - self.b) / (self.a + self.b)) ** 2
return math.pi * (self.a + self.b) * (1 + 3*h / (10 + math.sqrt(4 - 3*h)))
def afficher_statistiques(formes: list) -> None:
"""Fonctionne avec n'importe quelle liste de formes — duck typing."""
total_aire = sum(f.aire() for f in formes)
total_perimetre = sum(f.perimetre() for f in formes)
plus_grande = max(formes, key=lambda f: f.aire())
print(f"Nombre de formes : {len(formes)}")
print(f"Aire totale : {total_aire:.2f}")
print(f"Périmètre total : {total_perimetre:.2f}")
print(f"Plus grande forme : {repr(plus_grande)}")
catalogue = [
Cercle(3.0, "rouge"),
Rectangle(4.0, 5.0, "bleu"),
Triangle(3.0, 4.0, 5.0),
Ellipse(6.0, 2.0, "violet"),
Carre(4.0, "vert"),
]
afficher_statistiques(catalogue)
Nombre de formes : 5
Aire totale : 107.97
Périmètre total : 91.58
Plus grande forme : Ellipse(couleur='violet')
Duck typing : protocoles structurels vs héritage nominal#
Le duck typing est la philosophie centrale du polymorphisme en Python : « Si ça marche comme un canard et ça cancane comme un canard, c’est un canard. » Un objet n’a pas besoin d’hériter d’une classe particulière pour être utilisé là où cette classe est attendue — il suffit qu’il possède les méthodes nécessaires.
# Un objet "ressemblant à une forme" sans hériter de Forme
class Losange:
"""Losange — ne hérite PAS de Forme."""
def __init__(self, diagonale1: float, diagonale2: float) -> None:
self.d1 = diagonale1
self.d2 = diagonale2
self.couleur = "gris"
def aire(self) -> float:
return (self.d1 * self.d2) / 2
def perimetre(self) -> float:
cote = math.sqrt((self.d1/2)**2 + (self.d2/2)**2)
return 4 * cote
def __repr__(self) -> str:
return f"Losange({self.d1!r}, {self.d2!r})"
# Fonctionne parfaitement car Losange a aire() et perimetre()
catalogue_etendu = catalogue + [Losange(6.0, 8.0)]
afficher_statistiques(catalogue_etendu)
Nombre de formes : 6
Aire totale : 131.97
Périmètre total : 111.58
Plus grande forme : Ellipse(couleur='violet')
Composition vs héritage#
L’héritage est souvent présenté comme la façon de réutiliser du code, mais il n’est pas toujours le meilleur outil. Le principe « favor composition over inheritance » (préférez la composition à l’héritage) recommande d’utiliser l’héritage uniquement pour modéliser de vraies relations « est-un », et de préférer la composition (un objet contient d’autres objets) pour les relations « a-un ».
Remarque 18
L’héritage crée un couplage fort entre la classe fille et la classe parente : toute modification de la parente peut affecter la fille de façon imprévue. La composition est plus souple : si un objet délègue une fonctionnalité à un autre objet, on peut changer l’objet délégué sans modifier le code appelant. En Python, la composition s’exprime naturellement en stockant des objets comme attributs.
# ❌ Héritage problématique : une Pile qui hérite de list
class PileParHeritage(list):
"""Pile LIFO par héritage — expose toutes les méthodes de list !"""
def empiler(self, element) -> None:
self.append(element)
def depiler(self):
return self.pop()
# ✅ Composition : une Pile qui contient une list
class PileParComposition:
"""Pile LIFO par composition — interface contrôlée."""
def __init__(self) -> None:
self._elements: list = []
def empiler(self, element) -> None:
self._elements.append(element)
def depiler(self):
if self.est_vide():
raise IndexError("La pile est vide")
return self._elements.pop()
def sommet(self):
if self.est_vide():
raise IndexError("La pile est vide")
return self._elements[-1]
def est_vide(self) -> bool:
return len(self._elements) == 0
def __len__(self) -> int:
return len(self._elements)
def __repr__(self) -> str:
return f"Pile({self._elements})"
pile1 = PileParHeritage()
pile1.empiler(1)
pile1.empiler(2)
# Problème : insert, sort, reverse, extend... toutes les méthodes de list sont exposées
print(f"Méthodes de PileParHeritage : {[m for m in dir(pile1) if not m.startswith('_')]}")
pile2 = PileParComposition()
pile2.empiler(10)
pile2.empiler(20)
pile2.empiler(30)
print(f"Sommet : {pile2.sommet()}")
print(f"Dépiler : {pile2.depiler()}")
print(f"État : {pile2}")
# Interface propre : seules les méthodes utiles sont exposées
print(f"Méthodes de PileParComposition : {[m for m in dir(pile2) if not m.startswith('_')]}")
Méthodes de PileParHeritage : ['append', 'clear', 'copy', 'count', 'depiler', 'empiler', 'extend', 'index', 'insert', 'pop', 'remove', 'reverse', 'sort']
Sommet : 30
Dépiler : 30
État : Pile([10, 20])
Méthodes de PileParComposition : ['depiler', 'empiler', 'est_vide', 'sommet']
isinstance et issubclass#
Python fournit deux fonctions built-in pour inspecter les relations d’héritage :
c = Cercle(5.0)
print(f"isinstance(c, Cercle) : {isinstance(c, Cercle)}")
print(f"isinstance(c, Forme) : {isinstance(c, Forme)}") # héritage
print(f"isinstance(c, object) : {isinstance(c, object)}") # tout hérite d'object
print(f"isinstance(c, Rectangle) : {isinstance(c, Rectangle)}")
print(f"issubclass(Carre, Rectangle) : {issubclass(Carre, Rectangle)}")
print(f"issubclass(Carre, Forme) : {issubclass(Carre, Forme)}")
print(f"issubclass(Forme, object) : {issubclass(Forme, object)}")
isinstance(c, Cercle) : True
isinstance(c, Forme) : True
isinstance(c, object) : True
isinstance(c, Rectangle) : False
issubclass(Carre, Rectangle) : True
issubclass(Carre, Forme) : True
issubclass(Forme, object) : True
Remarque 19
Préférez isinstance(obj, ClasseAttendue) à type(obj) == ClasseAttendue. La version avec type() retourne False si obj est une instance d’une sous-classe, ce qui brise le polymorphisme. isinstance est aussi plus flexible : elle accepte un tuple de classes comme deuxième argument (isinstance(obj, (int, float))). Cela dit, dans l’esprit du duck typing, vérifier le type explicitement est souvent un signe que le code pourrait être mieux structuré.
Visualisation : arbre d’héritage et MRO#
Résumé#
Ce chapitre a couvert les mécanismes d’héritage et de polymorphisme en Python :
L”héritage simple (
class Fille(Parente)) permet de réutiliser et d’étendre le comportement d’une classe existante.super()appelle la prochaine classe dans le MRO — indispensable pour le chaînage correct des constructeurs.L”héritage multiple (
class C(A, B)) est supporté par Python grâce à l”algorithme C3, qui calcule un ordre de résolution de méthodes (MRO) déterministe.__mro__permet de l’inspecter. Le patron mixin exploite l’héritage multiple pour ajouter des fonctionnalités transversales.Les classes abstraites (
abc.ABC,@abstractmethod) définissent des contrats d’interface que les sous-classes concrètes doivent honorer. Elles ne peuvent pas être instanciées directement.Le polymorphisme permet à un même code de fonctionner avec des objets de types différents. En Python, il repose sur le duck typing : ce qui compte, c’est l’interface exposée, pas la classe d’appartenance.
Composition vs héritage : l’héritage modélise les relations « est-un » ; la composition modélise les relations « a-un ». La composition est souvent plus flexible et moins couplée.
isinstanceetissubclasspermettent d’inspecter les relations d’héritage. Préférerisinstanceàtype(obj) ==pour respecter le polymorphisme.
Le chapitre suivant explore le modèle de données de Python et les méthodes spéciales (dunder methods) qui permettent d’intégrer vos classes dans le langage : opérateurs arithmétiques, protocole itérable, protocole d’appel, et bien d’autres.