Classes, objets et usages avancés¶
- Structurer un programme
- Notions de programmation objet
- Classes en Python
- Suite du micro-projet
- Usages avancés
Contenu sous licence CC BY-SA 4.0, fortement inspiré de https://fitzinger.gitlab.io/formation-python
Structurer un programme¶
Pourquoi faire ?¶
Le fichier exos/meteo_json.py du micro-projet est un script linéaire qui n'est pas réutilisable directement depuis un autre programe.
%matplotlib inline
%config InlineBackend.figure_format = 'retina' # Pour augmenter la résolution
%pycat exos/meteo_json.py
Si on veut le réutiliser, il faut le structurer en fonctions que l'on peut appeler depuis un programme principal.
# %load exos/meteo_json_func.py
#!/usr/bin/env python3
"""
Process a weather forecast json file to plot the time evolution of temperature
of a given day in a given city
"""
import urllib.request
import json
import matplotlib.pyplot as plt
import matplotlib.image as mpimg
import numpy as np
import ssl
from io import BytesIO
context = ssl._create_unverified_context()
def get_json_from_name(city_name):
jsonfile_url = "https://www.prevision-meteo.ch/services/json/"\
+ city_name
f = urllib.request.urlopen(jsonfile_url, context=context) # open url
return json.loads(f.read())
def get_city_from_user():
"""From user input, return a meteo json dictionary corresponding to city"""
while True: # Infinite loop to handle city name input
city_name = input("Entrer la ville :\n")
city_json = get_json_from_name(city_name)
if 'errors' in city_json:
print("{} n'existe pas dans la base. Essayez un autre nom."
.format(city_name))
else:
return city_json
def plot_day_tempe(city_json, day_key):
"""Plot Temperature vs hour from a json dictionary for a given day_key"""
city_name = city_json['city_info']['name']
day = city_json[day_key]
day_hd = day['hourly_data'] # point to hourly data
# Get tempe = [[h1, T1], [h2, T2], ...] list
# where h1 is the time in hour and T2 is the temperature in deg. C
tempe = []
for hour, data in day_hd.items():
# get first part of time in "00H00" format and remove "H00"
# get temperature at 2m above ground 'TMP2m'
tempe.append([int(hour[:-3]), data['TMP2m']])
# Alternative form using list comprehension:
# tempe = [[int(hour[:-3]), data['TMP2m']] for hour, data in day_hd.iteritems()]
tempe.sort() # Sort temperatures according to the hour of day
t = np.array(tempe).transpose() # Transpose list of (hour, tempe)
# Plot T = T(hour)
fig = plt.figure() # initialise figure
title = "{} {} à {}".format(day['day_long'], day['date'], city_name)
fig.suptitle(title, fontsize=14, fontweight='bold')
ax = fig.add_subplot(111) # initialise a plot area
fig.subplots_adjust(top=0.85)
ax.set_title('Evolution horaire')
ax.set_xlabel('Temps [h]')
ax.set_ylabel('Température [deg. C]')
ax.plot(t[0], t[1]) # plot t[1] (tempe) as a function of t[0] (hour)
# Add meteo icon to plot
# Open weather icon
response = urllib.request.urlopen(day['icon_big'], context=context)
icon = BytesIO(response.read())
axicon = fig.add_axes([0.8, 0.8, 0.15, 0.15])
img = mpimg.imread(icon) # initialise image
axicon.set_xticks([]) # Remove axes ticks
axicon.set_yticks([])
axicon.imshow(img) # trigger the image show
plt.show() # trigger the figure show
def get_day(city_json):
"""From user input, return the day key in json dictonary"""
days = {day: data['day_long'] for day, data in city_json.items()
if day[:8] == "fcst_day"} # Create {'fcst_day_#': week-day}
question = "Choisir le jour :\n"
days_list = [] # Build ['fcst_day_#', week-day] sorted list
# This i-loop is required because "days" is not sorted:
for i in range(5):
key = "fcst_day_{}".format(i)
days_list.append([key, days[key]])
question = question + "{}) {}\n".format(i, days[key])
while True:
try:
choice = int(input(question)) # Prompt user for day index
return days_list[choice][0] # Return 'fcst_day_#'
except:
print("Entrée non valide.")
if __name__ == '__main__':
# This block is not executed if this file is imported as a module
city_json = get_city_from_user() # get json dict from user input
day_key = get_day(city_json) # get day key from user input
plot_day_tempe(city_json, day_key) # plot day temperature evolution
--------------------------------------------------------------------------- StdinNotImplementedError Traceback (most recent call last) Cell In[2], line 106 101 print("Entrée non valide.") 104 if __name__ == '__main__': 105 # This block is not executed if this file is imported as a module --> 106 city_json = get_city_from_user() # get json dict from user input 107 day_key = get_day(city_json) # get day key from user input 108 plot_day_tempe(city_json, day_key) # plot day temperature evolution Cell In[2], line 30, in get_city_from_user() 27 """From user input, return a meteo json dictionary corresponding to city""" 29 while True: # Infinite loop to handle city name input ---> 30 city_name = input("Entrer la ville :\n") 31 city_json = get_json_from_name(city_name) 32 if 'errors' in city_json: File /opt/conda/lib/python3.11/site-packages/ipykernel/kernelbase.py:1201, in Kernel.raw_input(self, prompt) 1199 if not self._allow_stdin: 1200 msg = "raw_input was called, but this frontend does not support input requests." -> 1201 raise StdinNotImplementedError(msg) 1202 return self._input_request( 1203 str(prompt), 1204 self._parent_ident["shell"], 1205 self.get_parent("shell"), 1206 password=False, 1207 ) StdinNotImplementedError: raw_input was called, but this frontend does not support input requests.
Si on définit une nouvelle fonction pour récupérer le dictionnaire de la ville :
import sys
def get_city(city_name):
"""return a meteo json dictionary corresponding to city_name"""
jsonfile_url = "https://www.prevision-meteo.ch/services/json/"\
+ city_name
f = urllib.request.urlopen(jsonfile_url) # open url
city_json = json.load(f)
if 'errors' in city_json:
msg = "La ville n'est pas dans la base"
sys.exit(msg)
else:
return city_json
On peut maintenant appeler les fonctions get_city()
et plot_day_tempe()
séparément du reste du script :
city_json = get_city("Toulouse")
plot_day_tempe(city_json, 'fcst_day_1')
Notions de programmation orientée objet¶
La programmation orientée objet est un paradigme de programmation qui introduit en particulier les concepts suivants :
- encapsulation
- classes
- objets
- héritage
Encapsulation¶
L'encapsulation est la manière d'associer les données et les méthodes qui s'y appliquent dans un même type appelé objet.
Classes¶
- la classe est le code qui définit l'objet par ses attributs et ses méthodes
- les méthodes sont des fonctions qui agissent sur les attributs de l'objet.
Objets¶
Les objets sont les instances de cette classe. Ils sont créés en appelant la classe comme on appelle une fonction :
a = MaClasse(45, True, 'toto')
b = MaClasse(78, False, 'titi')
- L'objet
a
est une instance de la classeMaClasse
. - L'objet
b
est une autre instance deMaClasse
. 45, True, 'toto'
sont les arguments passés à l'initialiseur.
Héritage¶
- l'héritage permet de créer des classes dérivées qui partagent du code et des données de leur(s) parent(s)
- les classes enfants héritent des attributs et des méthodes de leur(s) parent(s)
- Il est possible d'hériter de plusieurs classes. On parle alors d'héritage multiple.
L'héritage évite la duplication de code ! Le code commun se trouve en un seul exemplaire dans la ou les classes parentes.
class Vecteur:
commentaire = "Cette classe est top"
def __init__(self, x, y, z):
self.coord = [x, y, z]
print(Vecteur)
<class '__main__.Vecteur'>
Instancier un objet¶
- Pour instancier une classe, on utilise la notation d'appel de fonction
- Cet appel renvoie une instance de la classe
v1 = Vecteur(1., 2., 3.)
v2 = Vecteur(0., 0., 0.)
print(type(v1))
<class '__main__.Vecteur'>
v1
etv2
sont deux instances de la classeVecteur
self
:- est le premier argument de toutes les fonctions de la classe
- reçoit de manière automatique une référence à l'instance de l'objet
- est nommé ainsi par convention
- est omis lors de l'appel
- dans la définition de la classe les attributs sont référencés par la notation
self.un_attribut
- la méthode
__init__()
- c'est la méthode qui est appelée automatiquement lors de l'instanciation
- elle est appelée initialiseur
- elle reçoit les paramètres qui permettent l'initialisation de l'objet
- Ici,
commentaire
est un attribut de la classe : il ne dépend pas de son instanciation.
print(Vecteur.commentaire)
Cette classe est top
self.coord
est un attribut de l'objet qui est construit lors de l'instanciation
print(v1.coord, v2.coord)
print('commentaire' in dir(Vecteur)) # Rappel : dir() renvoie la liste des attributs de l'objet
print('commentaire' in dir(v1))
print('coord' in dir(Vecteur))
print('coord' in dir(v1))
[1.0, 2.0, 3.0] [0.0, 0.0, 0.0] True True False True
Surcharge d'opérateur¶
La surcharge d'opérateur consiste à redéfinir des fonctions existantes de la classe.
Par exemple, on souhaite représenter le contenu de l'objet en surchargeant la méthode __repr__()
.
La fonction print()
appliquée à l'instance fait un appel à __repr__()
. Si __repr__()
n'est pas définie dans la classe, print
renvoie ce type de sortie :
v1 = Vecteur(1., 2., 3.)
print(v1)
<__main__.Vecteur object at 0x7f1bd019a610>
Ce comportement de __repr__()
correspond en réalité à la méthode de la classe de base dont hérite Vecteur
.
On surcharge la méthode __repr__()
en écrivant sa définition dans la classe :
class Vecteur:
commentaire = "Cette classe est top"
def __init__(self, x, y, z):
self.coord = [x, y, z]
def __repr__(self):
"""On surcharge l'opérateur __repr__ en renvoyant
une chaîne de caractère"""
return ''.join(f'({c})\n' for c in self.coord)
La fonction print()
renvoie alors :
v1 = Vecteur(1., 2., 3.)
print(v1)
(1.0) (2.0) (3.0)
Opérateurs arithmétiques¶
Les opérateurs arithmétiques sont définis si la méthode associée est implémentée. Par exemple, pour utiliser l'opérateur +
entre deux objets, la classe doit définir la méthode __add__()
.
def __add__(self, v):
"""
self: l'instance de Vecteur comme membre de gauche de l'opérateur `+`
v: un autre objet Vecteur comme membre de droite
Retourne une nouvelle instance Vecteur
correspondant à la somme vectorielle `self + v`
"""
return Vecteur(self.coord[0] + v.coord[0],
self.coord[1] + v.coord[1],
self.coord[2] + v.coord[2])
On définit la fonction existante __add__()
comme nouvelle méthode de Vecteur
:
Vecteur.__add__ = __add__ # cette syntaxe est permise par le caractère dynamique de Python
On peut alors utiliser l'opérateur +
:
v1 = Vecteur(1., 2., 3.)
v2 = Vecteur(3., 2., 1.)
print(v1 + v2)
(4.0) (4.0) (4.0)
Exercice : Implémenter la méthode __mul__()
afin de définir le produit vectoriel entre
$$v_1 = \begin{pmatrix} x_1\\ y_1\\ z_1\\ \end{pmatrix} \quad \textrm{et} \quad v_2 = \begin{pmatrix} x_2\\ y_2\\ z_2\\ \end{pmatrix} $$
Rappel : le produit vectoriel $v_1\wedge v_2$ s'écrit $$ v_1\wedge v_2 = { \begin{pmatrix} y_{1}z_{2}-z_{1}y_{2}\\ z_{1}x_{2}-x_{1}z_{2}\\ x_{1}y_{2}-y_{1}x_{2} \end{pmatrix}} $$
def __mul__(self, v):
"""Retourne le produit vectoriel de self par v"""
pass
# votre code ici
Vecteur.__mul__ = __mul__
v1 = Vecteur(1., 0., 0.)
v2 = Vecteur(0., 1., 0.)
print(v1 * v2)
None
def __mul__(self, v):
"""Produit vectoriel"""
x1, y1, z1 = self.coord
x2, y2, z2 = v.coord
xp = y1*z2 - z1*y2
yp = z1*x2 - x1*z2
zp = x1*y2 - y1*x2
return Vecteur(xp, yp, zp)
Vecteur.__mul__ = __mul__
v1 = Vecteur(1., 0., 0.)
v2 = Vecteur(0., 1., 0.)
v1 * v2
(0.0) (0.0) (1.0)
Héritage¶
Pour que la classe Enfant
hérite des classes Parent1
et Parent2
, il faut passer ses dernières en arguments de la définition de Enfant
:
class Enfant(Parent1, Parent2):
pass
Note : En dehors de cet exemple, nous n'abordons pas ici les classes ayant plusieurs parents. Vous trouverez la documentation sur le sujet de l'héritage multiple dans ce tutoriel.
Illustration du mécanisme d'héritage¶
Nous reprenons la classe Vecteur
précédente dont nous dérivons la classe Force
:
class Force(Vecteur):
def __init__(self, x, y, z, x_a, y_a, z_a):
"""
x_a, y_a, z_a: coordonnées du point d'application de la force
"""
super().__init__(x, y, z)
self.coord_app = [x_a, y_a, z_a]
f = Force(1., 0., 0., 1., -1., 0.)
- En redéfinissant la fonction
__init__()
, nous surchargeons la méthode__init__()
de la classeVecteur
. - La fonction
super()
renvoie un objet qui permet d'accéder aux méthodes de la classe parente.
On dit que Force
hérite de la classe Vecteur
, ce qui signifie que toutes les méthodes définies dans Vecteur
sont définies dans Force
:
g = Force(0., 1., 0., 1., -1., 0.)
print(f + g)
print(f * g)
(1.0) (1.0) (0.0) (0.0) (0.0) (1.0)
On voit ici que Force
a hérité de __repr__()
, __add__()
et __mul__()
.
Intérêt de l'héritage¶
L'intérêt de dériver une classe enfant est :
- soit de définir une nouvelle classe par héritage multiple
- soit de redéfinir les méthodes de la classe parente en les surchargeant
- soit d'étendre la classe parente en définissant de nouvelles méthodes
- soit une combinaison de tout ça !
... tout en évitant de dupliquer le code des classes parentes.
Dans notre exemple, la nouvelle classe Force
nous permet de définir la nouvelle méthode moment()
qui retourne le moment vectoriel de la force par rapport à un point pivot :
def moment(self, x, y, z):
"""
Retourne le moment du vecteur Force par rapport au point pivot (x, y, z)
"""
# r: Vecteur reliant le point pivot au point d'application
r = Vecteur(self.coord_app[0] - x,
self.coord_app[1] - y,
self.coord_app[2] - z)
# Le moment est défini par le produit vectoriel
return r * self
Force.moment = moment
moment()
est une méthode propre à Force
: Vecteur
n'en est pas munie.
force = Force(1., 1., 0., 1., -1., 0.)
force.moment(1., 0., 0.)
(-0.0) (0.0) (1.0)
Exercice sur les classes¶
On veut superposer les courbes heure-température de plusieurs villes sur le même graphe. Pour ce faire, il faut :
- Définir une classe
City
qui dispose d'une méthode qui retourne les tableaux 1D hour et temperature pour un jour donné. - Instancier cette classe pour construire autant d'objets City que nécessaire
- Appeler une fonction qui admet comme arguments les objets City ainsi que le numéro du jour
Pour vous simplifier le travail, nous avons écrit l'ébauche d'un script python dans exos/meteo_city.py. En l'état, ce script s'exécute mais ne fait rien. Editez-le dans Spyder et écrivez le code nécessaire pour le rendre fonctionnel là où les commentaires vous y invitent.
Lorsque votre implantation sera terminée, la cellule ci-dessous devra s'exécuter correctement.
%matplotlib inline
from exos.meteo_city import City, plot_day_temperature
toulouse = City('Toulouse')
paris = City('Paris')
plot_day_temperature(toulouse, paris, day_number=3)
No artists with labels found to put in legend. Note that artists whose label start with an underscore are ignored when legend() is called with no argument.
# Solution
%matplotlib inline
from exos.correction.meteo_city import City, plot_day_temperature
toulouse = City('Toulouse')
paris = City('Paris')
stras = City('Strasbourg')
plot_day_temperature(toulouse, paris, stras, day_number=3)
Remarque : intérêt des docstrings¶
from exos.correction import meteo_city
help(meteo_city)
Help on module exos.correction.meteo_city in exos.correction: NAME exos.correction.meteo_city - Process weather forecast json files to plot the time evolution of temperature CLASSES builtins.object City class City(builtins.object) | City(city_name) | | Loads a json dictionary downloaded from city name | and provide a method to search for day temperature | | Methods defined here: | | __init__(self, city_name) | Loads city json dict | | get_temperature(self, day_key) | return hour and temperature as numpy arrays for given day_key | | ---------------------------------------------------------------------- | Data descriptors defined here: | | __dict__ | dictionary for instance variables (if defined) | | __weakref__ | list of weak references to the object (if defined) FUNCTIONS plot_day_temperature(*cities, day_number=0) Plot temperature vs hour for an arbitrary number of cities at given day_number (0: today, 1: tomorrow, etc.) DATA URL_PREFIX = 'https://www.prevision-meteo.ch/services/json/' FILE /builds/mm2act/cours-python/notebooks/exos/correction/meteo_city.py
Exercice avec argparse
¶
Dans un nouveau fichier python :
- importer le fichier
exos/correction/meteo_city.py
comme un module - ajoutez la gestion des arguments en ligne de commande avec
argparse
(cf. Gestion des arguments)
La solution est dans le fichier exos/correction/meteo_argparse.py qui est exécutable en ligne de commande :
# Pour obtenir l'aide de la commande :
%run exos/correction/meteo_argparse.py -h
usage: meteo_argparse.py [-h] [-d INTEGER] [STRING ...] Process some integers. positional arguments: STRING city for which to get the weather forecast options: -h, --help show this help message and exit -d INTEGER, --day INTEGER day for the weather forecast (0 to 4)
<Figure size 640x480 with 0 Axes>
# Lorsqu'on a compris l'usage de la commande à partir de l'aide :
%run exos/correction/meteo_argparse.py -d 0 Toulouse Strasbourg Nice
<Figure size 640x480 with 0 Axes>
Exercice sur l'héritage¶
Toujours dans l'objectif de superposer des courbes de températures, on veut pouvoir charger également des données définies par leurs coordonnées géographiques.
En effet, le site www.prevision-meteo.ch supporte les requêtes sous la forme
http://www.prevision-meteo.ch/services/json/lat=45.32lng=10
où lat=45.32lng=10
désigne la latitude et la longitude.
Pour ce faire, à partir de la classe City
, on va dériver la classe Location
qui sera instanciée de la façon suivante :
trou_perdu = Location(lat=45.32, lng=10)
- À partir du fichier exos/correction/meteo_city.py, écrivez la classe dérivée
Location
. - À partir de la classe
Location
, dérivez la classeHere
qui récupère les coordonnées géographique de la connexion courantes avec le paquetgeocoder
:
import geocoder
g = geocoder.ip('me')
print("lat: {}, lon: {}".format(g.latlng[0], g.latlng[1]))
Pas si vite ! Êtes-vous sûr ? Vraiment ?
Alors allez voir une proposition de solution dans exos/correction/meteo_heritage.py et le script d'exécution en ligne de commande correspondant exos/correction/meteo_argparse_heritage.py.
Usages avancés¶
- expressions lambda
- tests unitaires
- installer des paquets
- environnements virtuels
Les expressions lambda¶
Elles permettent de définir très concisement des fonctions anonymes (sans nom, contrairement aux fonctions définis avec def
).
# Création d'une fonction que l'on assigne à une variable
func = lambda arg1, ...: expr
# Appel de fonction par la variable
func(...)
ce qui est équivalent à :
def _noname(arg1, ...):
return expr
func = _noname
del _noname
func(...)
En plus court !
Elles sont utilisées par exemple, pour passer des petits bouts de code en paramètre à d'autres fonctions :
L1 = list('AlpHabetIZation')
L2 = L1[:]
print(L1)
L1.sort(key=lambda x: x.lower()) # Ce tri respecte l'ordre alphabétique
L2.sort() # Ce tri ne suit que l'ordre ASCII
print(L1)
print(L2)
['A', 'l', 'p', 'H', 'a', 'b', 'e', 't', 'I', 'Z', 'a', 't', 'i', 'o', 'n'] ['A', 'a', 'a', 'b', 'e', 'H', 'I', 'i', 'l', 'n', 'o', 'p', 't', 't', 'Z'] ['A', 'H', 'I', 'Z', 'a', 'a', 'b', 'e', 'i', 'l', 'n', 'o', 'p', 't', 't']
Plus d'informations sur les expressions lambda dans ce tutoriel
.
Les tests unitaires¶
Python permet d'écrire nativement des tests unitaires grâce au module unittest
de sa librairie standard.
- exos/vieux.py :
- C'est un exemple d'utilisation, sous la forme d'un module importable
- Il définit une fonction
vieux(age)
qui retourne une valeur booléenne indiquant si l'âge donné est au-dessus d'un seuil (une constante du module) - Cette fonction lève des exceptions si le paramètre donné n'est pas valide
- exos/tunitaires.py est un module contenant le code qui permet de tester la fonction
vieux.vieux()
%run exos/tunitaires.py -v
test_vieux (__main__.WidgetTestCase.test_vieux) ... ok test_vieux_raise_type (__main__.WidgetTestCase.test_vieux_raise_type) ... ok test_vieux_raise_value (__main__.WidgetTestCase.test_vieux_raise_value) ... ok ---------------------------------------------------------------------- Ran 3 tests in 0.003s OK
- Il existe une autre philosophie de tests intégrés à la documentation interne : le module
doctest
. - Il est possible de combiner ces solutions de testing/documentation.
- En pratique dans des projets plus importants, les tests sont agencés en une suite de tests. L'exécution des suites de tests est effectuée grâce à des pilotes comme pytest ou nose. Ces pilotes permettent, entre autres, le lancement de multiples tests en parallèle ainsi que des rapports de couverture de code.
Exercice¶
Ajoutez un module de tests unitaires pour le projet météo.
Installer des paquets¶
Pip¶
Pour installer des modules additionnels disponibles sur le Python Package Index, vous disposez du gestionnaire de paquet pip
pip install pytest
Supposons que votre exécutable python se trouve à l'emplacement :
/repertoire/d/installation/bin/python
Cette commande fera une installation dans le répertoire suivant (exemple pour Python 3.8) :
/repertoire/d/installation/lib/python3.8/site-packages
Si ce répertoire est un répertoire système, l'installation échouera car elle nécessite les droits administrateurs.
Conseil¶
Ne pas installer de paquets additionnels dans la version de python de votre système d'exploitation. Préférez :
- soit l'installation dans le compte utilisateur (dans le répertoire
~/.local/lib/python3.8/site-packages
) avec l'option--user
- soit l'installation dans un environnement virtuel (cf. section suivante).
Environnements virtuels¶
Afin d'isoler les installations de paquets python dans des environnements séparés, on dispose du mécanisme venv
.
venv
s'utilise en ligne de commande dans le terminal :
$ python3.8 -m venv venv_py38
$ source venv_py38/bin/activate
(venv_py38) $
Vous êtes alors dans un environnement où vous pouvez installer des paquets dans le répertoire venv_py38/
indépendamment du système et des autres environnements virtuels :
(venv_py38) $ pip install pytest
Remarques¶
venv
(bibliothèque standard) est un sous-ensemble devirtualenv
(paquet externe) et n'en contient pas toutes les fonctionnalitésconda
fournit également un utilitaire de gestion des environnements virtuels avecconda env
.