Les concepts de la POO avec Python
Classe
1. Déclaration
Une classe est la définition d’un concept métier, elle contient des attributs (des valeurs) et des méthodes (des fonctions).
En Python, le nom d’une classe ne peut pas commencer par un chiffre ou un symbole de ponctuation, ni être un mot-clé du langage comme while ou if. À part ces contraintes, Python est très permissif sur le nom des classes et variables en autorisant même les caractères accentués. Cette pratique est cependant fortement déconseillée à cause des problèmes de compatibilité entre différents systèmes.
Voici l’implémentation d’une classe en Python ne possédant aucun membre : ni attribut ni méthode.
class MaClasse:
# Pour l'instant, la classe est déclarée vide,
# d'où l'utilisation du mot-clé 'pass'.
pass
Le mot-clé class est suivi du nom de la classe. Le corps de la classe est indenté comme le serait le corps d’une fonction. Dans un corps de classe, il est possible de définir :
-
des fonctions (qui deviendront des méthodes de la classe) ;
-
des variables (qui deviendront des attributs de la classe) ;
-
des classes imbriquées, internes à la classe principale.
Les méthodes et les attributs seront présentés dans les sections suivantes éponymes.
Il est possible d’organiser le code de façon encore plus précise grâce à l’imbrication de classes. Tout comme il est possible, voire conseillé, de répartir les classes d’une application dans plusieurs fichiers (qu’on appelle « modules » en Python), il peut être bien plus lisible et logique de déclarer certaines classes dans une classe « hôte ». La déclaration d’une classe imbriquée dans une autre ressemble à ceci :
# Classe contenante, déclarée normalement.
class Voiture:
# Classes contenues, déclarées normalement aussi,
# mais dans le corps de la classe contenante.
class Moteur: pass ...
Héritage
1. Construction
L’héritage est le mécanisme par lequel une classe possède les membres d’une autre classe, afin d’éventuellement les spécialiser ou d’en ajouter de nouveaux. La syntaxe Python est la suivante :
# Définition de la classe de base.
class Forme:
x = 0
y = 0
# Définition de la classe dérivée.
class Cercle(Forme):
# Le corps de la classe dérivée est vide.
pass
c = Cercle()
print(c.x, c.y)
>>> 0 0
La classe Cercle hérite de la classe Forme et récupère donc les deux attributs x et y qui représentent les coordonnées de son centre. Cependant, ce centre est propre à l’instance de la forme, et ces attributs devraient être initialisés dans le constructeur, comme vu dans les sections précédentes. Lorsqu’une classe dérivée est instanciée, son constructeur appelle le constructeur de la classe de base. Dans le cas d’un constructeur par défaut, comme c’est ici le cas pour la classe Cercle, cette tâche est effectuée automatiquement.
Mais en cas de réimplémentation du constructeur, il ne faut pas oublier de faire explicitement cet appel, sous peine de comportement inattendu :
# Définition de la classe de base.
class Forme:
# Constructeur de la classe de base.
def __init__(self):
print("Init Forme")
# Initialisation des attributs d'instance.
self.x = 0
self.y = 0
# Définition de la classe dérivée.
class Cercle(Forme):
# Constructeur de la classe dérivée, qui n'appelle pas
# le constructeur de la classe de base.
def __init__(self):
print("Init Cercle")
c = Cercle()
>>> __init__Cercle # Constructeur de Cercle appelé,
# mais pas celui de Forme.
print(c.x, c.y) ...
Agrégation et composition
1. Agrégation
L’agrégation est une relation contenu-contenant dite « faible », dans le sens où le contenu survit à la destruction de son contenant. Pour reprendre le schéma UML du chapitre Les concepts de la POO, une voiture comprend de zéro à cinq roues (n’oublions pas la roue de secours), et une roue est associée à zéro ou une voiture.
Une version simple de cette relation pourrait être implémentée ainsi :
class Voiture:
def __init__(self):
# Déclaration d'une liste censée
# contenir les roues de la voiture.
self.roues = []
class Roue:
def __init__(self):
# Déclaration d'un attribut contenant une référence
# vers la voiture à laquelle est rattachée la roue.
self.voiture = None
# Création d'une liste avec quatre instances de Roue.
roues = [Roue() for i in range(4)]
# Création d'une instance de Voiture.
voiture = Voiture()
# Assignation de la liste de roues à la Voiture.
voiture.roues=roues
# À chaque roue de la liste...
for roue in roues:
# ... on associe la voiture.
roue.voiture = voiture
print("Instance de voiture : {}".format(voiture))
>>> Instance de voiture : <__main__.Voiture object at 0x105829ac8>
print("Les 4 roues de la voiture {} sont {}".format(voiture,
voiture.roues))
>>> Les 4 roues de la voiture <__main__.Voiture object at
0x105829ac8> sont [<__main__.Roue object at 0x1058299e8>,
<__main__.Roue object at 0x105829a20>, <__main__.Roue object at
0x105829a58>, <__main__.Roue object at 0x105829a90>]
print("La voiture de la roue {} est {}".format(roues[0],
roues[0].voiture))
>>> La voiture de la roue <__main__.Roue object at 0x1058299e8>
est <__main__.Voiture object...
Exception
1. Levée
Même si le système d’exception n’est pas à proprement parler une composante de la programmation orientée objet, il est tout de même bien présent dans les langages orientés objet, notamment en Python.
Il existe plusieurs façons de gérer les erreurs dans un programme (ouverture d’un fichier inexistant, division par zéro, accès à un membre inconnu, etc.). Une fonction peut retourner un certain entier (-1 par exemple) pour signifier une erreur. Un inconvénient est que si la fonction est déjà censée retourner une valeur logique, renvoyer cette valeur « technique » d’erreur viendrait perturber la sémantique de la fonction. Par exemple, on s’attendrait intuitivement à ce qu’une fonction division() renvoie une valeur flottante, non un booléen.
De plus, si jamais la fonction appelante ne sait pas quoi faire de cette valeur, elle est obligée de la propager elle-même à celle qui l’a appelée, et ainsi de suite jusqu’à ce que le code d’erreur soit utilisé pour résoudre le problème, ou du moins le signaler (via un message à l’utilisateur ou une inscription dans un journal de log) :
import sys
def division(a, b):
if b == 0:
return False
return a / b
def test(a, b):
resultat = division(a, b)
if resultat == False:
return False
return resultat + 42
resultat = test(55, int(sys.stdin.readline()))
if not resultat:
print("Erreur: division par zéro")
else:
print("Le résultat est {}".format(resultat))
Dans cet exemple, on demande à l’utilisateur d’entrer un nombre qui va subir des opérations arithmétiques. La fonction division() renvoie False si le diviseur est 0. Puisque test() ajoute 42 au résultat de la division, il est nécessaire de le tester afin d’éviter d’additionner False et 42, ce qui provoquerait une erreur. Ainsi, on teste la valeur de retour de division(). Or test() n’a aucun...
Concepts de la POO non natifs
1. Classe abstraite
Python ne propose pas de mécanisme natif pour déclarer des classes abstraites car le duck typing élude le problème. En effet, en POO, une classe abstraite est utile afin de définir un contrat commun entre plusieurs classes (les classes dérivées concrètes) et ceux qui vont les utiliser. Le terme de « contrat » désigne les comportements et données qu’une classe garantit de posséder et de produire. Techniquement, il s’agit de ses attributs et méthodes, mais la notion de contrat ajoute une dimension de responsabilité.
C’est la classe Forme qui déclare l’existence des méthodes de calcul de périmètre et d’aire, ainsi que des attributs de coordonnées (x, y). C’est donc grâce à elle que le module de dessin pourra placer les formes concrètes (carré, cercle, etc.) sur un plan.
Avec le duck typing, la déclaration de ce contrat n’a plus aucun sens : on traite les instances comme des formes en leur demandant leurs coordonnées pour les placer. Si jamais une de ces instances n’est pas une forme, mais possède ces attributs de coordonnées, il n’y a aucun problème, le traitement peut se poursuivre. Il a cancané : c’est un canard. Une classe s’est fait passer pour une Forme alors qu’elle ne connaissait pas le contrat établi par Forme. Donc, au final, Forme n’a plus vraiment d’utilité puisque d’autres classes peuvent remplir le contrat qu’elle déclare sans pour autant en hériter.
Oui, mais...
Lorsque l’on utilise un attribut x ou y d’un objet, on s’attend à un certain type de retour, à une certaine signification métier. Là où Forme certifie que x et y sont les coordonnées d’une forme géométrique, une instance de caryotype (l’ensemble des chromosomes d’une cellule en biologie) fournira une toute autre interprétation...
Énumération
Les énumérations sont le meilleur moyen de représenter un ensemble fini d’éléments, comme les jours de la semaine ou les couleurs de l’arc-en-ciel. Il existe plusieurs solutions pour les implémenter. L’une des plus simples consiste à créer une classe et à déclarer des attributs de classe en leur assignant des entiers de valeurs différentes :
class Couleur:
ROUGE = 1
VERT = 2
BLEU = 3
tomate = Couleur.ROUGE
salade = Couleur.VERT
Cette méthode a tout de même quelques inconvénients. D’une part, la valeur d’énumération n’est pas proprement typée : il s’agit juste d’un entier :
print(tomate)
>>> 1
print(salade)
>>> 2
Cela peut poser des difficultés de débogage (que représente la valeur 1 ? Est-ce réellement un entier ou une énumération ?). Cela peut également inciter à de mauvaises utilisations arithmétiques, alors qu’une énumération n’est clairement pas faite pour ça :
print(Couleur.ROUGE + Couleur.VERT == Couleur.BLEU)
>>> True
D’autre part, les valeurs d’énumération étant...
Duck typing
Le principe du duck typing consiste à traiter comme un canard n’importe quelle entité qui cancane, nage et vole. Même si ce n’est pas un canard. De ce fait, lorsqu’on lit du code écrit en Python, le type des objets manipulés ne peut être déduit qu’à partir de l’utilisation qu’on en fait.
Or, dans certains cas d’utilisation, il peut être tentant d’effectuer certains traitements en fonction du type de la variable manipulée. Python offre d’ailleurs la fonction native isinstance(), qui retourne True si l’objet donné en premier paramètre est une instance de la classe donnée en deuxième paramètre ou une de ses sous-classes.
Par exemple :
for animal in soigneur.animaux :
if isinstance(animal, Serpent) :
soigneur.nourrir(animal, new Insecte())
elif isinstance(animal, Manchot) :
soigneur.nourrir(animal, new Poisson())
L’utilisation de isinstance() est compréhensible : pour éviter de nourrir un animal avec de la nourriture qui ne lui correspond pas, on vérifie quel est le type de l’animal et on adapte la nourriture en conséquence. Cependant, cette technique va à l’encontre de la philosophie Python, En effet...