Lorsqu'on utilise une fonction, il n'est pas utile de connaître ni même de comprendre le code qui la définit mais on a absolument besoin de connaître son rôle, autrement dit de savoir ce que fait cette fonction. Cela signifie que le programmeur ne peut donc pas se contenter d'écrire uniquement le code de la fonction mais il doit s'assurer d'expliquer ce que fait son programme afin que d'autres puissent l'utiliser : pour cela, le programmeur doit créer une documentation.
De plus, pour écrire sa fonction, le programmeur doit s'assurer que son programme se comporte convenablement, autrement dit que la fonction renvoie la (ou les) valeur(s) attendue(s), et ce quelles que soient les valeurs (admissibles) des paramètres. Pour cela, le programmeur doit écrire un ou plusieurs jeux de tests et s'assurer que les tests passent avec succès. C'est une étape importante voire cruciale s'il s'agit par exemple d'un programme intervenant dans le pilotage d'un avion.
Vous aller découvrir dans ce chapitre les bonnes pratiques qui permettent au programmeur d'effectuer ces deux tâches.
Considérons la fonction Python suivante qui a été codée par l'un de vos camarades et que vous devez utiliser dans votre projet.
def f(x, n):
r = 1
for i in range(n):
r = r * x
return r
En la recevant telle quelle, il n'est pas évident que vous sachiez à quoi elle sert. En l'analysant un peu il est possible de comprendre ce que fait cette fonction mais on n'en a pas toujours ni le temps ni l'envie (surtout si ce n'est pas notre travail !)
🤔 Alors, elle fait quoi selon vous ?
Ainsi, le version ci-dessous aurait déjà été plus parlante :
def puissance(x, n):
r = 1
for i in range(n):
r = r * x
return r
Un nommage explicite est donc une première (bonne) étape mais elle n'est pas suffisante. En effet, on se doute désormais que cette fonction calcule une puissance mais c'est à peu près tout :
x
et de n
? ...La bonne pratique pour un codeur est de toujours expliquer son programme (ici sa fonction). Pour cela, une première méthode peut consister à commenter son code de la façon suivante.
# fonction qui renvoie la valeur de x^n
def puissance(x, n):
r = 1
for i in range(n):
r = r * x
return r
Cela donne l'information à celui qui a accès au code mais pas aux autres : si par exemple cette fonction appartient à une bibliothèque que l'on importe, nous n'avons pas directement accès au code de la fonction ni à ce commentaire et donc nous ne serions pas beaucoup plus avancé...
Il existe une meilleure façon d'expliquer son code. Pour cela, on associera une documentation à notre fonction sous la forme d'une chaîne de caractères écrites entre triples guillemets.
def puissance(x, n):
"""
Renvoie la valeur de x^n.
"""
r = 1
for i in range(n):
r = r * x
return r
On appelle cela la chaîne de documentation de la fonction (ou docstring en anglais). En procédant ainsi, le programmeur de la fonction permet à quiconque d'afficher cette chaîne de caractères en utilisant la fonction help
.
help(puissance)
Vous pouvez essayer d'afficher la docstring de fonctions connues.
help(abs) # abs est la fonction valeur absolue
help(len)
Maintenant que l'on sait mieux ce que fait cette fonction on peut facilement l'utiliser pour calculer par exemple $2^3$, $\left(\dfrac{1}{2}\right)^2$, $(-2)^5$ :
puissance(2, 3) # renvoie bien la valeur de 2^3
puissance(0.5, 2) # renvoie bien la valeur de (1/2)^2 = 1/4
puissance(-2, 5) # renvoie bien la valeur de (-2)^5 = -32
Cependant, en faisant d'autres tests on se rend vite compte que l'on ne peut pas calculer certaines puissances avec la fonction :
puissance(2, -3) # ne renvoie pas la valeur de 2^(-3) = 1/8
puissance(4, 1/2) # ne renvoie pas la valeur de 4^(1/2) = 2 mais lève une erreur
L'objectif d'une chaîne de documentation est d'être courte mais aussi précise.
On voit sur les deux derniers appels que la fonction ne permet pas de calculer correctement une puissance si l'exposant est négatif ou si l'exposant n'est pas entier. La chaîne de documentation de la fonction n'était donc pas assez précise.
De manière générale, la chaîne de documentation d'une fonction doit contenir sa spécification, c'est-à-dire :
Ainsi, la version suivante de notre chaine de documentation est bien meilleure car elle indique quelles sont les conditions (sur les paramètres) d'utilisation de la fonction.
def puissance(x, n):
"""
Renvoie la valeur de x^n, où x est un flottant et n est un entier positif ou nul.
"""
r = 1
for i in range(n):
r = r * x
return r
Ou encore, de manière plus "professionnelle", on peut écrire :
def puissance(x, n):
"""
Renvoie la valeur de x^n.
Paramètres
----------
x : float
n : int
n doit être positif ou nul
"""
r = 1
for i in range(n):
r = r * x
return r
✍️ À FAIRE : Exercices 1 et 2
Si une fonction est bien documentée et qu'elle est correcte, on peut considérer que le travail du programmeur (de la fonction) a été fait correctement. Cependant, rien n'empêche d'utiliser cette fonction avec des paramètres d'entrée ne respectant pas les préconditions définies dans la docstring : mais l'erreur incombe alors à l'utilisateur qui, soit n'a pas lu correctement la documentation, soit a volontairement testé la fonction avec des valeurs non admises.
Pour parer à cela, le programmeur a la possibilité d'utiliser la construction assert
suivie d'une condition à tester.
Si cette condition est vraie, alors il ne se passe rien et le programme poursuit son exécution :
# Cas d'un test valide
a = -2
assert a < 0
print(a) # est exécuté car l'assertion précédente est vraie
En revanche, si la condition est fausse, alors une erreur (de type AssetionError
, soit erreur d'assertion) est détectée et stoppe l'exécution du reste du programme.
Par exemple, le test invalide suivant produit l'affichage d'un message d'erreur et stoppe le programme (ici la dernière ligne n'est pas exécutée)
# Cas d'un test invalide
a = -2
assert a >= 0
print(a) # n'est pas exécuté car l'assertion précédente est fausse
On peut faire suivre la condition à tester d'un message pour expliquer l'erreur
# ajout du message à afficher en cas d'erreur
a = -2
assert a >= 0, "le nombre a n'est pas positif"
On pourrait donc utiliser ce mécanisme d'assertion dans notre fonction puissance(x, n)
de la façon suivante.
def puissance(x, n):
"""
Renvoie la valeur de x^n, où x est un flottant et n est un entier positif ou nul.
"""
assert type(n) == int and n >= 0, "n n'est pas un entier positif ou nul"
r = 1
for i in range(n):
r = r * x
return r
Avant l'écriture du corps de la fonction, on a ajouté une assertion (ligne 5) qui va au préalable tester si le paramètre n
est bien un entier et que c'est bien un entier positif. Si ce n'est pas le cas, la fonction est interrompue (à la ligne 5) et le message d'erreur sera affiché (sinon, le programme se poursuit).
puissance(2, -3) # précondition non vérifiée
puissance(2, 3) # précondition vérifiée
En écrivant cette assertion, le programmeur teste au préalable si les préconditions sont valides avant d'exécuter le reste du programme. On parle alors de programmation défensive.
Remarque : il est possible de combiner plusieurs assertions. Par exemple, l'assertion précédente aurait pu être "séparée" en les deux assertions suivantes :
assert type(n) == int, "n n'est pas un entier" assert n >= 0, "n n'est pas positif"
✍️ À FAIRE : Exercices 3 et 4
On peut tout à fait avoir bien spécifié et documenté sa fonction sans que celle-ci ne fonctionne comme prévu. En effet, il n'est pas rare de se tromper dans le code. Pour repérer les éventuelles erreurs, le programmeur peut utiliser sa fonction sur des cas concrets et vérifier que celle-ci renvoie la (ou les) bonne(s) valeur(s). On appelle cela le test.
Plutôt que de faire les tests manuellement un par un, il est possible d'utiliser la construction assert
directement dans le fichier contenant le programme.
def puissance(x, n):
"""
Renvoie la valeur de x^n, où x est un flottant et n est un entier positif ou nul.
"""
r = 1
for i in range(n):
r = r * x
return r
# jeu de tests :
assert puissance(2, 3) == 8
assert puissance(0, 2) == 0
assert puissance(5, 0) == 1
assert puissance(-2, 5) == -32
assert puissance(0.5, 2) == 0.25
Si l'un de ces tests échoue, un message indique le premier échec et la fonction doit être corrigée. Une fois que la correction a été faite, il faut relancer tous les tests. En effet, en corrigeant la fonction il est possible d'introduire une autre erreur (et donc qu'un des tests qui passait avec succès, échoue avec la correction apportée).
Une pratique courante consiste à écrire des tests avant même d'écrire le code de la fonction.
En effet, si la spécification de la fonction est claire, on sait quel doit être le comportement de celle-ci. Par exemple, supposons que l'on veuille écrire une fonction appartient(v, T)
dont la spécification, écrite dans sa docstring, est la suivante :
def appartient(v, t):
"""
Renvoie True si l'entier v appartient à tableau d'entiers t, et False sinon.
"""
# CODE A ECRIRE
On connaît son comportement et on peut tout suite écrire les tests suivants.
def appartient(v, t):
"""
Renvoie True si l'entier v appartient à tableau d'entiers t, et False sinon.
"""
# CODE DE LA FONCTION À ÉCRIRE
# jeu de tests
assert appartient(2, [2, 4, 7, 5]) == True
assert appartient(2, [0, 4, 7, 5]) == False
assert appartient(2, [2]) == True
Ensuite, on peut écrire le code de la fonction et exécuter le programme. Si l'un des tests échoue on est certain d'avoir fait une erreur et il faut la corriger. Cependant, si tous nos tests passent, nous ne sommes pas sûr que notre fonction est bien écrite pour autant.
Il est souvent impossible d'écrire de manière exhaustive tous les tests possibles car il y en a bien souvent une infinité. Pour se convaincre que notre fonction est bien écrite, l'enjeu consiste donc à trouver un ensemble de tests qui couvrent les différents comportements du programme.
Par exemple, avec le code suivant pour la fonction appartient
aucun des tests proposés n'échoue.
def appartient(v, t):
"""
Renvoie True si l'entier v appartient à tableau d'entiers t, et False sinon.
"""
for i in range(len(t)):
if t[i] == v:
return True
else:
return False
# jeu de tests
assert appartient(2, [2, 4, 7, 5]) == True
assert appartient(2, [0, 4, 7, 5]) == False
assert appartient(2, [2]) == True
Tous les tests de notre jeu de tests passent avec succès, super notre fonction est correcte ! 😎💪
... Et pourtant, avec l'appel suivant c'est le drame ... 😲😱
appartient(2, [0, 4, 2, 5])
Cet appel met en évidence que le code de notre fonction est incorrect puisque 2 appartient bien au tableau [0, 4, 2, 5]
.
Pire, l'appel ci-dessous ne renvoie rien alors qu'il devrait renvoyer False
😲😱...
appartient(2, [])
Notre code est donc (au moins) doublement incorrect !! Et pourtant tous les tests de notre jeu de tests sont passés avec succès... Cela montre que notre jeu de tests n'était pas bon et que la qualité du jeu de tests est primordiale !
Mais qu'est-ce qu'un bon jeu de tests ?
Il n'est pas simple de définir ce qu'est un bon jeu de tests mais de manière générale, voici quelques règles que l'on peut appliquer :
Essayons d'améliorer notre jeu de tests pour la fonction
appartient
en suivant ces préconisations !
Notre jeu de tests est pour le moment le suivant :
# jeu de tests
assert appartient(2, [2, 4, 7, 5]) == True
assert appartient(2, [0, 4, 7, 5]) == False
assert appartient(2, [2]) == True
On a bien écrit un test pour lequel la fonction renvoie True
et un autre pour lequel la fonction renvoie False
. On a pensé à faire un test pour un tableau particuler : celui réduit à un seul élément (le troisième test). Par ailleurs, nos deux tests pour lesquels la fonction renvoie True
sont particuliers car à chaque fois la valeur v
cherchée se trouve en première position (d'indice 0) dans le tableau.
Améliorons cela en partant des deux appels appartient(2, [0, 4, 2, 5])
et appartient(2, [])
effectués précédemment et qui ont montré que notre fonction était incorrecte. Si on a analyse ces deux exemples, on se rend compte que :
assert appartient(2, []) == False
assert appartient(2, [0, 4, 2, 5]) == True
Tant qu'à faire, on peut ajouter un dernier test où la valeur v
se trouve en dernière position du tableau, ce qui est aussi un cas particulier à considérer. Voici donc le jeu tests amélioré proposé (qui ne passera pas avec succès puisque nous n'avons toujours pas modifié le code de notre fonction appartient
) :
def appartient(v, t):
"""
Renvoie True si l'entier v appartient à tableau d'entiers t, et False sinon.
"""
for i in range(len(t)):
if t[i] == v:
return True
else:
return False
# un meilleur jeu de tests :
assert appartient(2, [2, 4, 7, 5]) == True
assert appartient(2, [0, 4, 7, 5]) == False
assert appartient(2, [2]) == True
assert appartient(2, [0, 4, 2, 5]) == True # cas où v est au "milieu du tableau"
assert appartient(2, [0, 4, 5, 2]) == True # cas où v est en dernière position
assert appartient(2, []) == False # cas du tableau vide
On va terminer en corrigeant notre fonction pour tous les tests soient validés !
Il n'est pas toujours évident de trouver pourquoi notre code ne fonctionne pas. Les tests qui échouent nous donne cependant de précieux éléments sur les erreurs dans le programme. En effet, c'est souvent en partant d'un test qui échoue que l'on trouve les erreurs en suivant l'état des variables.
Ceci peut se faire mentalement ou sur papier mais il également possible d'utiliser des outils numériques.
Pour un programmeur débutant, la première idée à mettre en oeuvre est souvent d'afficher les valeurs de certaines variables à des endroits stratégiques du programme. Par exemple, on peut afficher la valeur de la variable i
à chaque tour de boucle, et afficher le message "ici"
ou "là"
selon que l'on passe dans le if
ou dans le else
:
def appartient(v, t):
"""
Renvoie True si l'entier v appartient à tableau d'entiers t, et False sinon.
"""
for i in range(1, len(t)):
print("i =", i)
if t[i] == v:
print("ici")
return True
else:
print("là")
return False
On utilise le premier appel qui posait problème :
appartient(2, [0, 4, 2, 5])
On se rend compte que la variable i
ne prend que la valeur 0
, autrement dit que l'on ne fait qu'un seul tour de boucle : le premier (celui d'indice 0
). De plus, lors de ce premier tour de boucle, on voit que le message "là"
est affiché, autrement dit que l'on passe dans le else
. Or, si on arrive dans le else
notre fonction renvoie False
.
Or, il ne faut pas renvoyer False
mais poursuivre la recherche si la valeur v
n'est pas en première position (ni à une autre position d'ailleurs). On peut alors corriger notre programme en retirant le else
et en renvoyant False
après la boucle for
!
def appartient(v, t):
"""
Renvoie True si l'entier v appartient à tableau d'entiers t, et False sinon.
"""
for i in range(len(t)):
print("i =", i)
if t[i] == v:
print("ici")
return True
print("là")
return False
appartient(2, [0, 4, 2, 5])
On peut essayer à nouveau notre jeu de tests (en enlevant les affichages) :
def appartient(v, t):
"""
Renvoie True si l'entier v appartient à tableau d'entiers t, et False sinon.
"""
for i in range(len(t)):
if t[i] == v:
return True
return False
# un meilleur jeu de tests :
assert appartient(2, [2, 4, 7, 5]) == True
assert appartient(2, [0, 4, 7, 5]) == False
assert appartient(2, [2]) == True
assert appartient(2, [0, 4, 2, 5]) == True # cas où v est au "milieu du tableau"
assert appartient(2, [0, 4, 5, 2]) == True # cas où v est en dernière position
assert appartient(2, []) == False # cas du tableau vide
Super, tous les tests passent avec succès, et comme notre jeu de tests couvre tous les cas particuliers, on peut considérer que notre fonction est a priori correcte 👍.
Il existe des outils efficaces permettant d'exécuter ligne par ligne un programme pour y déceler une erreur. L'un d'entre eux se nomme Python tutor. Il n'est alors plus nécessaire d'utiliser des print
pour faire des affichages.
Voici un lien vers Python tutor pour exécuter l'appel appartient(2, [0, 4, 2, 5])
avec la version incorrecte de la fonction appartient
. On peut alors suivre les exécutions ligne après ligne pour se rendre compte qu'il n'y a qu'un seul tour de boucle avec renvoi de la valeur False
dès ce premier tour.
Enfin, les IDE les plus récents proposent généralement des débogueurs (ou debugger) qui permettent d'exécuter les programmes ligne par ligne et d'insérer des points d'arrêts pour stopper l'exécution du programme à certains endroits. On peut ainsi suivre les valeurs des variables pas à pas pour trouver la ou les erreurs.
✍️ À FAIRE : Exercices 5, 6, 7 et 8
Choisir et vérifier des tests pertinents est un travail précieux, dont il est important de garder une trace. De plus, des tests bien choisis peuvent constituer une explication très efficace de l'effet d'une fonction.
Une pratique fréquente est d'inclure une série de tests directement dans la chaîne d'aide d'une fonction.
def puissance(x, n):
'''
Renvoie la valeur de x^n, où n est un entier positif ou nul
>>> puissance(2, 3)
8
>>> puissance(0, 2)
0
>>> puissance(5, 0)
1
>>> puissance(-2, 5)
-32
>>> puissance(0.5, 2)
0.25
etc.
'''
r = 1
for i in range(n):
r = r * x
return r
Par convention, les tests prennent la forme d'expressions Python précédées de la syntaxe >>>
, qui représente l'invite de commande de l'interpréteur Python classique. Chaque expression est suivi de l'affichage (éventuellement vide) qui serait provoqué si elle était évaluée dans l'interpréteur.
Un outil prédéfini, accessible par le biais du module doctest
, permet d'extraire automatiquement et de vérifier chacun des tests présents dans les chaînes de documentation, dans toutes les fonctions d'un module par exemple (par défaut, les tests du module courant sont extraits et vérifiés).
import doctest
doctest.testmod()
Ici, il n'y a qu'une fonction qui contient des tests inclus dans sa chaîne de documentation. On se rend compte que les 5 tests pour la fonction puissance(x, n)
sont passés avec succès.
Il est possible d'ajouter un paramètre optionnel à la fonction testmod
afin d'obtenir plus d'informations sur les tests effectués, même en cas de succès.
doctest.testmod(verbose = True)
✍️ À FAIRE : Exercice 9.
Nous avons vu :
assert
était un bon moyen d'effectuer une série de tests pour se convaincre que notre programme est correct et, éventuellement, de mettre en évidence des erreurs ;doctest
) permettant de les extraire et de les vérifier automatiquement.Pour aller plus loin
Il existe bien d'autres outils de documentation (pydoc, Sphinx) et de test (pytest, unittest), pour des usages plus complexes.
Il est possible, de manière optionnelle, d'indiquer dans l'en-tête d'une fonction le type de certains paramètres et / ou du résultat. Cela peut être utile par exemple pour alléger la chaîne de documentation. Il existe aussi des outils externes qui permettent de vérifier que ces annotations de types sont vérifiées.
Références :
Germain BECKER & Sébastien POINT, Lycée Mounier, ANGERS