PROJET AUTOBLOG


Sam et Max

source: Sam et Max

⇐ retour index

Le guide ultime et définitif sur la programmation orientée objet en Python à l’usage des débutants qui sont rassurés par les textes détaillés qui prennent le temps de tout expliquer. Partie 2.

vendredi 18 janvier 2013 à 10:01

Prérequis pour cette partie :

Rappel

On créé des objets en instanciant des classes. Les classes sont des plans décrivant les objets, et notamment déclarant leurs méthodes (le comportement) et leurs attributs (les données) :

class ArticleDeSamEtMax:
 
    def __init__(self, titre):
 
        self.titre = titre
 
 
>>> article = ArticleDeSamEtMax('Votre Python aime les pip')
>>> print article.titre
Votre Python aime les pip'

Les méthodes nommées __methode__ (avec deux underscores de chaque côté) sont appelées automatiquement dans certaines conditions. __init__ est appelée automatiquement après la création de l’objet, et on s’en sert pour créer l’état de départ de l’objet (initialiser).

La chose la plus difficile à comprendre au début est self, l’objet en cours. Pour nous aider un peu, utilisons la fonction id():

>>> id("bip")
22114176
>>> id(13.2)
22417496
>>> id({})
20801584
>>> id(article)
21427120

id() retourne un identifiant unique pour chaque objet. Imaginez le comme “l’adresse en mémoire” d’un l’objet.

Maintenant créons une méthode qui identifie un peu self :

class ArticleDeSamEtMax:
 
    def __init__(self, titre):
        self.titre = titre
 
    def print_self(self):
 
        print self.titre
        print self
        print id(self)
 
>>> article1 = ArticleDeSamEtMax("La théorie de la salle de bain")
>>> article2 = ArticleDeSamEtMax("Mieux de fesse que de face")

Si j’affiche le titre et les id de l’article 1, ça donne ça :

>>> print article1.titre
La théorie de la salle de bain
>>> print article1
<__main__.ArticleDeSamEtMax instance at 0x1635488>
>>> print id(article1)
23286920

Et regardez le print_self :

>>> article1.print_self()
La théorie de la salle de bain
<__main__.ArticleDeSamEtMax instance at 0x1635488>
23286920

C’est exactement la même chose ! self EST article1 puisque self est l’objet en cours.

Pareil pour l’article2:

>>> print article2.titre
Mieux de fesse que de face
>>> print article2
<__main__.ArticleDeSamEtMax instance at 0x16355a8>
>>> print id(article2)
23287208
>>> article2.print_self()
Mieux de fesse que de face
<__main__.ArticleDeSamEtMax instance at 0x16355a8>
23287208

C’est cela la notion de l’objet en cours. Python passe automatiquement l’objet à lui-même, de telle sorte que chaque méthode ait une référence à lui-même pour pouvoir lire et modifier ses propres attributs.

Maintenant on en fait quoi ?

La POO a pour principal attrait de permettre de proposer une belle API, c’est à dire de créer du code réutilisable et facile à manipuler. Comme je vous le disais précément, il n’y a rien qu’on puisse faire en POO qu’on ne puisse faire autrement. On va surtout l’utiliser pour donner un style au code.

En fait, on fait de la POO pour celui qui va utiliser votre code plus tard.

Amusons-nous un peu avec le Web online de l’Internet

Imaginez, vous voulez proposer une bibliothèque qui permettent de récupérer les 100 dernières questions postées à propos de Python sur le site Stackoverflow. Heureusement Stackoverflow est très ouvert, et propose même d’avoir accès à leur base de données (avec quelques jours de retard) assez directement depuis data.stackexchange.com.

Premièrement, il faut créer un export de la base de données de Stackoverflow. Ca ne fait pas partie de la POO, alors je vous le donne tout fait.

Cet export peut être récupéré au format CSV, du coup on pourra télécharger nos données sous cette forme :

CreationDate,Post Link
"2013-01-13 12:42:41","{
  ""title"": ""How to convert float point to hex representation according IEEE754 in Python?"",
  ""id"": 14303571
}"
"2013-01-13 12:40:12","{
  ""title"": ""catch exceptions that caused by connection error in ftplib"",
  ""id"": 14303548yt
}"
"2013-01-13 12:35:47","{
  ""title"": ""variable type checking"",
  ""id"": 14303498
}"
...

Pour lire les données et les récupérer, pas besoin de POO, un petit script Python procédural fait très bien le taff :

import csv
import urllib2
 
from io import StringIO
 
# l'URL du CSV
URL = "http://data.stackexchange.com/StackOverflow/csv/109782"
 
# on télécharge les données, on les décode et on les enrobe dans
# StringIO pour qu'elles soient lisible de la même manière qu'un fichier
# malgré le fait qu'elles soient juste en mémoire
csv_data = StringIO(urllib2.urlopen(URL).read(100000).decode('utf8'))
 
 
# on utilise le module CSV pour lire notre "fichier" CSV
# DictReader retourne une liste de dictionnaires, un pour chaque entrée du "fichier"
for question in csv.DictReader(csv_data):
 
    # et on a accès aux données de chaque question
    print question['CreationDate'] # afficher la date de création
    print question['Post Link'] # afficher le titre et l'id de la question

C’est bien, c’est facile et ça marche :

2013-01-13 12:42:41
{
  "title": "How to convert float point to hex representation according IEEE754 in Python?",
  "id": 14303571
}
2013-01-13 12:40:12
{
  "title": "catch exceptions that caused by connection error in ftplib",
  "id": 14303548
}
...

Astuce au passage : si vous ouvrez le CSV issu de data.stackexchange.com, vous noterez qu’il y a plein de petits détails qui rendent ce format pas toujours facile à parser. Plutôt que d’y aller comme un bourrin avec des split(), nous utilisons donc le module csv pour récupérer chanque entrée du fichier comme un dictionnaire qui aura la structure:

{'nom_de_colonne': 'valeur_pour_cette_ligne', ...}

On est cependant loin d’avoir une bibliothèque réutilisable. On peut commencer à faire une fonction réutilisable :

#!/usr/bin/env python
# -*- coding: utf-8 -*-
 
 
import csv
import json
import urllib2
 
from datetime import datetime
from io import StringIO
 
 
DATA_SOURCE_URL = "http://data.stackexchange.com/StackOverflow/csv/109782"
QUESTION_URL = "http://stackoverflow.com/questions/{id}"
 
 
def download_questions(url=DATA_SOURCE_URL):
 
    csv_data = StringIO(urllib2.urlopen(url).read(100000).decode('utf8'))
 
    for question in csv.DictReader(csv_data):
 
        # on transforme la chaîne date en objet datetime
        question['CreationDate'] = datetime.strptime(question['CreationDate'],
                                                     '%Y-%m-%d %H:%M:%S')
 
        # le deuxième champ est au format JSON
        # alors on le transforme en objet Python
        question['Post Link'] = json.loads(question['Post Link'])
 
        # on ajoute l'url de la question générée à partir de l'ID
        question['Post Link']['url'] = QUESTION_URL.format(id=question['Post Link']['id'])
 
        yield question

On pourrait l’importer et faire :

for question in download_questions():
    # affiche le titre et l'url
    print "{title} : {url}".format(**question['Post Link'])

On obtient l’affichage suivant :

How to convert float point to hex representation according IEEE754 in Python? : http://stackoverflow.com/questions/14303571
catch exceptions that caused by connection error in ftplib : http://stackoverflow.com/questions/14303548
variable type checking : http://stackoverflow.com/questions/14303498
Convert python to a pseudo code : http://stackoverflow.com/questions/14303484
How to insert annotation into pdf with Python : http://stackoverflow.com/questions/14303418

Et ce n’est pas une mauvaise façon de faire. On peut également faire des interfaces très sympas en programmation fonctionnelle, mais ce n’est pas le but de l’article.

Voyons comment on ferait ça en POO

Si on utilise uniquement les outils qu’on a vue jusqu’ici, voici ce qu’on obtient :

import csv
import json
import urllib2
 
from datetime import datetime
from io import StringIO
 
 
DATA_SOURCE_URL = "http://data.stackexchange.com/StackOverflow/csv/109782"
QUESTION_URL = "http://stackoverflow.com/questions/{id}"
 
 
# au crée un objet question dans lequel on va mettre les données de notre dico
 
class Question:
 
    def __init__(self, id, title, creation_date):
 
        # self est le fameux "object en cours", on lui attache les attributs
        self.id = id
        self.title = title
        self.creation_date = creation_date
 
    # on va générer ces valeurs à la lecture, pas à l'écriture comme
    # tout à l'heure
    def get_creation_date(self):
        return datetime.strptime(self.creation_date, '%Y-%m-%d %H:%M:%S')
 
    def get_url(self):
        return QUESTION_URL.format(title=self.title, id=self.id)
 
 
# maintenant notre fonction nous retourne des objets Question
 
def download_questions(url=DATA_SOURCE_URL):
 
        csv_data = StringIO(urllib2.urlopen(url).read(100000).decode('utf8'))
 
        for question in csv.DictReader(csv_data):
 
            question['Post Link'] = json.loads(question['Post Link'])
 
            # au lieu de retourner des dictionaires, on retourne
            # des objets Question
            yield Question(creation_date=question['CreationDate'],
                           id=question['Post Link']['id'],
                           title=question['Post Link']['title'])

Donc on a encore une fonction, sauf que cette fois elle ne sort plus des dicos, mais des objets Question. A première vue ça n’a pas l’air très intéressant. Ca fait la même chose, mais c’est plus long.

Par contre on a déjà un petit changement du côté de l’utilisation, et c’est ça qu’on vise :

for question in download_questions():
    # affiche le titre et l'url
    print "{title} : {url}".format(title=question.title, url=question.get_url())

Au lieu d’avoir un dictionnaire qui pourrait contenir n’importe quoi, on a un objet question, avec un titre, et la possibilité de construire l’URL.

Petit détour par les attributs de classe

La POO, ce n’est pas juste faire des objets, c’est aussi les habiller.

Dans notre cas on a moitié fonction, moitié classe. On a des variables globales qui trainent. C’est pas terrible. On pourrait arranger ça avec des attributs de classe. Retour à un peu de théorie.

Un attribut de classe est un attribut qui appartient, non pas à l’objet, mais à la classe.

Par exemple:

class UnObjetQuiPassaitParLa:
 
    un_attribut_de_classe = 'meme valeur pour pour tous les objets'
 
    def __init__(self):
 
        self.attribut_d_objet = "valable seulement pour l'objet en cours"

un_attribut_de_classe est accessible depuis UnObjetQuiPassaitParLa, sans créer d’instance, donc sans avoir à faire UnObjetQuiPassaitParLa().

>>> print UnObjetQuiPassaitParLa.un_attribut_de_classe
meme valeur pour tous les objets et la classe
>>> print UnObjetQuiPassaitParLa.attribut_d_objet
 
Traceback (most recent call last):
  File "<pyshell#1>", line 11, in <module>
    print UnObjetQuiPassaitParLa.attribut_d_objet
AttributeError: class UnObjetQuiPassaitParLa has no attribute 'attribut_d_objet'

Par contre attribut_d_objet n’est pas accessible si on a pas d’instance.

Une instance a accès aux deux:

>>> print instance.un_attribut_de_classe
meme valeur pour tous les objets et la classe
>>> print instance.attribut_d_objet
valable seulement pour l'objet en cours

Les attributs de classe ont d’autres propriétés intéressantes, mais on va s’arrêter là pour le moment.

On peut aussi créer des méthodes de classe. C’est le même principe:

>>> class UnObjetQuiPassaitParLa:
...
...     @classmethod # <- tranforme la méthode en méthode de classe
...     def methode_de_classe(cls):
...
...         print "yeah baby"
...
... UnObjetQuiPassaitParLa.methode_de_classe()
yeah baby

On utilise ici un décorateur, qui dit que la méthode est une méthode de classe, donc accessible sans créer aucune instance.

Notez que la convention de nommage change : le premier argument n’est plus nommé self mais cls. C’est parce que le premier argument sera “la classe en cours” (UnObjetQuiPassaitParLa) et non plus “l’object en cours” (une instance de UnObjetQuiPassaitParLa).

Bon, tout ça c’est très flou pour le moment. Appliquons.

Little Boxes on the hill side

L’interêt de ça, c’est que ça va nous permettre de regrouper tout ce qui a un rapport avec notre objet Question dans la classe.

#!/usr/bin/env python
# -*- coding: utf-8 -*-
 
 
import csv
import json
import urllib2
 
from datetime import datetime
from io import StringIO
 
 
class Question:
 
    # ces constantes sont maintenant des attributs de classe
    DATA_SOURCE_URL = "http://data.stackexchange.com/StackOverflow/csv/109782"
    QUESTION_URL = "http://stackoverflow.com/questions/{id}"
 
    def __init__(self, id, title, creation_date):
 
        # self est le fameux "object en cours", on lui attache les attributs
        self.id = id
        self.title = title
        self.creation_date = creation_date
 
    def get_creation_date(self):
        return datetime.strptime(self.creation_date, '%Y-%m-%d %H:%M:%S')
 
    def get_url(self):
        return self.QUESTION_URL.format(id=self.id)
 
    # la fonction qui fabrique tous les objets Question est maintenant
    # à l'intérieur de la class Question, en tant que méthode de classe
    @classmethod
    def query(cls, url=DATA_SOURCE_URL):
 
            csv_data = StringIO(urllib2.urlopen(url).read(100000).decode('utf8'))
 
            for question in csv.DictReader(csv_data):
 
                question['Post Link'] = json.loads(question['Post Link'])
 
                yield Question(creation_date=question['CreationDate'],
                               id=question['Post Link']['id'],
                               title=question['Post Link']['title'])

C’est ce qu’on appelle l’encapsulation. On fout tous les trucs qui ont un rapport entre eux dans la même boîte, et on laisse la boîte s’occuper de comment ça marche en interne.

Et là on commence à avoir une API très mignone :

print "Questions from : {}".format(Question.DATA_SOURCE_URL)
 
for question in Question.query():
    # affiche le titre et l'url
    print "{title} : {url}".format(title=question.title, url=question.get_url())

Tout part de l’objet Question. Si on cherche quelque chose liée à l’objet Question, on doit faire Question.< un_truc >. On peut expérimenter dans le shell avec la complétion du code. En regardant ce bout de code, pas besoin de savoir comment Question marche pour savoir ce que ça fait. C’est assez explicite.

On peut encore faire un peu mieux.

Les properties

Les propriétés, ou properties dans la langue de Cameron Diaz, sont des outils qui déguisent des méthodes pour les faire passer pour des attributs. L’exemple le plus simple est le suivant :

>>> class UnObjetQuiPassaitParLa:
...
...     def __init__(self, valeur):
...         self.valeur = valeur
...
...
...     def get_valeur_au_carre(self):
...
...         return self.valeur * self.valeur
...
>>> objet = UnObjetQuiPassaitParLa(2)
>>> print objet.get_valeur_au_carre()
4

C’est moche et verbeux. Avec une prioperty, on dit à Python “fait comme si cette méthode était un banal attribut” :

>>> class UnObjetQuiPassaitParLa:
...
...     def __init__(self, valeur):
...         self.valeur = valeur
...
...     @property # <- tranforme la méthode en propriété
...     def carre(self):
...
...         return self.valeur * self.valeur
...
>>> objet = UnObjetQuiPassaitParLa(2)
>>> print objet.carre
4

Le décorateur @property transforme la méthode carre() et on peut l’utiliser sans parenthèse.

Appliquons cela à notre exemple :

#!/usr/bin/env python
# -*- coding: utf-8 -*-
 
 
import csv
import json
import urllib2
 
from datetime import datetime
from io import StringIO
 
 
class Question:
 
    # ces constantes sont maintenant des attributs de classe
    DATA_SOURCE_URL = "http://data.stackexchange.com/StackOverflow/csv/109782"
    QUESTION_URL = "http://stackoverflow.com/questions/{id}"
 
    def __init__(self, id, title, creation_date):
 
        # self est le fameux "object en cours", on lui attache les attributs
        self.id = id
        self.title = title
        self.creation_date = creation_date
 
    # on donne aux méthodes des noms plus simples, et on applique
    # le décorateur @property dessus
 
    @property
    def created(self):
        return datetime.strptime(self.creation_date, '%Y-%m-%d %H:%M:%S')
 
    @property
    def url(self):
        return self.QUESTION_URL.format(id=self.id)
 
    # la fonction qui fabrique tous les objets Question est maintenant
    # à l'intérieur de la class Question, en tant que méthode de classe
    @classmethod
    def query(cls, url=DATA_SOURCE_URL):
 
            csv_data = StringIO(urllib2.urlopen(url).read(100000).decode('utf8'))
 
            for question in csv.DictReader(csv_data):
 
                question['Post Link'] = json.loads(question['Post Link'])
 
                yield Question(creation_date=question['CreationDate'],
                               id=question['Post Link']['id'],
                               title=question['Post Link']['title'])

Ca ne change pas grand chose, mais on peut virer le très moche get_url().

print "Questions from : {}".format(Question.DATA_SOURCE_URL)
 
for question in Question.query():
    # affiche le titre et l'url
    print "{title} : {url}".format(title=question.title, url=question.url)

Conclusion très provisoire

On a amélioré notre compréhension de l’usage de la POO dans le monde réel. Le code se complexifie côté bibliothèque. Par contre côté utilisateur final, on passe de ça :

 
from question_lib import download_questions, DATA_SOURCE_URL
 
print "Questions from : {}".format(DATA_SOURCE_URL)
 
for question in download_questions():
    print "{title} : {url}".format(**question['Post Link'])

A :

 
from question_lib import Question
 
print "Questions from : {}".format(Question.DATA_SOURCE_URL)
 
for question in Question.query():
    # affiche le titre et l'url
    print "{title} : {url}".format(title=question.title, url=question.url)

Le style change, la manière dont sont exposées les données n’est pas la même. Sur cette exemple simple, la complexité ajoutée pour le résultat fait que le jeu peut ne pas en valoir la chandelle. Sur des codes très gros, cela peut changer la vie.

path.py est un très bel exemple de cela : l’API est belle et simple (beaucoup plus que ce que propose la lib standard pour faire la même chose). Cela valait la complexité du code source pour obtenir ce résultat.

Un autre avantage de l’encapsulation, c’est que même si demain Stackoverflow change son format de données (ce n’est plus un CSV mais un XML par exemple), on a juste a apdater la classe. Le code qui utilise classe, lui, n’aura pas besoin de changer. Une bonne encapsulation (qu’on peut faire sans POO, mais la POO est spécialisée pour ça), aide les utilisateurs finaux de votre lib car ils savent que leur code ne devrait pas trop changer.

On en va bien entendu pas s’arrêter là, car on peut faire beaucoup mieux. Ce n’était qu’un avant goût pédagoqique. Sautez à la partie 3.