Prérequis : fonctions en Python , programmation orientée objet (pour les exceptions personnalisées)
À l’issue de ce chapitre, vous saurez :
- utiliser les messages d’erreur de Python pour localiser et diagnostiquer un bug ;
- appliquer différentes techniques de débogage :
print, assertions, débogueur (pdb/breakpoint) ; - gérer les exceptions avec
try/except/finallyet lever des exceptions avecraise; - écrire des tests avec
doctest,unittestoupytest; - identifier les causes typiques de bugs (typage, effets de bord, débordements, flottants) ;
- mesurer et optimiser les performances d’un programme (chronométrage, profilage).
Nom des variables, spécification, documentation
Un code simple, clair et lisible sera beaucoup plus facile à déboguer : choisir des noms de variables significatifs, spécifier et documenter les fonctions, privilégier un découpage en plusieurs fonctions simples plutôt que de gros blocs de codes…
Annotations de types
Python permet d’annoter les paramètres et la valeur de retour d’une fonction avec le type attendu :
def moyenne(notes: list[float]) -> float:
return sum(notes) / len(notes)
L’interpréteur n’en tient pas compte (aucune vérification à l’exécution), mais ces annotations documentent la fonction, sont lues par l’éditeur (complétion, signalement d’un appel avec un mauvais type) et par des outils de vérification comme mypy. Elles complètent la docstring et les assertions : la docstring dit ce que fait la fonction, l’annotation sur quels types, l’assertion vérifie une propriété à l’exécution.
Outils de mise au point et débogage
Informations données par l’éditeur
Code couleur
Les éditeurs utilisent des codes couleurs (mots réservés en couleur, …). En observant les couleurs d’un script, on peut voir que l’on n’a pas refermé une chaine de caractère, mal tapé une instruction, utiliser une fonction prédéfinie comme nom de variable (min par exemple)…
Message d’erreur
Le message d’erreur donne le type d’erreur et la ligne où l’erreur est apparue. Ce n’est pas forcément la ligne fautive. L’erreur peut venir d’une parenthèse absente à la ligne précédente, d’une erreur à tout autre endroit du code entrainant qu’une variable n’a pas le comportement attendu.
Quelques messages d’erreur :
SyntaxError: invalid syntax→ Syntaxe pas correcte : mot mal orthographié, symbole manquant, …IndexError: list index out of range→ Débordement dans une liste : la liste compte par exemple 4 élément et on essaie d’accéder au 5ème.TypeError: 'int' object is not callable→ Il y a des parenthèses après une variable du typeint; un objet « callable » est une fonction.TypeError: 'float' object is not subscriptable→ Il y a des crochets après une variable du type float ; un objet « subscriptable » est un conteneur (une liste, une chaine de caractères…).
Affichage
La méthode la plus classique de débogage, toujours efficace, consiste à rajouter des affichages dans son
code, en insérant une ou plusieurs instructions print provisoires pour voir les valeurs des variables à des
points critiques.
Assertions
Une assertion va interrompre l’exécution du code si la condition n’est pas vérifiée. On peut utiliser des assertions pour vérifier :
- des préconditions vérifiées par les variables avant leur traitement par la fonction, par exemple qu’une variable est bien supérieure à une autre, qu’une liste ou une chaine de caractères n’est pas vide, qu’une variable est bien du bon type …
- des postconditions vérifiées avant le
returnpour s’assurer que le traitement est conforme aux attentes, le fait qu’une fonction renvoie un résultat du bon type, positif, non vide …
Syntaxe en Python : assert condition ou assert condition, message d’erreur
Exemples :
assert type(message) is strassert longueur > largeur, "La longueur n’est pas plus grande que la largeur !"
Les exceptions
Toutes les erreurs sont en fait des exceptions qui héritent de la même classe mère Exception.
Lorsque Python lève une exception, le programme est stoppé immédiatement et un message d’erreur est affiché pour prévenir l’utilisateur de la cause du problème.
Utiliser try/except
Cependant parfois, il est plus intéressant de “capturer” cette exception de manière à informer l’utilisateur du problème et lui proposer une alternative, plutôt que de stopper brutalement le programme.
Pour cela on utilisera les mot-clefs try et except :
tryva débuter un bloc d’instructions à risque, susceptible de générer une erreurexceptva intercepter l’erreur pour éviter l’arrêt brutal du programme et proposer un traitement du problème, souvent un affichage
Le modèle est celui-ci :
try:
# Code à risque
except Exception as erreur:
# Traitement de l'erreur (affichage...)
Par exemple, le programme qui calcule l’inverse d’un nombre donnerait :
try:
x = int(input("Saisir x : "))
print("1/x =", 1 / x)
except Exception as erreur:
print("Erreur :", erreur)
Différenciation
Si l’on souhaite différencier le traitement des erreurs, il faut ajouter plusieurs
blocs except, typiquement un par type d’erreur.
try:
x = int(input("Saisir x : "))
print("1/x =", 1 / x)
except ValueError as erreur:
print("Erreur de saisie :", erreur)
except ZeroDivisionError as erreur:
print("Impossible de diviser par zéro :", erreur)
Et quoi qu’il arrive…
On peut également ajouter un bloc d’instructions qui sera exécuté quoiqu’il arrive :
erreur ou comportement normal. Pour cela on ajoutera à la fin le mot clef finally:
try:
x = int(input("Saisir x : "))
print("1/x =", 1 / x)
except ValueError as erreur:
print("Erreur de saisie :", erreur)
except ZeroDivisionError as erreur:
print("Impossible de diviser par zéro :", erreur)
finally:
print("Merci d'avoir utiliser ce calculateur")
Lever soi même une Exception
Quand on écrit son propre module Python avec ses fonctions, ses classes, ses méthodes, on a souvent besoin de lever nous même des exceptions sans attendre l’interpréteur Python.
À l’aide du mot-clef raise, on peut lever une exception à tout moment.
x = int(input("Saisir x : "))
if x == 13:
raise ValueError("Désolé, je suis superstitieux.")
print("1/x =", 1 / x)
Celle-ci sera interceptée par un bloc try / except ou pas. Il est également possible de
définir ses propres exceptions personnalisées en la rattachant à la classe Exception via
l’héritage.
class SuperstitionError(Exception):
pass
x = int(input("Saisir x : "))
if x == 13:
raise SuperstitionError("Désolé, je suis superstitieux.")
print("1/x =", 1 / x)
Le débogueur Python (ou débugueur)
L’insertion de la ligne import pdb; pdb.set_trace() (ou breakpoint() à partir de la version 3.7) dans un
code mettra l’exécution du code en pause au niveau de cette ligne et entrera dans le mode débogage.
On peut alors consulter les valeurs des variables dans l’explorateur de variables de l’éditeur ou dans la
console en tapant p suivi du nom de la variable, par exemple p n pour la valeur de la variable n.
On peut utiliser pp (pretty print) pour afficher plus lisiblement les collections d’objets.
Les commandes du débogueur sont à l’adresse https://docs.python.org/fr/3/library/pdb.html
.
Citons principalement :
n(ext)exécute la ligne en cours, et renvoie de suite le résultat d’une fonction appelées(tep)est identique à next mais va entrer à l’intérieur d’une fonction appeléer(eturn)continue l’exécution jusqu’au return de la fonction en coursc(ontinue)l’exécution jusqu’à la fin du script ou jusqu’au prochain point d’arrêt!en début de ligne permet d’entrer des instructions en Pythonh(elp)affiche l’aide du débogueur
Ce débogueur est intégré dans la plupart des éditeurs.
Causes typiques de bugs
Problèmes liés au typage
En Python, si a est la liste [1, 2, 3], l’expression a * 2 renvoie [1, 2, 3, 1, 2, 3].
Si a est le tableau numpy [1, 2, 3], l’expression a * 2 renvoie le tableau numpy [2, 4, 6].
En C, après l’exécution du code suivant, a sera égal à 3. On peut perdre la partie décimale par erreur.
int a;
float b = 3.45;
a = b;
Un dépassement de capacité lors d’une conversion de flottant en entier a causé l’explosion de la fusée Ariane 5 en 1996 !
En C, la variable b définie ci-dessous est négative.
int a = 2e9;
int b = a + 5e8;
Effets de bord non désirés
On parle d’effet de bord lorsqu’une fonction modifie un état extérieur à son environnement local. Par exemple, une fonction agissant sur une liste définie en dehors de la fonction crée un effet de bord. Il est souvent difficile de suivre les conséquences de ces modifications, ce qui est source de bugs. Les effets de bord n’existent pas en programmation fonctionnelle.
Débordements dans les tableaux
On essaie d’accéder à un élément d’un tableau (ou liste) vide, ou au dixième élément d’un tableau comptant neuf éléments. Il faut toujours penser à traiter le cas du tableau vide, et vérifier qu’on ne sort pas du tableau.
On suppose qu’on a défini une liste de nombres lst. Expliquer l’erreur dans le code Python suivant.
lst = []
i = 0
while lst[i] < 100 and i < len(lst):
i += 1
Instruction conditionnelle non exhaustive
Lorsqu’on traite différents cas à l’aide d’instructions conditionnelles, il faut bien s’assurer qu’on oublie aucun cas.
Choix des inégalités et des opérateurs booléens
Bien réfléchir au sens des inégalités, si on doit prendre des inégalités larges ou strictes, pour éviter les débordements par exemple. Attention aussi aux opérateurs booléens :
while not est_vide(lst1) and not est_vide(lst2): est équivalent à
while not (est_vide(lst1) or est_vide(lst2)):
Comparaisons et calculs entre flottants
Une boucle du type while x != y: ou while f(x) != 0: risque de ne jamais se terminer si x et y sont des
flottants. En effet, même si dans un modèle mathématiques, x devrait être égal à y au bout d’un certain
nombre d’exécution, ou si f(x) devrait s’annuler, les erreurs d’arrondis vont perturber les calculs et ces
conditions risquent de ne jamais se réaliser. On utilisera plutôt while abs(x - y) > 1e-10: par exemple.
Mauvais nommage des variables
Des noms de variables mal choisis vont induire le programmeur en erreur. Pour éviter les problèmes de casse (majuscules/minuscules), respecter les conventions de nommage : tous les noms de variables en minuscules, sauf pour les classes et les noms composés. Dans ce dernier cas, on utilise la convention camel case ou snake case.
Tests
Tester régulièrement le code
On teste son code régulièrement, morceau par morceau, pour s’assurer que chaque fonction remplit bien son rôle ou pour comparer les temps d’exécution.
On peut tester son code « à la main », utiliser des fonctions de tests ou écrire ses scripts de tests. L’utilisation de fonctions de tests ou de scripts s’impose pour le développement d’applications dans lequel on va coder plusieurs versions d’une fonction, puis effectuer des mises à jour. On peut alors réutiliser la même base de tests après chaque modification du code ou avec chaque nouvelle version de la fonction.
Certains programmeurs pratiquent le TDD : Test-Driven Development (développement piloté par les tests). Ils écrivent d’abord les scripts de tests, puis écrivent les codes requis pour passer les tests.
Jeux de tests
L’ensemble des valeurs utilisées pour tester son code est appelé jeu de tests. Un bon jeu de tests va explorer les situations critiques, en cherchant à mettre en défaut le programme, utilisant par exemple les valeurs minimales et maximales des variables.
Pour effectuer un grand nombre de tests, on peut générer des données aléatoires, par exemple des listes
de nombres en utilisant la bibliothèque random, ou utiliser un site comme https://generatedata.com/
.
Module unittest
Le module Python unittest fournit un riche ensemble d’outils pour construire et lancer des tests.
https://docs.python.org/fr/3/library/unittest.html
Exemple
On veut tester une fonction fact calculant \(n!=1\times 2\times 3\cdots\times n\).
Par définition, \(0! = 1\) et on souhaite que notre fonction renvoie une erreur d’assertion si n est négatif.
def fact(n):
assert n >= 0, "n est un entier naturel !"
if n == 0:
return 1
else:
return n * fact(n - 1)
Pour tester cette fonction, on écrit les lignes suivantes à la suite du script ou dans
un autre script dédié aux tests, en important la fonction fact.
import unittest
from mon_fichier import fact
class TestFact(unittest.TestCase):
def test_calcul_fact_5(self):
self.assertEqual(fact(5), 120)
def test_calcul_fact_0to6(self):
self.assertEqual([fact(n) for n in range(7)], [1, 1, 2, 6, 24, 120, 720])
def test_calcul_fact_0(self):
self.assertTrue(type(fact(0)) is int)
def test_fact_n_negatif(self):
with self.assertRaises(AssertionError):
fact(-2)
if __name__ == '__main__':
unittest.main()
On crée notre classe de tests comme sous-classe de unittest.TestCase avec class NomDeLaClasseDeTests(unittest.TestCase).
Chaque méthode de cette classe correspond à un test ou à une série de tests similaires.
Les noms des méthodes doivent commencer par le mot-clef test et être le plus explicite possible car
ils vont s’afficher dans la console en cas d’erreur. On peut utiliser les fonctions de
tests suivantes :
self.assertEqual(a, b): teste l’égalité deaetb;self.assertTrue(a): teste si un booléen est égal àTrue ;self.assertFalse(a): teste si un booléen est égal àFalse;self.assertRaises(erreur): vérifie que le code renvoie une erreur précise (TypeError,AssertionError, …). Afin que l’erreur n’interrompe pas l’exécution du code, on intègre le test dans un bloc with. On appelle ensuiteunittest.main()pour exécuter les tests ainsi créés.
Module pytest
Le module Python pytest fournit également un riche ensemble d’outils pour construire et lancer des tests.
https://docs.pytest.org/en/stable/contents.html
import pytest
from mon_fichier import fact
def test_calcul_fact_5():
assert fact(5) == 120
def test_calcul_fact_0to6():
assert [fact(n) for n in range(7)] == [1, 1, 2, 6, 24, 120, 720]
def test_calcul_fact_0():
assert type(fact(0)) is int
def test_fact_n_negatif():
with pytest.raises(AssertionError):
fact(-2)
if __name__ == '__main__':
pytest.main()
On peut aussi executer le fichier ci-dessus avec pytest test_fact.py si l’on n’écrit pas les deux dernières lignes.
Module doctest
Le module doctest effectue les tests mentionnés dans les documentations. https://docs.python.org/3/library/doctest.html
On entre des exemples d’utilisation des fonctions dans leurs documentations, avec les résultats attendus, et doctest va effectuer ses vérifications.
Exemple
def fact(n):
"""
Renvoie n! pour n entier
>>> fact(5)
120
>>> [fact(n) for n in range(7)]
[1, 1, 2, 6, 24, 120, 720]
>>> fact(-2)
Traceback (most recent call last):
...
AssertionError: n est un entier naturel !
"""
assert n >= 0, "n est un entier naturel !"
if n == 0:
return 1
else:
return n * fact(n - 1)
if __name__ == "__main__":
import doctest
doctest.testmod()
Le résultat des tests s’affiche uniquement si un test échoue.
doctest.testmod(verbose=True)forcera l’affichage même si les tests réussissent.doctest.run_docstring_examples(fonction, globals())pour tester une fonction spécifique.
Optimisation des performances
Principe
Les gains d’optimisation sont souvent liés :
- à l’algorithme utilisé ;
- au langage lui-même et sa machinerie interne.
Chronométrage simple
Le chronométrage d’une portion de code ou d’une fonction peut s’effectuer avec la
fonction perf_counter() du module time comme ci-dessous :
import time
# Fonction qu'on souhaite chronométrer
def additionner(limite):
somme = 0
for i in range(0, limite + 1):
somme += i
return somme
# Lancement et chronométrage de la fonction/
debut = time.perf_counter()
resultat = additionner(1_000_000)
fin = time.perf_counter()
# Affichage du résultat et de la durée d'exécution/
print(f"La somme jusqu'à 1_000_000 est {resultat}")
delai = fin - debut
print(f"Durée d'exécution = {delai} s")
La somme jusqu'à 1_000_000 est 500000500000
Durée d'exécution = 0.078339777 s
Profilage
Le profilage d’un code donne des résultats beaucoup plus complet avec pour chaque fonction :
- le temps total d’exécution ;
- le nombre d’appels ;
- la durée moyenne d’un appel ;
- etc.
Le module cProfile présent dans la distribution standard de Python permet de réaliser
facilement ce profilage. Il suffit de lancer l’exécution d’un script depuis un terminal
comme ci-dessous :
$ python3 -m cProfile ./mon_fichier.py
1001004 function calls in 14.529 seconds
Ordered by: standard name
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.000 0.000 14.529 14.529 mon_fichier.py:1(<module>)
1 0.001 0.001 14.529 14.529 mon_fichier.py:1(faire_quelque_chose)
1000 0.180 0.000 14.529 0.015 mon_fichier.py:5(faire_autre_chose)
1000000 14.349 0.000 14.349 0.000 mon_fichier.py:9(faire_semblant)
1 0.000 0.000 14.529 14.529 {built-in method builtins.exec}
1 0.000 0.000 0.000 0.000 {method 'disable' of '_...' objects}
On peut noter par exemple que la fonction faire_semblant() a été appelée \(1000000\) de
fois pour un temps total d’exécution de \(14,349\) s soit presque l’intégralité du temps
d’exécution du programme complet.
C’est précisément sur cette fonction qu’il faudrait focaliser les optimisations.
Vérifiez votre compréhension
- Quelle est la différence entre une assertion et une exception ?
Réponse
Une assertion (assert) est un contrôle que le programmeur insère pour vérifier des hypothèses pendant le développement ; si la condition est fausse, le programme s'arrête immédiatement. Une exception est un mécanisme plus riche : elle peut être capturée avectry/exceptpour permettre au programme de réagir à l'erreur au lieu de s'arrêter brutalement. - Pourquoi est-il dangereux d’écrire
while x != yquandxetysont des flottants ?Réponse
Les nombres flottants sont soumis à des erreurs d'arrondi dues à leur représentation en binaire. Même si mathématiquementxdevrait devenir égal ày, l'égalité exacte peut ne jamais être atteinte, provoquant une boucle infinie. On utilise plutôtwhile abs(x - y) > epsilonavec un seuil de tolérance. - À quoi sert le profilage d’un programme, par rapport à un simple chronométrage ?
Réponse
Le chronométrage donne le temps total d'exécution, mais ne dit pas où le temps est consommé. Le profilage (aveccProfilepar exemple) détaille le nombre d'appels et le temps passé dans chaque fonction, ce qui permet d'identifier précisément les goulots d'étranglement à optimiser. - Quelle est la différence entre
unittestetdoctest?Réponse
doctestplace les tests dans la docstring de la fonction, sous forme d'exemples d'utilisation. C'est simple et sert aussi de documentation.unittest(etpytest) permettent des tests plus structurés dans des fichiers séparés, avec des assertions variées (assertEqual,assertRaises…) et une meilleure organisation pour les projets importants. - Que fait l’instruction
breakpoint()dans un programme Python ?Réponse
Elle met l'exécution en pause à cet endroit et ouvre le débogueur interactif (pdb). On peut alors inspecter les variables, exécuter le code pas à pas (npour next,spour step) et comprendre le comportement du programme. - Pourquoi faut-il traiter le cas du tableau vide avant d’accéder à ses éléments ?
Réponse
Accéder à un élément d'un tableau vide provoque uneIndexError. De plus, en Python, la conditionlst[i] < 100 and i < len(lst)est mal ordonnée : il faut d'abord vérifier que l'indice est valide (i < len(lst)) puis accéder à l'élément, grâce à l'évaluation paresseuse duand.
- Un code lisible (noms de variables significatifs, documentation, découpage en fonctions) est la première prévention contre les bugs.
- Les assertions vérifient les préconditions et postconditions pendant le développement ; les exceptions (
try/except) permettent de gérer les erreurs à l’exécution sans interrompre brutalement le programme. - Les tests (avec
doctest,unittestoupytest) doivent couvrir les cas nominaux et les cas limites ; le TDD préconise d’écrire les tests avant le code. - Le débogueur (
pdb/breakpoint) permet d’exécuter le code pas à pas et d’inspecter les variables en temps réel. - Le profilage (
cProfile) identifie les fonctions les plus coûteuses pour cibler les optimisations ; ces techniques complètent la modularité pour produire des programmes fiables.