Aller au contenu

Algo & Prog 1 Python : CM séance 06

6. Héritage, gel et modules

6.1. Héritage

6.1.1. Principe

L'héritage consiste à fabriquer une nouvelle classe, appelée classe fille ou classe dérivée, à partir d'une classe existante, appelée classe mère ou classe de base :

  • la fille spécialise la mère ;
  • la mère généralise la fille.
class Plante :              # mère
    pass

class Fleur(Plante) :       # fille de Plante
    pass

>>> issubclass (Fleur, Plante)      # True
>>> f = Fleur()
>>> isinstance (f, Fleur)           # True
>>> isinstance (f, Plante)          # True : f est aussi une Plante

Les méthodes sont héritées par la classe fille :

class Oiseau :
    def chanter(self) :
        print("Cui cui !")

class Pigeon(Oiseau) :
    pass

>>> o = Oiseau()
>>> o.chanter()                     # Cui cui !
>>> p = Pigeon()
>>> p.chanter()                     # Cui cui !

On peut surcharger (redéfinir) les méthodes de la classe fille :

class Oiseau :
    def chanter(self) :
        print("Cui cui !")

class Pigeon(Oiseau) :
    def chanter(self) :             # méthode surchargée
        print("Rhou rhou !")

>>> o = Oiseau()
>>> o.chanter()                     # Cui cui !
>>> p = Pigeon()
>>> p.chanter()                     # Rhou rhou !

Dans une méthode héritée on peut déterminer la classe réelle avec __class__ :

class Véhicule :
    def je_suis_quoi (self) :
        print(self.__class__)

class Moto(Véhicule) :
    pass

>>> v = Véhicule()
>>> v.je_suis_quoi()                # <class '__main__.Véhicule'>
>>> m = Moto()
>>> m.je_suis_quoi()                # <class '__main__.Moto'>

6.1.2. Constructeurs

Le constructeur est hérité par défaut (c'est une méthode) :

class Animal :
    def __init__(self, nom) :
        print("Je m'appelle", nom)

class Chien(Animal) :
    pass

>>> a = Animal("Martin")            # Je m'appelle Martin
>>> c = Chien("Raoul")              # Je m'appelle Raoul

Si la classe fille a son propre constructeur, alors celui de la mère n'est plus appelé. Si besoin on peut l'appeler explicitement :

class Animal :
    def __init__(self, nom) :
        print("Je m'appelle", nom)

class Chien(Animal) :
    def __init__(self, nom) :
        print("Ouah ouah !")
        Animal.__init__(self, nom)  # appel explicite

>>> a = Animal("Martin")            # Je m'appelle Martin
>>> c = Chien("Raoul")              # Ouah ouah ! Je m'appelle Raoul
Remarque : utilisation de super

dans __init__, au lieu de :

Animal.__init__(self, nom)  

on peut écrire :

super(Chien, self).__init__(nom)
super().__init__(nom)               # Raccourci, sans self

Les constructeurs d'une classe mère et d'une classe fille peuvent avoir des paramètres différents et mémoriser d'autres attributs :

class Rectangle :
    def __init__(self, longueur, largeur) :
        self.long = longueur
        self.larg = largeur
    def aire(self) :
        return self.long * self.larg

class Carré(Rectangle) :
    def __init__(self, côté, couleur) :
        self.couleur = couleur
        super().__init__(côté, côté)
        # ou : Rectangle.__init__(self, côté, côté)

>>> r = Rectangle(5,7)
>>> r.aire()                        # 35
>>> c = Carré(4, "rouge")
>>> c.couleur                       # 'rouge'
>>> c.long                          # 4
>>> c.larg                          # 4
>>> c.aire()                        # 16

6.1.3. Héritage multiple

On a vu le cas où la fille n'a qu'une seule mère, c'est l'héritage simple.

La classe fille peut avoir plusieurs classes mère, c'est l'héritage multiple. La syntaxe est :

class Fille (Mere1, Mere2, ...) :
    ...

La fille hérite alors des méthodes de chaque mère.

Il peut y avoir des conflits de méthodes dans l'héritage multiple, c'est-à-dire une même méthode qui apparait plusieurs fois (à éviter...) :

class Chat :
    def crier(self) :
        print("miaou !")

class Chien :
    def crier(self) :
        print("ouah !")

class MyPet(Chien, Chat) :
    pass

>>> p = MyPet()
>>> p.crier()
Ouah !              # la première classe mère gagne

Comme l'héritage peut être simple ou multiple, éventuellement sur plusieurs niveaux, toute classe possède un Method Resolution Order (MRO), que l'on peut consulter :

>>> Pet.__mro__
(<class '__main__.MyPet'>, <class '__main__.Chien'>, <class '__main__.Chat'>,
<class 'object'>)

On voit ici que la dernière classe est <class 'object'> : en effet dans la mécanique interne de Python, toute classe dérive de la classe racine object.

6.2. Gel des attributs

Le mécanisme de surcharge d'instance donne une grande souplesse, mais est aussi une cause de bug dans les programmes :

class Tasse :
    def __init__(self) :
        self.café = 0
        self.thé = 0

>>> tasse = Tasse()
>>> tasse.cafe = 1          # on a maintenant tasse.café et tasse.cafe ...

On peut geler une classe en déclarant les attributs possible dans __slots__ :

class Tasse :
    __slots__ = ['café', 'thé']         # attribut de classe
    def __init__(self) :
        self.café = 0
        self.thé = 0

>>> tasse = Tasse() 
>>> tasse.café = 1      # ok
>>> tasse.cafe = 1      # AttributeError: 'Tasse' object has no attribute 'cafe'
>>> tasse.choco = 1     # AttributeError: 'Tasse' object has no attribute 'choco'

La protection est aussi faite à l'intérieur de la classe :

class Tasse :
    __slots__ = ['café', 'thé']
    def __init__(self) :
        self.café = 0
        self.thé = 0
        self.lait = 0

tasse = Tasse()     # AttributeError: 'Tasse' object has no attribute 'lait'

Comment faire si malgré tout on a besoin de rajouter un attribut ? → par héritage :

class Tasse :
    __slots__ = ['café', 'thé']
    def __init__(self) :
        self.café = 0
        self.thé = 0

class Bol(Tasse) :
    def __init__(self) :
        super().__init__()
        self.lait = 0

>>> bol = Bol()
>>> bol.thé             # 0
>>> bol.lait            # 0
>>> bol.choco = 1

Mais du coup, la classe Bol n'est pas gelée, donc pas protégée ! → on peut la geler aussi :

class Tasse :
    __slots__ = ['café', 'thé']
    def __init__(self) :
        self.café = 0
        self.thé = 0

class Bol(Tasse) :
    __slots__ = ['lait']
    def __init__(self) :
        super().__init__()
        self.lait = 0

>>> bol = Bol()
>>> bol.thé             # 0
>>> bol.lait            # 0
>>> bol.choco = 1       # AttributeError: 'Bol' object has no attribute 'choco'

Comment voir tous les slots ?

dir(bol)                                # ... café thé lait
bol.__slots__                           # ['lait']
bol.__class__                           # <class '__main__.Bol'>
bol.__class__.__bases__                 # (<class '__main__.Tasse'>,)
bol.__class__.__bases__[0]              # <class '__main__.Tasse'>
bol.__class__.__bases__[0].__slots__    # ['café', 'thé']

Peut-on "forcer" des attributs via __dict__ ?

bol.__dict__                    # AttributeError: .. no attribute '__dict__'
bol.__dict__ = {'choco' : 1}    # AttributeError: .. no attribute '__dict__'

Les valeurs des attributs sont directement mémorisés dans la mémoire de l'objet, pas dans un dictionnaire → code plus efficace.

6.3. Les modules

Dès que le code est un peu long, on a intérêt à découper un programme en modules, pour faciliter la lecture et la réutilisation.

Un module = un fichier python.

hello.py
def dire_bonjour() :
    print("Bonjour !")

# Programme principal
dire_bonjour()

Dans le terminal :

$ python3 hello.py
Bonjour !

En programmation orientée objet, on peut choisir de faire un module par classe (comme en java), ou de regrouper dans un module des classes qui vont ensemble : par exemple, un module geometrie.py avec des classes Point, Segment, Polygone.

6.3.1. Import sans exécution

Tout fichier python peut être importé comme module, dans le terminal python comme dans un programme ; le but est d'importer ses déclarations (classes, fonctions, etc) afin de pouvoir les utiliser :

>>> import hello
Bonjour !

Problème : cela exécute tout le code en dehors des déclarations des fonctions et des classes, en particulier du programme principal !

Comment l'éviter ? en utilisant la variable __name__ :

hello.py
def dire_bonjour() :
    print("Bonjour !")

# Programme principal
print('__name__', __name__)
dire_bonjour()

On obtient alors une valeur différente lors de l'exécution dans le terminal :

$ python3 hello.py
__name__ __main__
Bonjour !

par rapport à celle obtenue lors de l'import :

>>> import hello
__name__ hello
Bonjour !
>>> __name__
'__main__'

→ La variable __name__ expose le nom du module qui contient le code exécuté.

On écrit donc toujours le programme principal avec une garde sous cette forme (en bleu) :

hello.py
def dire_bonjour() :
    print("Bonjour !")

# Programme principal
if __name__ == '__main__' :
    dire_bonjour()

On teste dans le terminal :

$ python3 hello.py
Bonjour !               # programme principal exécuté

et avec un import :

>>> import hello        # n'affiche rien, programme principal non exécuté

Remarque : après l'import on peut faire :

>>> help(hello)
>>> help(hello.dire_bonjour)

Et on peut tester en direct les méthodes :

>>> hello.dire_bonjour()
Bonjour !
Remarque :
Si on modifie un module, l'interpréteur python3 n'en tiendra pas compte.
→ il faut fermer et réouvrir python3, puis réimporter le module.

6.3.2. Bytecode et cache

Sur certains systèmes tels que Linux, l'import crée un répertoire __pycache__/ contenant

$ ls -l __pycache__/
-rw-rw-r-- 1 thiel thiel  302 sept. 10 14:21 hello.cpython-38.pyc
$ od -A x -t x1z -v __pycache__/hello.cpython-38.pyc
$ strings __pycache__/hello.cpython-38.pyc

Il s'agit du bytecode compilé du module.

On peut éviter la création des fichiers .pyc avec : python3 -B

$ rm -rf __pycache__
$ python3 -B
>>> import hello
>>> quit()
$ ls

6.3.3. Autres façons d'importer

  • Importer en renommant :

    >>> import hello as foo
    >>> foo.dire_bonjour()
    Bonjour !
    >>> del foo
    
  • Importer dans l'espace de noms courant :

    >>> from hello import *
    >>> dire_bonjour()
    Bonjour !
    

    Inconvénient : conflit de noms, ambiguïté...

  • On peut aussi importer certaines classes ou fonctions :

    >>> from hello import dire_bonjour, autres_fonctions, ...
    

Remarque :

Il ne doit pas y avoir de - dans un nom de module car pour Python un nom de module doit être un identificateur.

Solutions :

  • renommer le fichier avec des _ : nom-module.pynom_module.py
  • ou encore remplacer : import nom-module par : nom_module = __import__('nom-module')

6.3.4. Dépendances circulaires

Python gère les dépendances circulaires :

Module a.py
import b

def exclamer(s) :
    b.afficher(s+'!')
Module b.py
import a

def afficher(s) :
    print(s)

def plop() :
    a.exclamer('plop')

if __name__ == '__main__' :
    plop()

Dans le terminal :

$ python3 b.py
plop!

6.3.5. Package Python

Un package Python est le nom d'un répertoire contenant un fichier __init__.py éventuellement vide, et d'autres modules.

Pour utiliser le package, importer le répertoire. Cela provoque l'exécution du fichier __init__.py ; son rôle peut être d'importer les modules.

→ Un package regroupe des modules et les masque (on ne voit que le package).

Exemple d'un package toto :

toto/truc.py
def bingo() :
    print('Bingo !')
toto/__init__.py
from .truc import *                # ou : from toto.truc import *

Test :

>>> import toto
>>> toto.bingo()
Bingo !

Les modules fournis avec python sont en réalité des packages :

  • math (fonctions mathématiques réelles)

    >>> import math
    >>> math.sqrt(5)
    2.23606797749979
    
  • cmath (fonctions mathématiques complexes)

    >>> import cmath
    >>> cmath.sqrt(5+2j)
    (2.27872385417085+0.4388421169022545j)
    
  • re (expressions régulières)

    >>> import re
    >>> s = "numéro 123"
    >>> p = re.search("^\D+ (?P<num>\d+)", s)
    >>> print(p.group('num'))
    123
    
  • io (entrées-sorties)

  • os (accès au système)
  • sqlite3 (bases de données SQLite)
  • numpy, scipy, matplotlib
  • tkinter, gi (pour PyGTK)
  • ...

Voir https://docs.python.org/3/library/index.html et https://pypi.org/