Programmation Objet avec Python

par Samuel Bancal, EPFL-ENAC-IT, © Creative Commons BY-SA

(rev. 2019-09-17)

1 Introduction

1.1 Avant-propos

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.

1.2 Pourquoi programmer en objet?

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:

2 Le Namespace

2.1 L’espace de nommage (namespace)

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.

3 Les Classes

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'''
    a_number = 15

    def a_function(self):
        return 'Hello World'

3.1 Les instances

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 :

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 :

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()

x = ComplexNumber()
y = ComplexNumber(15, 2)
x.r, x.i  # (0, 0)
y.display()  # 15 + 2i
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']

3.2 Méthodes particulières

3.2.1 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

p1 = Point(1, 2, 3)
p2 = Point(x=5, y=6, z=7)

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

3.2.3 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)

p1 = Point(1, 2, 3)
p1._repr_()  # 'Point(x=1, y=2, z=3)'

3.2.4 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)

p1 = Point(1, 2, 3)
print(p1)  # '<1,2,3>'

3.2.5 def _lt_(self, other): et autres méthodes de comparaison

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

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()

p1 = Point(1, 2, 3)
p2 = Point(2, 3, 4)
p1 < p2  # True

3.3 Héritage

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!')

my_cat = Animal('SuperCat', 'Miaoooow')
my_birdy = Bird('Plume', 'Tchip')
my_ostrich = Bird('Bipbip', '', False)

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

4 Exemples spécifiques Python

4.1 Itérateurs

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)
d = {'a': 1, 'b': 2, 'c': 3}
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 :

liste = [10, 20, 30]
it = iter(liste)
it  # <list_iterator at 0x7f38481c6358>
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]

4.2 Générateurs

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]

4.3 Les classe compatibles avec le mot clé 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é.

f = open('filename', 'w')
f.write('Some text\n')
# Do some dangerous operations
f.write('Some more text\n')
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.

  1. la mise en place de l’objet
  2. la fermeture propre de l’objet

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:
    f.write('Some text\n')
    # Do some dangerous operations
    f.write('Some more text\n')

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 :

  1. _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).
  2. _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,
      server="ldap://ldap.epfl.ch",
      base_dn="o=epfl,c=ch",
      scope=ldap.SCOPE_SUBTREE):
        self.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
        """
        ldap_res = self.l.search_s(
            base=self.base_dn,
            scope=self.scope,
            filterstr=l_filter,
            attrlist=l_attrs
        )
        # 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,
            key=lambda x: len(x[1].get("uid", [])),
            reverse=True
        )

l_attrs = ["uid", "uniqueIdentifier", "sn",
           "givenName", "displayName", "mail"]
with EPFLLdap() as directory:
    oneUser = directory.read_ldap("sn=bancal", l_attrs)
    print(oneUser)
    # Do whatever ... even something dangerous
    directory.read_ldap("sn=AutrePersonne", l_attrs)

À 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

https://pyserial.readthedocs.io/en/latest/shortintro.html

with serial.Serial('/dev/ttyS1') as ser:
    one_byte = ser.read()     # read 1 byte
    ten_bytes = ser.read(10)  # read 10 bytes
    one_line = ser.readline() # read bytes until '\n'

5 Exemple pratique

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 :

  1. avec des variables indépendantes
  2. avec des listes et des dictionnaires
  3. avec des objets

5.1 Avec des variables indépendantes

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

station1_latitude = 46.270656
station1_longitude = 9.001583
station1_altitude = 252
station1_email = "admin_station1@example.com"
station1_url = "https://example.com/stations/station1"
station1_data = []
station2_latitude = 46.362942
station2_longitude = 9.226781
station2_altitude = 569
station2_email = "admin_station2@example.com"
station2_url = "https://example.com/stations/station2"
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:
        time.sleep(600)
    station1_data.append(readTemp(1))
    station2_data.append(readTemp(2))
    # ... 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 !  : ((

5.2 Avec des listes et des dictionnaires

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:
        time.sleep(600)
    for i, station in enumerate(stations, start=1):
        station['data'].append(readTemp(i))

# Process data acquired for every station
for i in range(len(stations)):
    processData(
        stations[i]['data'],
        stations[i]['latitude'],
        stations[i]['longitude'],
        stations[i]['altitude'],
    )

5.3 Avec des objets

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(
    coord=[46.270656, 9.001583],
    alt=252,
    email='admin_station1@example.com',
    url='https://example.com/stations/station1',
))
stations.append(Station(
    coord=[46.362942, 9.226781],
    alt=569,
    email='admin_station2@example.com',
    url='https://example.com/stations/station2',
))
# Continue for all the stations

# Acquire 6x temperature on all stations every 10 min
for i in range(6):
    if i != 0:
        time.sleep(600)
    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()

6 References

  1. https://docs.python.org/3/tutorial/classes.html

 


cc-by-sa Samuel Bancal, EPFL, ENAC-IT (2019)