Héritage
1. Introduction
L’héritage permet de construire une classe (appelée classe héritée, classe dérivée ou sous-classe) à partir d’une autre (appelée classe de base ou super-classe), par différence. La sous-classe dispose de tous les attributs et de toutes les méthodes de sa super-classe.
L’héritage exprime la relation "est une sorte de" (a kind of), qu’il faut bien distinguer de la relation de composition (ou d’aggrégation).
- Exemple d’héritage
-
Soit la classe abstraite
Container
modélisant quelque chose pouvant contenir des objets. Un objet container dispose d’une méthodesize()
renvoyant le nombre d’objets qu’il contient, etinsert(objet)
insérant un nouvel objet dans le container.Soit la classe abstraite
Iterable
modélisant un ensemble d’objets que l’on peut parcourir. Un itérable dispose d’une méthodecurrent()
renvoyant l’objet courant, etnext()
déplaçant l’objet courant sur le prochain.Une liste est à la fois un container et un itérable. Par conséquent
Liste
peut hériter deContainer
et deIterable
.Liste
hérite donc de 2 classes (c’est de l’héritage multiple). Une liste dispose donc (en plus des siennes propres) des 4 méthodessize()
,insert()
,current()
etnext()
.
- Exemple de composition
-
Soit la classe Point modélisant un point dans le plan. Et soit la classe Segment modélisant un segment de droite. Un segment peut être modélisé par 2 points. La relation entre Segment et Point n’est une relation d’héritage (un segment n’est pas une sorte de point), mais une relation d’aggrégation :
class Point: def __init__(self,x,y): self.x = x self.y = y class Segment: def __init__(self,a,b): self.a = a self.b = b s = Segment(Point(1,1),Point(0,2)
2. Sous-classe
Soit A
une classe, et B
une sous-classe de A
:
class A:
att = 1
class B(A): # B hérite de A
pass
if __name__=="__main__":
print(B.att) # -> 1
B
dispose de l’attribut de classe att
hérité de sa super-classe A
.
De même, la classe B
ci-dessous dispose d’une méthode f()
héritée de sa super-classe A
:
class A:
def f(self):
return 2
class B(A):
pass
if __name__=="__main__":
b = B()
print(b.f()) # -> 2
Dans l’exemple c-dessous, B
complète A
en ajoutant une méthode get()
:
class A:
def set_x(self,x):
self.x = x
class B(A):
def get_x(self):
return self.x (1)
if __name__=="__main__":
b = B()
b.set_x(3)
print(b.get_x()) # -> 3
1 | cette méthode définie dans B accède à un attribut
créé par une méthode issue de A |
La sous-classe peut aussi modifier le comportement issu de sa super-classe,
en redéfinissant (surchargeant) certaines méthodes.
Ci-dessous, B
surcharge la méthode mod_x()
issue de A
:
class A:
def set_x(self,x):
self.x = x
def get_x(self):
return self.x
def mod_x(self,k):
self.x += k
class B(A):
def mod_x(self,k):
self.x *= k
if __name__=="__main__":
a = A()
a.set_x(3)
a.mod_x(2)
print(a.get_x()) # -> 5
b = B()
b.set_x(3)
b.mod_x(2)
print(b.get_x()) # -> 6
3. Construction des objets
3.1. Appel des constructeurs
Soit une classe A
doté d’un constructeur,
et une sous-classe B
de A
.
class A:
def __init__(self):
self.A_is_built = True
class B(A):
pass
if __name__=="__main__":
b = B()
print(f"{b.A_is_built=}") # -> b.A_is_built=True
B
hérite de la méthode __init__()
de A,
donc la partie de B
héritée de A
est correctement initialisé.
Mais si B
possède lui-même un constructeur :
class A:
def __init__(self):
self.A_is_built = True
class B(A):
def __init__(self):
self.B_is_built = True
if __name__=="__main__":
a = A()
print(f"{a.A_is_built=}") # -> a.A_is_built=True
b = B()
print(f"{b.B_is_built=}") # -> b.B_is_built=True
print(f"{b.A_is_built=}") # -> AttributeError: 'B' object has no attribute 'A_is_built'. Did you mean: 'B_is_built'?
alors, on constate que le constructeur de A
n’est plus exécuté.
La partie de B
héritée de A
n’est donc pas initialisée,
et c’est un problème.
Le constructeur de la super-classe n’est pas appelé automatiquement dans une sous-classe. C’est un choix de Python, qui n’est pas universel. Par exemple, en C++, le constructeur de la super-classe est automatiquement appelé par le constructeur de la sous-classe. |
Pour intialiser la partie de B
héritée de A
,
il faut faire un appel explicite au constructeur de A
:
class A:
def __init__(self):
self.A_is_built = True
class B(A):
def __init__(self):
A.__init__(self) (1)
self.B_is_built = True
if __name__=="__main__":
b = B()
print(f"{b.B_is_built=}") # -> b.B_is_built=True
print(f"{b.A_is_built=}") # -> b.A_is_built=True
1 | appel explicite au constructeur de la super-classe |
3.2. super()
Mentionner le nom de sa super-classe A dans une classe B n’est pas judicieux.
En effet, si, au grè des évolutions, une classe C
vient à s’intercaler,
dans l’arbre d’héritage, entre A
et B
, alors il faudra reprendre le code de B
.
Pour éviter d’utiliser le nom de la super-classe en dur,
mieux vaut utiliser la fonction super()
qui la renvoie (dynamiquement) :
class A:
def __init__(self):
self.A_is_built = True
class B(A):
def __init__(self):
super().__init__() (1)
self.B_is_built = True
if __name__=="__main__":
b = B()
print(f"{b.B_is_built=}") # -> b.B_is_built=True
print(f"{b.A_is_built=}") # -> b.A_is_built=True
1 | appel explicite au constructeur de la super-classe |
Ce qui permet d’intercaler une classe C
sans douleur :
class A:
def __init__(self):
self.A_is_built = True
class C(A):
def __init__(self):
super().__init__()
self.C_is_built = True
class B(C):
def __init__(self):
super().__init__()
self.B_is_built = True
if __name__=="__main__":
b = B()
print(f"{b.B_is_built=}") # -> b.B_is_built=True
print(f"{b.C_is_built=}") # -> b.C_is_built=True
print(f"{b.A_is_built=}") # -> b.A_is_built=True
3.3. Exemple
Soit la classe Personne
, et Etudiant
une sous-classe :
class Personne:
def __init__(self,nom):
self.nom = nom
def __str__(self):
return f"{self.nom=}"
class Etudiant(Personne):
def __init__(self,nom,master):
super().__init__(nom) (1)
self.master = master
def __str__(self):
return f"{self.nom=}, {self.master=}"
if __name__=="__main__":
jean = Etudiant("Neymar",2022)
print(jean) # -> self.nom='Neymar', self.master=2022
1 | initialisation explicite de la partie héritée |
Similairement à ce qui a été fait pour __init__()
,
on pourrait réutiliser la méthode __str()__
de Personne
dans la méthode __str()__
de Etudiant
, pour afficher la partie héritée :
class Personne:
def __init__(self,nom):
self.nom = nom
def __str__(self):
return f"{self.nom=}"
class Etudiant(Personne):
def __init__(self,nom,master):
super().__init__(nom)
self.master = master
def __str__(self):
return f"{super().__str__()}, {self.master=}" (1)
if __name__=="__main__":
jean = Etudiant("Neymar",2022)
print(jean) # -> self.nom='Neymar', self.master=2022
1 | appel explicite à la méthode __str()__ de la super-classe |
En évitant d’avoir à spécifier le nom de la super-classe en dur, l’usage de super()
facilite d’insertion d’une nouvelle classe dans l’arbre d’héritage :
class Personne:
def __init__(self,nom):
self.nom = nom
def __str__(self):
return f"{self.nom=}"
class Bachelier(Personne): (1)
def __init__(self,nom,bac):
super().__init__(nom)
self.bac = bac
def __str__(self):
return f"{super().__str__()}, {self.bac=}"
class Etudiant(Bachelier):
def __init__(self,nom,bac,master):
super().__init__(nom,bac)
self.master = master
def __str__(self):
return f"{super().__str__()}, {self.master=}"
if __name__=="__main__":
print(jean) # -> self.nom='Neymar', self.bac=2018, self.master=2022
1 | Bachelier intercalée entre Personne et Etudiant |
Bachelier
a été intercalée, et (presque) aucune modification n’a été nécessaire dans Etudiant
.
3.4. Récapitulatif
Soit B
une classe qui hérite de A
.
Créer une instance de B
, c’est donc créer d’abord une instance de A
,
puis y ajouter ce qu’il manque pour parvenir à un objet de type B
.
Si la construction de l’instance de A
nécessite des arguments,
cela doit être pris en charge par l’instance de B
, lors de sa création :
class A:
def __init__(self,x):
self.x = x
def get_x(self):
return self.x
class B(A):
def __init__(self,x,y): (1)
A.__init__(self,x) (2)
self.y = y (3)
def get_y(self):
return self.y
if __name__=="__main__":
b = B(1,2)
print(b.get_x(),b.get_y())
1 | Pour initialiser l’instance de B … |
2 | … on initialise ce qui vient de A … |
3 | … puis on ajoute le complément. |
La fonction super()
renvoie la super-classe, ce qui permet d’écrire de façon équivalente :
class B(A):
def __init__(self,x,y):
super().__init__(x) # équivalent à A.__init__(self,x)
self.y = y
L’héritage permet de factoriser du code entre différentes classes issues d’une même super-classe.
4. L’appel de méthodes en détail
La sous-classe peut donc compléter sa super-classe, et aussi modifier son comportement en redéfinissant (surchargeant) certaines méthodes.
class A:
def f(self):
print(" inside A.f")
def g(self):
print(" inside A.g")
def i(self):
print(" inside A.i")
def j(self):
print(" inside A.j")
class B(A):
def f(self):
print(" inside B.f")
def h(self):
print(" inside B.h")
self.f()
def i(self):
print(" inside B.i")
super().i()
def j(self):
print(" inside B.j")
self.g()
if __name__=="__main__":
b = B()
b.f() # "inside B.f" (1)
b.g() # "inside A.g" (2)
b.h() # "inside B.h" "inside B.f" (3)
b.i() # "inside B.i" "inside A.i" (4)
b.j() # "inside B.j" "inside A.g" (5)
1 | appelle B.f puisque f est surchargée dans B |
2 | appelle A.f puisque f n’existe pas dans B |
3 | appelle B.h , qui appelle B.f puisque self est de type B |
4 | appelle B.i qui appelle A.i puisque super() renvoie A |
5 | appelle B.j qui appelle B.g , qui n’existe pas, donc qui est remplacée par A.g héritée |
5. Classe abstraite
Par définition, une classe abstraite est une classe qui n’a pas vocation à être instanciée, mais seulement à servir de classe de base pour dériver d’autres classes. C’est en général une classe dont le contenu est insuffisant pour représenter complètement un objet réel.
6. Héritage multiple
L’héritage est dit multiple lorsqu’une classe possède plusieurs classes de base directes.
class A:
pass
class B:
pass
class C(A,B): (1)
pass
1 | héritage multiple |
6.1. isinstance() et __class__
isinstance(objet,classe)
et une fonction booléenne
indiquant si objet
est une instance (directe ou indirecte) de classe
.
__class__
est un attribut de chaque classe contenant ses caractéristiques, par exemple :
-
un attribut
__name__
ayant pour valeur le nom de la classe, -
un attribut
__bases__
ayant pour valeur la liste des classes de base directes.
class A:
pass
class B(A):
pass
class C:
pass
class D(B,C):
pass
if __name__=="__main__":
a = A()
b = B()
d = D()
print(a.__class__.__name__) # A
print(b.__class__.__name__) # B
print(d.__class__.__name__) # D
print(isinstance(a,A), # True
isinstance(b,B), # True
isinstance(b,A), # True
isinstance(b,C), # False
isinstance(d,D), # True
isinstance(d,B), # True
isinstance(d,C), # True
isinstance(d,A)) # True
7. Polymorphisme
Formellement, le polymorphisme est la propriété d’un élément à pouvoir se présenter sous plusieurs formes. Il se matérialise ici par la capacité à réaliser des opérations s’effectuant différemment suivant le contexte : une opération plus ou moins abstraite définie dans une super-classe, qui se réalise concrètement et spécifiquement dans une sous-classe.
7.1. Le principe
Une classe peut utiliser les méthodes de ses sous-classes à la place des siennes, de façon transparente et automatique :
class A:
def f(self):
print(" inside A.f")
def k(self):
print(" inside A.k")
self.f() (1)
class B(A):
def f(self):
print(" inside B.f")
1 | une simple lecture statique des classes A et B
ne permet pas de déterminer quelle méthode est appelée ici |
Et en effet, cela varie :
a = A()
a.k() # inside A.k
# inside A.f
b = B()
b.k() # inside A.k
# inside B.f
7.2. L’implémentation
Voici un exemple de ce qu’il ne faut pas faire :
import math
class Shape:
def area(self):
if isinstance(self,Circle):
return self.surface()
if isinstance(self,Square):
return self.space()
class Circle(Shape):
def __init__(self,radius):
self.radius = radius
def surface(self):
return math.pi * self.radius**2
class Square(Shape):
def __init__(self,side):
self.side = side
def space(self):
return self.side**2
if __name__=="__main__":
shape1 = Circle(10)
shape2 = Square(14)
print(shape1.area()) # 314.1592653589793
print(shape2.area()) # 196
Dans ce programme, qui calcule correctement les surfaces des cercles et carrés, il y a un problème :
la classe Shape
référence explicitement ses sous-classes Circle
et Square
.
Or le concepteur de Shape
n’est pas censé connaitre toutes les classes qui seront dérivées de sa classe.
D’ailleurs, elles n’existent probablement pas au moment où il conçoit Shape
.
Pour chaque nouvelle forme à venir, il faudra modifier le constructeur de Shape
.
Par exemple, le concepteur d’une classe Triangle
, qui n’est qu’un utilisateur de la classe Shape
, va devoir la modifier : ça ne va pas.
La POO permet de faire beaucoup mieux :
class Shape:
pass
class Circle(Shape):
def __init__(self,radius):
self.radius = radius
def area(self):
return math.pi * self.radius**2
class Square(Shape):
def __init__(self,side):
self.side = side
def area(self):
return self.side**2
Ici, plus de dépendance super classe → sous classe, c’est nettement mieux.
Mais le programmeur qui va utiliser Shape
pour dériver ses propres classes doit comprendre que celles-ci doivent implémenter une méthode area(self)
, sinon :
import math
class Shape:
pass
...
class Triangle(Shape):
def __init__(self,side1,side2,side3):
self.side1 = side1
self.side2 = side2
self.side3 = side3
def surface(self):
...
if __name__=="__main__":
shape1 = Triangle(3,3,4)
print(shape1.area()) # AttributeError: 'Triangle' object has no attribute 'area'
Il y a moyen de rendre les choses plus explicites pour le développeur. Par exemple :
class Shape:
def area(self):
raise NotImplementedError("method area() must be redefined in subclasses")
class Circle(Shape):
def __init__(self,radius):
self.radius = radius
def area(self):
return math.pi * self.radius**2
class Triangle(Shape):
def __init__(self,side1,side2,side3):
self.side1 = side1
self.side2 = side2
self.side3 = side3
def surface(self):
...
if __name__=="__main__":
shape1 = Circle(10)
shape2 = Triangle(3,3,4)
print(shape1.area())
print(shape2.area()) # NotImplementedError: method area() must be redefined in Shape's subclasses
Une autre façon de faire consiste à utiliser la classe ABC
(pour Abstract Base Classes) du module abc
,
qui est justement faite pour ça.
7.3. La magie du polymorphisme
Le polymorphisme contribue fortement à la réutilisabilité des composants logiciels. L’exemple ci-dessous le met en évidence.
Soit la situation suivante :
-
Une super-classe A est réalisée à un instant t par un développeur d1.
-
Elle est réutilisée bien après t par un développeur d2 sans concertation avec d1, pour créer une sous-classe B (sans modifier A bien-entendu).
Malgré ce décalage temporelle, une méthode de la classe A peut appeler, sans aucune modification, une méthode de B qui n’existait pas au moment où A a été créée. C’est l’aspect magique du polymorphisme.
Pour exploiter ce concept, le développeur d1 doit imaginer tout le potentiel de sa classe A, comment elle pourrait être utilisée dans le futur. Il doit faire en sorte de permettre au développeur qui réutilisera sa classe A pour créer une classe dérivée B, de pouvoir insérer son propre code là où c’est judicieux. Concrètement, cela veut dire créer dans A des méthodes destinées à être redéfinies dans les futures sous-classes. Et même parfois à créer des méthodes qui ne font rien dans A (des hooks), et dont le seul but est d’être redéfinies dans B.
En Python, ces méthodes particulières ne se distinguent pas des autres méthodes. En C++, les méthodes destinées à être redéfinies dans les sous-classes sont appelées méthodes virtuelles, et parmi elles, celles qui ne font rien sont des méthodes virtuelles pures. |
Avec un langage compilé, le polymorphisme est encore plus étonnant. En effet, une méthode d’une classe A compilée dans le passé peut appeler une méthode d’une sous-classe créée bien après A, sans nécessité d’être recompilée.