(rev. 2019-09-17)
Ce support de cours présente brièvement les caractéristiques et avantages de la programmation orientée objet en Python.
Il est un complément et suit les mêmes conventions de notations que le support de cours rédigé par Jean-Daniel Bonjour.
Il est rédigé en français et les parties de code Python sont volontairement écrites en anglais. L’habitude d’écrire du code en anglais offre une meilleure portabilité de celui-ci et une possibilité de le partager et colaborer avec d’autres contributeurs, pas nécessairement francophones plus tard.
Pour des développements qui prennent de l’ampleur, clarifier la structure du code devient une nécessité.
Pour y arriver, il peut être utile de diviser le projet en différentes parties. Cela se fait en séparant le code dans différents modules (et par conséquent différents fichiers).
La programmation objet est également très utile dans cet objectif. Nous allons rassembler dans une classe (outil pour construire un objet) le stockage de données ainsi que les opérations qui s’y rapportent.
En déplaçant des variables et des fonctions dans des classes, il devient plus clair de comprendre leur utilité. La portée de celles-ci est limitée au nécessaire (évitant ainsi des collisions).
En implémentant des objets, nous pouvons également bénéficier gratuitement de n instances d’une même classe. Cela permet de ne pas avoir à dupliquer du code et de cloisonner l’impact des fonctions à l’objet lui-même.
En résumé, la programmation objet évite:
Au contraire, elle offre:
L’espace de nommage est le lieu où chaque objet à disposition est référencé.
Pour chaque emplacement dans le code, il y a plusieurs espaces de nommages actifs.
Le plus profond est celui des fonctions de base (builtins
) du langage. Le second plus profond est celui du module courant.
À l’appel d’une fonction, un nouvel espace de nommage est créé. Les variables utilisées dans cette fonction y seront déposées. Si un objet est appelé et ne se trouve pas dans cet espace là, alors Python ira chercher dans l’espace de nommage parent, et ainsi de suite.
Lorsqu’à l’exécution d’un code on sort d’une fonction, l’espace de nommage dédié à celle-ci est perdu.
Dans le cas d’appels récursifs à une même fonction, chaque appel créera un nouvel espace de nommage. Ceux-ci ne sont donc pas mélangés.
Voici un exemple simple de la syntaxe pour créer une classe.
La classe permet de décrire l’objet qui sera ensuite instancié. On peut y définir une documentation, des variables, des fonctions.
MyFirstClass.a_number
et MyFirstClass.a_function
sont ici des attributs de la classe MyFirstClass
.
MyFirstClass._doc_
l’est également, il retourne la chaine 'Un simple exemple'
.
Les bonnes pratiques recommandent que le nom d’une classe commence par une majuscule (par oposition à une variable ou une fonction). Si l’on souhaite utiliser plusieurs mots, on les appondra en mettant chaque 1ère lettre en majuscule. Cela se nomme la convention UpperCaseCamelCase
class MyFirstClass:
'''A simple example'''
= 15
a_number
def a_function(self):
return 'Hello World'
Pour utiliser une classe, nous en créons une instance avec la simple syntaxe x = NomDeLaClasse()
. Cette instance est stoquée dans la variable x
.
Chaque instance possède son propre état, qui est décrit par les attributs de son espace de nommage.
On compte 2 types d’attributs pour une instance :
attributs de données (data attributes) Il s’agit de variables hébergées dans l’objet. x.r
et x.i
dans l’exemple ci-contre.
attributs méthode Il s’agit de fonctions hébergées dans l’objet. x._init_()
, x.display()
et x.display2x()
.
Tous les attributs sont publics en Python. Par convention, un attribut commençant par le caractère _
doit être traité comme non-public, mais aucune contrainte n’est imposée dans ce sens.
La méthode builtin dir(x)
permet de lister la totalité des attributs de l’instance x
.
Dans cet exemple :
x = ComplexNumber()
crée une nouvelle instance de la classe ComplexNumber
et l’assigne à la variable x
. On peut ensuite faire appel à ses attributs, tels que r
, i
, display
et display2x
.
Chaque méthode d’une classe reçoit comme 1er argument la référence à l’objet lui-même. C’est une convention de systématiquement le nommer self
pour faciliter la lecture.
La méthode _init_()
est automatiquement appelée au moment de la création de l’instance. Comme pour une fonction habituelle, les arguments qui ont une valeur par défaut peuvent être omis lors de l’appel.
Dans une méthode, il est possible d’appeler une autre méthode comme c’est ici le cas avec display2x
.
class ComplexNumber:
'''Basic Complex Number Object'''
def _init_(self, real=0, imag=0):
self.r = real
self.i = imag
def display(self):
print("%d + %di" % (self.r, self.i))
def display2x(self):
self.display()
self.display()
= ComplexNumber()
x = ComplexNumber(15, 2)
y # (0, 0)
x.r, x.i # 15 + 2i
y.display()
x.display2x()# 0 + 0i
# 0 + 0i
dir(x)
# ['_class_', '_delattr_', '_dict_', '_dir_',
# '_doc_', '_eq_', '_format_', '_ge_',
# '_getattribute_', '_gt_', '_hash_',
# '_init_', '_init_subclass_', '_le_', '_lt_',
# '_module_', '_ne_', '_new_', '_reduce_',
# '_reduce_ex_', '_repr_', '_setattr_',
# '_sizeof_', '_str_', '_subclasshook_',
# '_weakref_', 'display', 'display2x', 'i', 'r']
def _init_(self, [args]):
Comme décrit plus haut, cette méthode est invoquée automatiquement lors de la création d’une nouvelle instance d’objet.
Il est possible d’y ajouter autant d’arguments que désiré. Ceux devront ensuite être fournis par l’appelant à la création de l’objet.
class Point():
'''Defines a point in a 3-D environnement'''
def _init_(self, x, y, z):
self.x = x
self.y = y
self.z = z
= Point(1, 2, 3)
p1 = Point(x=5, y=6, z=7) p2
def _del_(self):
Méthode invoquée automatiquement lorsque l’instance est sur le point d’être supprimée. Cela permet de définir des opérations à faire pour garantir la destruction propre de l’objet.
def __repr_(self):
Méthode servant à donner une représentation textuelle de l’objet en énumérant toutes les informations nécessaire à sa recréation. Elle est facultative, et plutôt utilisée pour du débuggage.
Elle ne prend qu’un seul argument : self
et doit retourner une chaîne de caractères.
Lorsqu’elle n’est pas définie, une chaîne générique est retournée à la place, tel que <_main_.Point object at 0x7ff6905e6358>
class Point():
'''Defines a point in a 3-D environnement'''
def _init_(self, x, y, z):
self.x = x
self.y = y
self.z = z
def _repr_(self):
return 'Point(x=%s, y=%s, z=%s)' % \
self.x, self.y, self.z)
(
= Point(1, 2, 3)
p1 _repr_() # 'Point(x=1, y=2, z=3)' p1.
def _str_(self):
Méthode servant à retourner une représentation informelle de l’objet. Elle est invoquée lors d’un appel aux builtins print()
et format()
de Python.
class Point():
'''Defines a point in a 3-D environnement'''
def _init_(self, x, y, z):
self.x = x
self.y = y
self.z = z
def _str_(self):
return '<%s,%s,%s>' % \
self.x, self.y, self.z)
(
= Point(1, 2, 3)
p1 print(p1) # '<1,2,3>'
def _lt_(self, other):
et autres méthodes de comparaisonSix méthodes servant à comparer l’objet courant avec un autre objet. Ils sont automatiquement invoqués lors d’un appel à l’opérateur de comparaison correspondant.
def _lt_(self, other):
–> a < b
def _le_(self, other):
–> a <= b
def _eq_(self, other):
–> a == b
def _ne_(self, other):
–> a != b
def _gt_(self, other):
–> a > b
def _ge_(self, other):
–> a >= b
import math
class Point():
'''Defines a point in a 3-D environnement'''
def _init_(self, x, y, z):
self.x = x
self.y = y
self.z = z
def dist_to_origin(self):
return math.sqrt(self.x**2 + self.y**2 + self.z**2)
def _lt_(self, other):
return self.dist_to_origin() < other.dist_to_origin()
= Point(1, 2, 3)
p1 = Point(2, 3, 4)
p2 < p2 # True p1
Lorsque l’on souhaite écrire une nouvelle classe qui aura un comportement majoritairement identique à une autre déjà existante mais avec quelques spécificités supplémentaires, l’héritage est l’outil idéal à utiliser.
Une classe qui hérite d’une autre classe, va récupérer tous ses attributs (variables, méthodes) sans avoir besoin de les ré-écrire. Puis nous écrivons dans la nouvelle classe les parties qui seront spécifiques.
La bonne pratique prévoit que pour 2 classes, A
et B
qui hérite de A
, A
sera plus générique et B
plus spécifique. On devrait pouvoir dire «B
est un A
».
On met entre parenthèses le nom de la classe dont on souhaite hériter. Dans l’exemple ci-contre, la classe Bird
hérite de la classe Animal
grâce à la mention class Bird(Animal):
La classe parente peut être appelée en appelant super()
.
class Animal():
def _init_(self, name, sound):
self.name = name
self.sound = sound
def speak(self):
if self.sound == '':
print('...')
else:
print(self.sound)
def wake_up(self):
print('Wake up %s' % self.name)
self.speak()
class Bird(Animal):
def _init_(self, name, sound, can_fly=True):
super()._init_(name, sound)
self.can_fly = can_fly
def wake_up(self):
super().wake_up()
if self.can_fly:
print('He flew away!')
= Animal('SuperCat', 'Miaoooow')
my_cat = Bird('Plume', 'Tchip')
my_birdy = Bird('Bipbip', '', False)
my_ostrich
my_cat.wake_up()# Wake up SuperCat
# Miaoooow
my_birdy.wake_up()# Wake up Plume
# Tchip
# He flew away!
my_ostrich.wake_up()# Wake up Bipbip
# ...
Nous connaissons déjà en Python la possibilité d’itérer sur différents objets. En voici quelques exemples :
for i in [1, 2, 3]:
print(i)
for i in range(5):
print(i)
= {'a': 1, 'b': 2, 'c': 3}
d for k, v in d.items():
print('%s -> %s' % (k, v))
for c in 'plein de caractères':
print(c)
for ligne in open('file.txt'):
print(ligne, end='')
Un bout de la mécanique interne d’une itération en Python peut être décomposée ainsi :
= [10, 20, 30]
liste = iter(liste)
it # <list_iterator at 0x7f38481c6358>
it next(it) # 10
next(it) # 20
next(it) # 30
next(it) # StopIteration exception raised
Selon ce modèle, nous pouvons rendre un objet itérable en définissant un attribut _iter_
qui retourne l’objet qui offre la méthode _next_
.
Le cas le plus simple est que l’objet lui-même offre cette méthode _next_
.
Cette méthode devra lancer l’exception «builtin» prévue à cet effet : StopIteration
afin d’interrompre le cycle des itérations.
import random
class Roll3Dices:
'''Iterator for 3 dice roll'''
def _init_(self):
self.i = 0
def _iter_(self):
return self
def _next_(self):
self.i += 1
if self.i > 3:
raise StopIteration
else:
return random.randint(1, 6)
for dice in Roll3Dices():
print(dice)
# 1
# 4
# 6
list(Roll3Dices())
#[5, 6, 6]
list(Roll3Dices())
#[1, 4, 6]
Ceci est une parenthèse qui ne fait pas usage de la notation objet, mais qui fait suite à l’exemple ci-dessus des itérateurs.
Un générateur est un moyen simplifié de créer un itérateur. Il est écrit avec la même syntaxe qu’une fonction dans laquelle on utilise le mot clé yield
à chaque emplacement où une valeur doit être retournée.
À chaque fois que next()
est appelé, cette fonction est rappelée depuis l’emplacement où elle s’était arrêtée la précédente fois. L’état des variables locales est restauré automatiquement.
Ainsi on peut ré-écrire le lanceur de 3 dés avec la simple fonction suivante :
import random
def roll_3_dices():
for i in range(3):
yield random.randint(1, 6)
for dice in roll_3_dices():
print(dice)
# 3
# 1
# 2
list(roll_3_dices())
# [3, 2, 2]
list(roll_3_dices())
# [4, 6, 4]
with
Le code Python suivant est tout simple, mais peut poser problème.
En effet, si une exception est lancée, alors que le fichier est encore ouvert, alors le fichier ne sera pas correctement refermé.
= open('filename', 'w')
f 'Some text\n')
f.write(# Do some dangerous operations
'Some more text\n')
f.write( f.close()
En Python on a vu qu’il existe le mot clé with
. Celui-ci permet de créer des objets en garantissant l’exécution des 2 portions de code autour de celui-ci.
Ceci apporte une solution simple et efficace au code mentionné ci-dessus. Nous garantissons que le fichier sera correctement fermé, même si une exception a lieu dans le code durant l’utilisation de celui-ci.
Toute la partie du code qui est indentée dans le bloc with
sera avec l’objet f
à disposition. Dès que l’exécution sortira de cette zone indentée, alors la fermeture du fichier sera effectuée.
with open('nomDeFichier', 'w') as f:
'Some text\n')
f.write(# Do some dangerous operations
'Some more text\n') f.write(
Python prévoit qu’une classe puisse être utilisée avec ce même mot clé with
.
Pour ce faire, la classe devra implémenter les 2 méthodes suivantes :
_enter_(self)
: Méthode appelée lors de la mise en place de l’objet. Cette méthode doit retourner l’objet en question (qui peut très bien être lui-même self
)._exit_(self, type, value, traceback)
: Méthode appelée lors de la fermeture de l’objet. Les arguments passés (à l’exception de self
) décrivent l’exception qui a causé la sortie du bloc. S’il n’y a pas d’exception mais que c’est une sortie naturelle, alors la valeur de ces arguments sera None
.L’exemple donné ici définit une classe qui permet de faire des requêtes sur un serveur LDAP. Tant que nous sommes dans le bloc with EPFLLdap() as directory:
, la connexion avec le serveur LDAP est conservée. Cet exemple concret évite de devoir établir une nouvelle connexion pour chaque requête.
import ldap
class EPFLLdap(object):
"""
EPFL Ldap connector
"""
def _init_(self,
="ldap://ldap.epfl.ch",
server="o=epfl,c=ch",
base_dn=ldap.SCOPE_SUBTREE):
scopeself.server = server
self.base_dn = base_dn
self.scope = scope
def _enter_(self):
self.l = ldap.initialize(self.server)
return self
def _exit_(self, type, value, traceback):
pass
def read_ldap(self, l_filter, l_attrs):
"""
+ Proceed a request to the LDAP
+ sort entries
(only if "uid" attribute was requested)
+ 1st is main accreditation
+ other accreditations come after, unsorted
"""
= self.l.search_s(
ldap_res =self.base_dn,
base=self.scope,
scope=l_filter,
filterstr=l_attrs
attrlist
)# Return main accreditation first.
# + main's uid attribute has 2 values :
# "username", "username@unit"
# + other's uid attribute has 1 value :
# "username@unit"
return sorted(
ldap_res,=lambda x: len(x[1].get("uid", [])),
key=True
reverse
)
= ["uid", "uniqueIdentifier", "sn",
l_attrs "givenName", "displayName", "mail"]
with EPFLLdap() as directory:
= directory.read_ldap("sn=bancal", l_attrs)
oneUser print(oneUser)
# Do whatever ... even something dangerous
"sn=AutrePersonne", l_attrs) directory.read_ldap(
À titre de dernier exemple, on peut citer la librairie Pyserial qui emploie cette technique pour gérer l’ouverture et la fermeture du port série. Dans le bloc indenté, nous sommes capable de lire et écrire sur ce port série. Lorsque l’exécution sort de ce bloc, l’accès au port série est alors fermé.
Cette librairie peut être installée avec la commande:
pip install pyserial
with serial.Serial('/dev/ttyS1') as ser:
= ser.read() # read 1 byte
one_byte = ser.read(10) # read 10 bytes
ten_bytes = ser.readline() # read bytes until '\n' one_line
Nous allons écrire un code répondant au besoin suivant :
10 sondes de températures sont réparties sur toute une région. Périodiquement notre script va être exécuté pour lire les valeurs de ces sondes toutes les 10 minutes pendant 1 heure, les stocker, puis lancer un traitement automatisé sur ces données. Dans cet exercice, les données seront lues par la fonction readTemp(num)
et le traitement sera simulé par le simple appel à processData(data, lat, long, alt)
.
Pour chaque sonde, on souhaite stocker :
Nous investiguerons 3 approches :
La première approche serait de créer une variable pour chaque information que nous souhaitons mémoriser.
Cette approche apporte beaucoup de redondance et aucune flexibilité. Tout doit être écrit, autant de fois que nécessaire. Le code est très “verbeux” et est sujet à contenir des erreurs qui passeront inaperçues. Il reste lisible à toute petite échelle.
import time
= 46.270656
station1_latitude = 9.001583
station1_longitude = 252
station1_altitude = "admin_station1@example.com"
station1_email = "https://example.com/stations/station1"
station1_url = []
station1_data = 46.362942
station2_latitude = 9.226781
station2_longitude = 569
station2_altitude = "admin_station2@example.com"
station2_email = "https://example.com/stations/station2"
station2_url = []
station2_data # ... Duplicate this for the n stations ! : ((
# Acquire 6x temperature on all stations every 10 min
for i in range(6):
if i != 0:
600)
time.sleep(1))
station1_data.append(readTemp(2))
station2_data.append(readTemp(# ... Duplicate this for the n stations ! : ((
# Process data acquired for every station
processData(
station1_data,
station1_latitude,
station1_longitude,
station1_altitude
)
processData(
station2_data,
station2_latitude,
station2_longitude,
station2_altitude
)# ... Duplicate this for the n stations ! : ((
Cette seconde approche consiste à tout stocker dans une seule variable qui sera une liste de dictionnaires.
S’il nous venait d’avoir davantage de stations, il suffirait alors de les ajouter (append
) à celle-ci. C’est déjà un énorme avantage.
Il est maintenant possible d’itérer sur la liste complète, sans avoir besoin de connaître le nombre total de stations. Toutes les valeurs sont accessibles de façon logique (sans modifier le code source), contrairement à l’exemple ci-dessus où il fallait écrire le numéro de la station dans le nom de la variable. Ici le numéro de la station correspond à la position de celle-ci dans la liste.
Note: en Python les listes commencent à 0
et nous avons choisi de numéroter les stations à partir de 1
. C’est la raison du i+1
.
import time
= []
stations
stations.append({'latitude' : 46.270656,
'longitude' : 9.001583,
'altitude' : 252,
'email' : "admin_station1@example.com",
'url' : "https://example.com/stations/station1",
'data' : [],
})
stations.append({'latitude' : 46.362942,
'longitude' : 9.226781,
'altitude' : 569,
'email' : "admin_station2@example.com",
'url' : "https://example.com/stations/station2",
'data' : [],
})# Continue for all the stations
# Acquire 6x temperature on all stations every 10 min
for i in range(6):
if i != 0:
600)
time.sleep(for i, station in enumerate(stations, start=1):
'data'].append(readTemp(i))
station[
# Process data acquired for every station
for i in range(len(stations)):
processData('data'],
stations[i]['latitude'],
stations[i]['longitude'],
stations[i]['altitude'],
stations[i][ )
Comme toutes les stations se ressemblent (elles ont les mêmes attributs), nous allons créer une classe Station
.
Ensuite chaque station sera une instance de cette classe, stockée dans une liste.
Le code final est beaucoup plus clair. L’objet Station est défini de façon très explicite. Si des attributs devaient être ajoutés par la suite ou que les traitements devaient être changés, nous ne le ferons qu’à un seul endroit et cela s’appliquera à toutes les stations.
Nous avons séparé le code “métier” relatif à la station du code qui gère l’ensemble des stations.
import time
class Station():
'''
Weather station
'''
def _init_(self, coord, alt, email, url):
self.latitude = coord[0]
self.longitude = coord[1]
self.altitude = alt
self.email = email
self.url = url
self.data = []
def add_data(self, data):
self.data.append(data)
def process_data(self):
processData(self.data,
self.latitude,
self.longitude,
self.altitude,
)
= []
stations
stations.append(Station(=[46.270656, 9.001583],
coord=252,
alt='admin_station1@example.com',
email='https://example.com/stations/station1',
url
))
stations.append(Station(=[46.362942, 9.226781],
coord=569,
alt='admin_station2@example.com',
email='https://example.com/stations/station2',
url
))# Continue for all the stations
# Acquire 6x temperature on all stations every 10 min
for i in range(6):
if i != 0:
600)
time.sleep(for i, station in enumerate(stations, start=1):
station.add_data(readTemp(i))
# Process data acquired for every station
for station in stations:
station.process_data()
Samuel Bancal, EPFL, ENAC-IT (2019)