Héritage et polymorphisme

Une application Python est composée d’un ensemble d’objets. Un des intérêts de la programmation orientée objet (POO) réside dans les relations que ces objets entretiennent les uns avec les autres. Ces relations sont construites par les développeurs et constituent ce que l’on appelle l’architecture d’une application. Il existe deux relations fondamentales en POO :

a un(e) (has-a)

Cette relation permet de créer une relation de dépendance d’une classe envers une autre. Une classe a besoin des services d’une autre classe pour réaliser sa fonction. On parle également de relation de composition pour désigner ce type de relation.

La relation « a un » implique simplement qu’un objet possède un attribut qui référence un autre objet. Par exemple, si une programme déclare une classe Personne, cette classe peut avoir une propriété pour stocker l’adresse sous la forme d’une classe adresse :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
class Personne:

    def __init__(self, nom, prenom, adresse):
        self.nom = nom
        self.prenom = prenom
        self.adresse = adresse


class Adresse:

    def __init__(self, rue, code_postal, ville):
        self.rue = rue
        self.code_postal = code_postal
        self.ville = ville


adresse = Adresse(rue="4 rue d'ici", code_postal="78000", ville="Paris")
personne = Personne(prenom="Jean", nom="Dumond", adresse=adresse)

Le code ci-dessus illustre la relation de composition et on peut bien dire qu’une personne a une adresse.

est un(e) (is-a)

Cette relation permet de créer une chaîne de relation d’identité entre des classes. Elle indique qu’une classe peut être assimilée à une autre classe qui correspond à une notion plus abstraite ou plus générale. On parle d’héritage pour désigner le mécanisme qui permet d’implémenter ce type de relation. Le reste de ce chapitre est consacré à ce type particulier de relation entre classes.

L’héritage

L’héritage est donc le mécanisme qui permet de traduire une relation de type « est un(e) ».

Prenons l’exemple d’une classe Voiture et d’une classe Vehicule. Il est possible de dire qu’une voiture est un véhicule. Je peux traduire cette relation en Python. On dit que Vehicule est la classe parente de Voiture ou la généralisation. Symétriquement, on dit que Voiture est la classe enfant (ou fille) de Vehicule ou la spécialisation. On dit aussi que la classe Vehicule est la super classe de Voiture.

class Vehicule:
    pass


class Voiture(Vehicule):
    pass

Cette relation se traduit en précisant le nom de la classe parente entre parenthèses après le nom de la classe.

Si nous créons un objet à partir de la classe Voiture, cet objet est à la fois de type Vehicule et de type Voiture.

>>> v = Voiture()
>>> isinstance(v, Voiture)
True
>>> isinstance(v, Vehicule)
True

Le mot de héritage vient du fait qu’une classe fille hérite des attributs et des comportements de sa classe mère.

class Vehicule:

    def __init__(self):
        self._vitesse = 0

    @property
    def vitesse(self):
        return self._vitesse

    def accelerer(self, delta_vitesse):
        self._vitesse += delta_vitesse

    def decelerer(self, delta_vitesse):
        self._vitesse -= delta_vitesse


class Voiture(Vehicule):
    pass
>>> v = Voiture()
>>> v.vitesse
0
>>> v.accelerer(80)
>>> v.vitesse
80

Les objets de type Voiture disposent des mêmes comportements, propriétés et attributs déclarés dans la classe Vehicule.

La classe fille peut fournir ses propres comportements et ses propres attributs :

class Voiture(Vehicule):

    def klaxonner(self):
        print("tût tût !")

Constructeur et héritage

Le constructeur est, comme toutes les méthodes, hérité. Cependant que ce passe-t-il si nous souhaitons ajouter une constructeur dans notre classe Voiture ?

class Voiture(Vehicule):

    def __init__(self, klaxon="tût tût !"):
        self.klaxon = klaxon

    def klaxonner(self):
        print(self.klaxon)
>>> v = Voiture()
>>> v.klaxonner()
tût tût !
>>> v.vitesse
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
    return self._vitesse
AttributeError: 'Voiture' object has no attribute '_vitesse'

Cela ne fonctionne plus car le constructeur déclaré dans la classe Voiture remplace et cache celui hérité de la classe Vehicule et l’initialisation de l’attribut _vitesse n’est plus faite. Quand une classe hérite d’une autre classe, elle a la responsabilité de s’assurer que le constructeur de la classe parente est appelé pour garantir l’initialisation. Pour cela, Python 3 fournit la fonction super() qui retourne le type de la super classe. L’implémentation correcte de la classe Voiture doit donc être :

class Voiture(Vehicule):

    def __init__(self, klaxon="tût tût !"):
        super().__init__()
        self.klaxon = klaxon

    def klaxonner(self):
        print(self.klaxon)
>>> v = Voiture()
>>> v.klaxonner()
tût tût !
>>> v.vitesse
0

Il est donc facile de passer des paramètres à l’initialisation de la classe parente si cette dernière attend des paramètres de constructeur.

class Vehicule:

    def __init__(self, marque=None, vitesse_initiale=0):
        self.marque = marque
        self._vitesse = vitesse_initiale

    @property
    def vitesse(self):
        return self._vitesse

    def accelerer(self, delta_vitesse):
        self._vitesse += delta_vitesse

    def decelerer(self, delta_vitesse):
        self._vitesse -= delta_vitesse


class Voiture(Vehicule):

    def __init__(self, marque=None, vitesse_initiale=0, klaxon="tût tût !"):
        super().__init__(marque, vitesse_initiale)
        self.klaxon = klaxon

    def klaxonner(self):
        print(self.klaxon)
>>> v = Voiture("De Lorean", 88.0)
>>> v.vitesse
88.0
>>> v.marque
'De Lorean'
>>> v.klaxonner()
tût tût !

Note

Pour chaîner l’appel des constructeurs, il est également possible d’appeler directement le constructeur en désignant la classe parente :

class Voiture(Vehicule):

    def __init__(self, marque=None, vitesse_initiale=0, klaxon="tût tût !"):
        Vehicule.__init__(marque, vitesse_initiale)
        self.klaxon = klaxon

Cette écriture est cependant considérée comme obsolète en Python 3 grâce à la fonction super().

Héritage et mutualisation de code

Un des intérêts de l’héritage est de permettre la réutilisation de code. Maintenant que nous avons défini une Vehicule, nous pouvons imaginer diverses classes appropriées pour notre système et qui sont des véhicules.

class Charette(Vehicule):

    def __init__(self, vitesse_initiale=0):
        super().__init__(vitesse_initiale=vitesse_initiale)
>>> c = Charette(1)
>>> c.accelerer(5)
>>> c.vitesse
6

La classe object

Python définit la classe object. Toutes les classes hérite directement ou indirectement de cette classe. Si une classe ne déclare aucune classe parente alors sa classe parente est object.

class MaClasse:
    pass

La déclaration ci-dessus est strictement équivalente à :

class MaClasse(object):
    pass

Il est possible de créer des instances de la classe object. Cela reste d’un usage limité car cette classe n’offre aucune méthode particulière et il n’est pas possible d’ajouter dynamiquement des attributs à ses instances.

>>> o = object()
>>> o.nom = "Un nom"
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'object' object has no attribute 'nom'

Le polymorphisme

Le polymorphisme est un mécanisme important dans la programmation objet. Il permet de modifier le comportement d’une classe fille par rapport à sa classe mère. Le polymorphisme permet d’utiliser l’héritage comme un mécanisme d’extension en adaptant le comportement des objets.

Prenons l’exemple de la classe Animal. Cette classe offre une méthode crier. Pour simplifier notre exemple, la méthode se contente d’écrire le cri de l’animal sur la sortie standard.

class Animal:

    def crier(self):
        print("un cri d'animal")

Nous pouvons créer les classes Chien et Chat qui héritent toutes deux de la classe Animal. Ces classes peuvent être des spécialisations de la classe Animal en ce qu’elles peuvent redéfinir (override) le comportement de la méthode crier ().

class Chien(Animal):

    def crier(self):
        print("whouaf whouaf !")


class Chat(Animal):

    def crier(self):
        print("miaou !")
>>> a = Animal()
>>> animal = Animal()
>>> animal.crier()
un cri d'animal
>>> animal = Chien()
>>> animal.crier()
whouaf whouaf !
>>> animal = Chat()
>>> animal.crier()
miaou !

Un chat est bien un animal, un chien est bien un animal. Les objets de ces types disposent bien de la méthode crier() mais son comportement est polymorphe. Il dépend du type réel de l’objet.

À noter qu’il est toujours possible d’appeler la méthode parente dans la classe enfant grâce à la fonction super().

class Chien(Animal):

    def crier(self):
        print("whouaf whouaf !")

    def crier_comme_un_animal(self):
        super().crier()
>>> chien = Chien()
>>> chien.crier()
whouaf whouaf !
>>> chien.crier_comme_un_animal()
un cri d'animal

Polymorphisme et duck typing

Pour les langages de programmation à typage fort comme le C++ ou le Java, le polymorphisme a des implications très fortes et il permet d’introduire une grande souplesse dans les relations entre types. Pour les langages de programmation à typage dynamique comme Python, le polymorphisme, même s’il reste une notion importante, a moins d’impact.

En Python, on préfère souvent le principe du duck typing (ou typage canard). Cette approche repose sur le principe suivant :

« Si je vois un oiseau qui vole comme un canard, cancane comme un canard, et
nage comme un canard, alors j’appelle cet oiseau un canard. »

Cela signifie que le type réel est moins important que le comportement attendu. Pour reprendre notre exemple des véhicules : si une classe offre des méthodes pour accélérer, décélérer et des propriétés pour la vitesse et la marque, alors c’est qu’il s’agit d’un véhicule et peu importe que cette classe hérite ou non de la classe Vehicule. Tous les objets de cette classe pourront être utilisés dans un contexte qui manipule des véhicules.

Masquer les attributs et les méthodes pour les classe filles

Parfois, on ne souhaite pas qu’une méthode puisse être redéfinie ou qu’un attribut puisse être modifié dans une classe fille. Pour cela, il suffit que le nom de la méthode ou de l’attribut commence par deux caractères soulignés (underscores). Nous avons vu précédemment que Python n’a pas de mécanisme pour contrôler la visibilité des éléments d’une classe. Par convention, les développeurs signalent par un caractère souligné (underscore) le statut privé d’un attribut ou d’une méthode. Par contre le recours à deux caractères soulignés à un impact sur l’interpréteur. Ce dernier renomme la méthode ou l’attribut de la forme :

_<nom de la classe>__<nom>

Ainsi si nous modifions le nom de la la classe Vehicule :

class Vehicule:

    def __init__(self, marque=None, vitesse_initiale=0):
        self.marque = marque
        self.__vitesse = vitesse_initiale

    @property
    def vitesse(self):
        return self.__vitesse

    def accelerer(self, delta_vitesse):
        self.__vitesse += delta_vitesse

    def decelerer(self, delta_vitesse):
        self.__vitesse -= delta_vitesse

L’attribut __vitesse sera renommé _Vehicule__vitesse. Donc, si un attribut __vitesse est également utilisé dans une classe fille, l’interpréteur considérera qu’il s’agit d’un attribut différent.

Note

Cette notation est avant tout faite pour éviter des effets de bord involontaire lorsqu’on hérite d’une classe dont on ne connaît pas bien le code source. Après tout, il est toujours possible d’implémenter une classe fille qui utilise un attribut ou déclare une méthode sans que le développeur ait conscience qu’il réutilise un attribut ou qu’il redéfinit une méthode d’une classe parente.

Les exceptions comme objets

En Python, une exception est un objet qui est directement ou indirectement une instance de la classe BaseException.

Il existe beaucoup de classes pour représenter des exceptions en Python. Vous pouvez consulter la documentation pour connaître la hiérarchie des exceptions. Néanmoins, il est très simple de créer ses propres exceptions. Il est recommandé de créer des exceptions en héritant de Exception ou d’une classe héritant de Exception. Exception est une classe qui hérite de BaseException. Pour simplifier l’implémentation, BaseException définit l’attribut args qui contient tous les paramètres passés au constructeur.

class MonException(Exception):
    pass

try:
    raise MonException("Mon message")
except MonException as e:
    print(e.args)
    # Affiche ('Mon message',)
    print(e)
    # Affiche Mon message

Héritage multiple et mixin

Une classe peut hériter de plusieurs classes. Dans ce cas, elle héritera des méthodes et des attributs de l’ensemble de ces classes et de leurs classes parentes. Il suffit d’indiquer la liste des classes parentes en les séparant par une virgule.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class Animal:

    def crier(self):
        pass


class Carnivore:

    def chasser(self):
        pass


class Chien(Animal, Carnivore):
    """Un chien qui est à la fois un animal et un carnivore"""
>>> c = Chien()
>>> c.chasser()
>>> c.crier()

La classe Chien hérite des comportements mais aussi des attributs des classes parentes. La variable c désigne un objet qui est à la fois un Chien, un Animal et un Carnivore.

>>> isinstance(c, Chien)
True
>>> isinstance(c, Animal)
True
>>> isinstance(c, Carnivore)
True

L’héritage en diamant

Il peut arriver qu’une classe hérite indirectement plusieurs fois de la même classe. Reprenons notre exemple précédent et supposons que les classes Animal et Carnivore héritent toutes deux de la classe EtreVivant et que la classe EtreVivant possède l’attribut point_de_vie.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
class EtreVivant:

    def __init__(self):
        self.point_de_vie = 100

class Animal(EtreVivant):

    def dormir(self):
        self.point_de_vie += 1

class Carnivore(EtreVivant):

    def chasser(self):
        self.point_de_vie -= 1


class Chien(Animal, Carnivore):
    """Un chien qui est à la fois un animal et un carnivore"""

Dans ce cas, la classe Chien hérite deux fois de la classe EtreVivant. À cause de la représentation graphique d’une telle situation, on appelle ce cas particulier l’héritage en diamant.

Python résout cette situation en considérant qu’une classe ne peut pas directement ou indirectement hériter plusieurs fois d’une même classe. Donc, les instances de la classe Chien ne posséderont qu’un seul attribut point_de_vie.

>>> c = Chien()
>>> c.chasser()
>>> c.point_de_vie
99
>>> c.dormir()
>>> c.point_de_vie
100

Mais que se passe-t-il si l’héritage en diamant implique des méthodes ? Par exemple, imaginons que la classe EtreVivant possède la méthode se_nourrir() qui est redéfinie à la fois dans la classe Animal et dans la classe Carnivore.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class EtreVivant:

    def __init__(self):
        self.point_de_vie = 100

    def se_nourrir(self):
        self.point_de_vie += 1


class Animal(EtreVivant):

    def dormir(self):
        self.point_de_vie += 1

    def se_nourrir(self):
        self.point_de_vie += 5


class Carnivore(EtreVivant):

    def chasser(self):
        self.point_de_vie -= 1

    def se_nourrir(self):
        self.point_de_vie += 10


class Chien(Animal, Carnivore):
    """Un chien qui est à la fois un animal et un carnivore"""

Que se passe-t-il si nous appelons la méthode se_nourrir() depuis une instance de Chien ?

>>> c = Chien()
>>> c.se_nourrir()
>>> c.point_de_vie
105

C’est la méthode de la classe Animal qui est exécutée, passant la valeur de point_de_vie à 105. Pour choisir quelle méthode est appelée, l’interpréteur se base sur un algorithme appelé method resolution order ou mro. Il s’agit de chercher dans la liste de l’héritage des classes la première déclaration de la méthode appelée. On peut facilement connaître le mro d’une classe car il existe la méthode class.mro() qui retourne précisément cette liste. Pour accéder à la classe d’une instance, on peut utiliser la fonction type.

>>> type(c).mro()
[<class '__main__.Chien'>, <class '__main__.Animal'>,
 <class '__main__.Carnivore'>, <class '__main__.EtreVivant'>,
 <class 'object'>]

On voit que pour le type Chien, un appel à une méthode conduit à chercher cette méthode d’abord dans la classe Chien et ensuite dans les classes Animal Carnivore, EtreVivant et enfin object.

Note

Rappelez-vous qu’une classe qui ne précise explicitement aucune classe parente hérite tout de même de la classe object.

Si nous intervertissons l’ordre d’héritage de la déclaration de la classe Chien.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class EtreVivant:

    def __init__(self):
        self.point_de_vie = 100

    def se_nourrir(self):
        self.point_de_vie += 1


class Animal(EtreVivant):

    def dormir(self):
        self.point_de_vie += 1

    def se_nourrir(self):
        self.point_de_vie += 5


class Carnivore(EtreVivant):

    def chasser(self):
        self.point_de_vie -= 1

    def se_nourrir(self):
        self.point_de_vie += 10


class Chien(Carnivore, Animal):
    """Un chien qui est à la fois un animal et un carnivore"""
>>> c = Chien()
>>> c.se_nourrir()
>>> c.point_de_vie
110
>>> type(c).mro()
[<class '__main__.Chien'>, <class '__main__.Carnivore'>,
 <class '__main__.Animal'>, <class '__main__.EtreVivant'>,
 <class 'object'>]

Maintenant, c’est la méthode se_nourrir() de la classe Carnivore qui est appelée pour un objet de type Chien. L’ordre de déclaration de l’héritage multiple est donc primordial !

Appel des constructeurs

Si nous voulons déclarer des constructeurs, il faut impérativement appeler le constructeur de la super classe.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class EtreVivant:

    def __init__(self, point_de_vie):
        self.point_de_vie = point_de_vie

    def se_nourrir(self):
        self.point_de_vie += 1


class Animal(EtreVivant):

    def __init__(self, nom, point_de_vie):
        super().__init__(point_de_vie)
        self.nom = nom

    def dormir(self):
        self.point_de_vie += 1

    def se_nourrir(self):
        self.point_de_vie += 5


class Carnivore(EtreVivant):

    def chasser(self):
        self.point_de_vie -= 1

    def se_nourrir(self):
        self.point_de_vie += 10


class Chien(Carnivore, Animal):
    """Un chien qui est à la fois un animal et un carnivore"""

    def __init__(self, point_de_vie, nom):
        super().__init__(point_de_vie, nom)
>>> c = Chien("Médor", 60)
>>> c.nom
'Médor'
>>> c.point_de_vie
60
>>> c.se_nourrir()
>>> c.point_de_vie
70

La fonction super() retourne le type suivant dans la liste du mro. La classe Carnivore ne proposant pas de constructeur, elle se contente de passer l’appel au constructeur de la classe Animal qui est la classe suivante dans la liste mro.

Les mixins

Un mixin est une classe qui permet d’ajouter des fonctionnalités supplémentaires. Il s’agit simplement d’une classe comme une autre mais qui n’est pas destinée à être utilisée directement pour créer des instances.

Imaginons que nous désirions ajouter à certaines de nos classes la possibilité d’afficher leur état (la valeur des attributs) grâce à une méthode afficher(). Nous pouvons créer un mixin proposant cette méthode. Ce mixin accepte en paramètre le caractère à utiliser comme séparateur ainsi que l’indentation à utiliser lors de l’affichage.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
class Affichable:

    def __init__(self, *args, indentation=0, separateur="-", **kwargs):
        self.__indentation = indentation
        self.__separateur = separateur
        super().__init__(*args, **kwargs)

    def afficher(self):
        cls = type(self)
        separateur = self.__separateur * 80
        print(separateur)
        print("Objet de la classe", cls.__name__)
        print(cls.__doc__)
        print("mro : ", cls.mro())
        print()
        indentation = ' ' * self.__indentation
        for attr in self.__dict__:
            if not attr.startswith('_'):
                valeur = getattr(self, attr)
                print(f"{indentation}{attr} = {valeur}")
        print(separateur)

Nous pouvons modifier notre implémentation de la classe Chien pour la rendre affichable :

1
2
3
4
5
class Chien(Affichable, Animal):
    """Un chien qui est à la fois un animal et un carnivore"""

    def __init__(self, point_de_vie, nom, **kwargs):
        super().__init__(point_de_vie, nom, **kwargs)

Ainsi nous pouvons afficher l’état interne d’un objet de type Chien.

>>> c = Chien(40, "Médor", indentation=4)
>>> c.afficher()
--------------------------------------------------------------------------------
Objet de la classe Chien
Un chien qui est à la fois un animal et un carnivore
mro :  [<class '__main__.Chien'>, <class '__main__.Affichable'>,
<class '__main__.Animal'>, <class '__main__.EtreVivant'>, <class 'object'>]

    point_de_vie = 40
    nom = Médor
--------------------------------------------------------------------------------

Il y a plusieurs points importants à remarquer dans l’implémentation ci-dessus :

  1. La classe Chien hérite en premier du mixin Affichable. À cause de ce que nous avons vu à propos du mro, un mixin devrait toujours être déclaré avant la classe parente.

  2. La classe Chien accepte comme dernier paramètre de constructeur le paramètre de compactage **kwargs. La classe Chien peut ainsi accepter les paramètres nommés identation et separateur sans avoir besoin de les répéter.

  3. La classe Affichage a un constructeur assez compliqué.

    def __init__(self, *args, indentation=0, separateur="-", **kwargs):
        self.__indentation = indentation
        self.__separateur = separateur
        super().__init__(*args, **kwargs)
    

    Les paramètres indentation et separateur sont placés après l’opérateur de compactage *args ce qui implique qu’il s’agit de paramètres nommés. Le constructeur utilise à la fin la fonction super() pour appeler le constructeur suivant en lui passant tous les paramètres que le mixin ne reconnaît pas. On voit qu’il est tout à fait possible de concevoir un mixin qui accepte des paramètres de constructeur tout en s’intégrant élégamment à un héritage existant… au prix d’une petite complexité dans la déclaration du constructeur.

Note

Les mixins sont une façon de construire de nouvelles classes par agrégat de fonctionnalités. Attention cependant car en programmation orienté objet, il est recommandé de définir ses classes de manière à ce qu’elles aient une responsabilité unique et clairement identifiable dans le système. Cela permet de limiter la complexité des classes et donc de faciliter leur compréhension. L’abus du recours au mixin risque de créer des classes tout-en-un difficiles à comprendre et à maintenir.

La méta-classe

La méta-classe est un concept avancé en Python qui n’est que très très rarement utilisé directement par les développeurs.

En Python, les classes sont elles-mêmes des objets qui héritent de type. Il est possible de spécifier le type dont doit hériter l’objet qui représente la classe. On parle alors de méta-classe.

Une méta-classe est une classe qui décrit une classe. Cela signifie que tous les attributs et toutes les méthodes d’une méta-classe seront les attributs et les méthodes de la classe.

L’usage de la méta-classe permet de réaliser des implémentations qui ne sont pas possibles avec une simple classe. Par exemple, il n’est pas possible en Python de déclarer une propriété de classe avec le décorateur @property. Mais cela devient possible par le truchement d’une méta-classe.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
class MetaclasseCompteur(type):
    """Une méta-classe pour aider à compter les instances créées."""

    def __init__(cls, *args, **kwargs):
        super().__init__(*args, **kwargs)
        cls._nb_instances = 0

    @property
    def nb_instances(cls):
        return cls._nb_instances

    def plus_une_instance(cls):
        cls._nb_instances += 1


class MaClasse(metaclass=MetaclasseCompteur):

    def __init__(self):
        MaClasse.plus_une_instance()

Une méta-classe doit hériter directement ou indirectement de type. À la ligne 16, on déclare la méta-classe de la classe MaClasse. Cette méta-classe va fournir la propriété de classe nb_instances ainsi que la méthode de classe plus_une_instance().

>>> MaClasse.nb_instances
0
>>> m1 = MaClasse()
>>> m2 = MaClasse()
>>> m3 = MaClasse()
>>> MaClasse.nb_instances
3

Classes et méthodes abstraites

Comme Python est un langage à typage dynamique, nous avons déjà dit plus haut que la plupart des développeurs favorisent le duck typing : le type réel d’un objet importe moins que le fait qu’il produise les comportements attendus (c’est-à-dire les méthodes et les propriétés attendues). Cette approche amène indéniablement plus de souplesse dans la conception des applications. Mais elle rend plus difficile la validation du code et donc cela peut aboutir à la production d’un code moins robuste.

Les langages de programmation à typage fort comme C++, Java ou C# introduisent tous le principe de classes abstraites et/ou d’interfaces. Ce type d’approche insiste sur le fait de pouvoir définir un type particulier contenant un certain nombre de méthodes mais pour lesquelles on ne fournit aucune implémentation. Ces classes abstraites et ces interfaces sont ensuite héritées ou implémentées par d’autres classes qui doivent fournir les implémentations des méthodes attendues. Cela permet de garantir qu’un objet aura bien les comportements attendus, c’est-à-dire implémentera les méthodes attendues. On parle parfois de programmation par contrat, dans le sens où ces classes abstraites et ces interfaces sont comme des contrats qui lient les objets qui les implémentent et les objets qui appellent ces méthodes.

En Python, le module abc permet de simuler ce type d’approche. Le nom de ce module est la contraction de abstract base classes. Ce module fournit une méta-classe appelée ABCMeta qui permet de transformer une classe Python en classe abstraite. Ce module fournit également le décorateur @abstractmethod qui permet de déclarer comme abstraite une méthode, une méthode statique, une méthode de classe ou une propriété. Cela signifie qu’il n’est pas possible de créer une instance d’une classe qui hérite d’une classe abstraite tant que toutes les méthodes abstraites ne sont pas implémentées.

Si nous reprenons notre exemple de la classe Animal. Cette classe déclare une méthode crier() pour laquelle il n’est pas vraiment possible de fournir une implémentation correcte pour un animal. On peut donc considérer que la classe Animal est abstraite en déclarant que sa méta-classe est ABCMeta et déclarer la méthode crier() comme une méthode abstraite.

from abc import ABCMeta, abstractmethod


class Animal(metaclass=ABCMeta):

    @abstractmethod
    def crier(self):
        pass

Toute tentative de créer un objet de type Animal échouera à l’exécution :

>>> a = Animal()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: Can't instantiate abstract class Animal with abstract methods crier

La classe Animal est en quelque sorte devenu un contrat qui indique que toute classe qui en hérite doit fournir une implémentation pour la méthode crier().

class Chien(Animal):

    def crier(self):
        print("whouaf whouaf !")
>>> c = Chien()
>>> c.crier()
whouaf whouaf !