modifié le

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éthode size() renvoyant le nombre d’objets qu’il contient, et insert(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éthode current() renvoyant l’objet courant, et next() 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 de Container et de Iterable. 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éthodes size(), insert(), current() et next().

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.

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

Exemple
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 :

Exemple :
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 classesous 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 :

  1. Une super-classe A est réalisée à un instant t par un développeur d1.

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