Objectifs du chapitre
À la fin de ce chapitre, vous serez capable de:
- reconnaître les situations où une fonction s'impose, et distinguer la définition d'une fonction de son appel;
- écrire une fonction avec
def, lui donner des paramètres, des valeurs par défaut, et l'appeler avec des arguments positionnels ou nommés; - expliquer la différence entre une fonction qui retourne une valeur et une fonction qui affiche une valeur, et choisir la bonne;
- décrire ce qu'est l'espace local d'un appel, prévoir ce que voit et ce que ne voit pas le code qui entoure une fonction, et lire une
UnboundLocalError; - documenter une fonction par une docstring et la consulter avec
help(); - écrire trois assertions qui disent ce qu'une fonction doit satisfaire, les exécuter, et lire une
AssertionError; - découper un programme d'une cinquantaine de lignes en petites fonctions testées séparément, puis les composer.
Les sorties et les messages d'erreur reproduits dans ce chapitre ont tous été obtenus en exécutant les programmes avec Python 3.13. Comme au chapitre 1, nous abrégeons le chemin complet que Python imprime dans ses messages d'erreur — sur votre machine, quelque chose comme /home/vous/python/compteur.py — en son seul nom de fichier. C'est la seule retouche faite à ces messages: le numéro de ligne, la ligne de code recopiée, les accents circonflexes, le type de l'erreur et son message sont exactement ce que Python a imprimé.
Pourquoi des fonctions
Le même calcul à trois endroits
Voici un problème minuscule, qui suffit pourtant à tout déclencher. Un enseignant corrige trois épreuves. Chacune est notée sur un nombre de points différent — 40, 60 et 25 — et il veut les convertir en notes suisses, entre 1 et 6, selon la règle habituelle: zéro point donne 1, le maximum donne 6, et entre deux la conversion est linéaire. La formule est
Avec ce que vous savez des chapitres 1 à 3, le programme s'écrit tout seul:
# Trois epreuves, chacune notee sur un total different.
note_1 = 1 + 5 * 34 / 40
note_2 = 1 + 5 * 51 / 60
note_3 = 1 + 5 * 18 / 25
print(note_1)
print(note_2)
print(note_3)
5.25
5.25
4.6
Ce programme est correct. Il est aussi mauvais, et il est important de comprendre pourquoi, parce que la raison n'est pas esthétique.
La formule (4.1) est écrite trois fois. Tant qu'elle ne change pas, tout va bien. Mais elle va changer: l'enseignant décide que la note minimale n'est pas 1 mais 1,5 parce qu'une copie blanche n'est pas comparable à une absence; ou le règlement passe à un barème où la moitié des points donne exactement 4; ou l'on s'aperçoit d'une faute de frappe. Il faut alors modifier trois lignes. À trois lignes, on y arrive. À trente, non: on en oublie une, et le programme se met à donner deux réponses différentes à la même question, sans rien signaler. Ce type de faute est le plus coûteux qui soit, parce qu'il ne provoque aucune erreur: il produit des résultats faux qui ont l'air vrais.
Il y a pire. Rien, dans ces trois lignes, ne dit ce que le calcul signifie. Un lecteur — vous-même dans quinze jours — doit relire 1 + 5 * 34 / 40 et reconstituer l'intention. Le nombre 5 n'est pas un nombre quelconque: c'est l'étendue de l'échelle suisse, six moins un. Le 1 est la note plancher. Ces deux informations sont perdues, noyées dans une expression arithmétique.
Nommer un raisonnement
Une fonction règle les deux problèmes d'un coup:
def note_sur_six(points, total):
return 1 + 5 * points / total
note_1 = note_sur_six(34, 40)
note_2 = note_sur_six(51, 60)
note_3 = note_sur_six(18, 25)
print(note_1)
print(note_2)
print(note_3)
5.25
5.25
4.6
Les trois dernières lignes de calcul ne parlent plus d'arithmétique: elles parlent de notes. Elles disent «convertis 34 points sur 40 en note sur six», ce qui est exactement ce que l'enseignant a en tête. La formule, elle, n'existe qu'à un seul endroit. Le jour où le barème change, il y a une ligne à modifier, et il est impossible d'en oublier une autre, puisqu'il n'y en a pas d'autre.
C'est la fonction essentielle d'une fonction: donner un nom à un morceau de raisonnement. Vous avez déjà rencontré ce mécanisme sans le nommer. Quand vous écrivez print(...), vous ne savez pas comment Python s'y prend pour envoyer des caractères vers le terminal, et vous n'avez pas besoin de le savoir: quelqu'un a écrit ce morceau de raisonnement une fois, lui a donné le nom print, et vous l'utilisez par ce nom. len, int, input sont dans le même cas. Ce chapitre vous fait passer du côté de celui qui écrit ces morceaux.
Isoler pour pouvoir vérifier
Le troisième bénéfice ne se voit qu'à l'usage, et c'est le plus profond. Tant que la formule (4.1) est dispersée dans un programme de deux cents lignes, on ne peut pas la vérifier: pour savoir si elle est juste, il faut exécuter tout le programme, avec ses lectures de données, ses boucles et ses affichages, et essayer de reconnaître une erreur au milieu du reste.
Une fois la formule enfermée dans note_sur_six, on peut l'interroger seule. Zéro point sur quarante donne-t-il bien 1? Quarante sur quarante donnent-ils bien 6? Vingt sur quarante donnent-ils 3,5, ce qui est la conséquence — un peu surprenante, mais correcte — d'une échelle linéaire de 1 à 6? Ces trois questions se posent en trois lignes, et le reste du programme n'intervient pas. La dernière section de ce chapitre montre comment les écrire une fois pour toutes sous forme d'assert, de façon que le programme les repose lui-même à chaque exécution.
Retenez cette triade, qui revient dans tout le cours: une fonction évite la répétition, nomme une intention et rend une partie du programme vérifiable isolément.
Définir une fonction avec def
La syntaxe
Une définition de fonction a exactement cette forme:
def nom_de_la_fonction(parametre_1, parametre_2):
# corps de la fonction, indente
resultat = parametre_1 + parametre_2
return resultat
Ligne par ligne:
- le mot-clé
def(pour define) annonce une définition; - suit le nom de la fonction, soumis aux mêmes règles que les noms de variables (lettres, chiffres, soulignés, pas de chiffre en tête, pas d'accent), et écrit en
snake_case; - entre parenthèses, la liste des paramètres, séparés par des virgules. Les parenthèses sont obligatoires même quand il n'y a aucun paramètre: on écrit alors
def bonjour():; - le deux-points termine la ligne. Il annonce un bloc indenté, exactement comme après un
ifou unwhiledu chapitre 2 et du chapitre 3; - le corps est le bloc indenté qui suit, décalé de quatre espaces par convention. L'indentation n'est pas une décoration: c'est elle, et elle seule, qui dit où la fonction commence et où elle finit;
- le mot-clé
return(facultatif) termine l'exécution de la fonction et renvoie une valeur à qui l'a appelée.
Le corps peut contenir tout ce que vous savez écrire: des affectations, des if, des while, des for, des print, et des appels à d'autres fonctions.
Définir n'est pas appeler
C'est le premier obstacle réel, et il vaut la peine de s'y arrêter. Exécutez ce programme:
def avertir():
print("Attention: la note est insuffisante.")
print("Le programme est termine.")
Le programme est termine.
L'avertissement ne s'affiche pas. Il n'y a pourtant pas d'erreur: le programme s'est déroulé normalement. Ce que Python a fait en rencontrant les deux premières lignes, c'est créer un objet fonction et lui coller l'étiquette avertir — exactement comme x = 3 colle l'étiquette x sur la valeur 3 (figure du chapitre 1). Le corps a été mis de côté, pas exécuté. Il ne s'exécutera que si quelqu'un écrit avertir().
Ce qui se passe pendant un appel
Ajoutons l'appel, et observons l'ordre des opérations:
def avertir():
print("2. le corps s'execute maintenant")
print("1. avant l'appel")
avertir()
print("3. apres l'appel")
1. avant l'appel
2. le corps s'execute maintenant
3. apres l'appel
Le déroulement mérite d'être décomposé, parce que tout le chapitre en dépend. Quand l'interpréteur rencontre avertir():
- il suspend la ligne en cours: le programme appelant s'arrête exactement là où il est;
- il évalue les arguments, de gauche à droite (ici il n'y en a pas), et copie leurs valeurs dans les paramètres de la fonction;
- il crée un espace local, un petit espace de noms tout neuf qui n'existe que pour cet appel;
- il exécute le corps, ligne par ligne;
- dès qu'il rencontre un
return, ou qu'il arrive au bout du corps, il abandonne l'espace local et revient à la ligne suspendue; - l'expression d'appel est remplacée par la valeur retournée, et la ligne de l'appelant reprend son cours.
L'idée que l'appel est remplacé par la valeur est la plus utile de tout le chapitre. Elle explique pourquoi on peut écrire note_sur_six(34, 40) + 1, ou print(note_sur_six(34, 40)), ou même note_sur_six(note_sur_six(34, 40), 6): partout où un nombre peut apparaître, un appel qui retourne un nombre peut apparaître aussi.
Remettez dans l'ordre les étapes que l'interpréteur exécute lorsqu'il rencontre la ligne note = note_sur_six(34, 40).
Glissez les éléments pour les mettre dans le bon ordre
- Le corps de la fonction s'exécute.
- La ligne appelante est suspendue.
- Les arguments 34 et 40 sont évalués, puis copiés dans les paramètres points et total.
- L'instruction return calcule 5.25 et termine la fonction.
- Un espace local est créé pour cet appel.
- L'appel est remplacé par 5.25, et l'affectation à note se fait.
- L'espace local est abandonné.
Un programme définit une fonction saluer qui affiche Bonjour, puis contient la ligne saluer, écrite sans parenthèses. Que se passe-t-il à l'exécution?
Paramètres et arguments
Deux mots pour deux choses
Le vocabulaire est ici plus précis que dans la conversation courante, et cette précision sert à quelque chose: les messages d'erreur de Python l'utilisent.
Dans def note_sur_six(points, total):, points et total sont des paramètres. Dans note_sur_six(34, 40), 34 et 40 sont des arguments. Le premier argument remplit le premier paramètre, le deuxième le deuxième: c'est ce qu'on appelle le passage positionnel, parce que c'est la position qui décide de la correspondance.
L'ordre compte donc, et il compte beaucoup:
def perimetre(longueur, largeur):
return 2 * (longueur + largeur)
print(perimetre(3, 5))
print(perimetre(5, 3))
print(perimetre(2.5, 2.5))
16
16
10.0
Ici le résultat est le même dans les deux sens, parce que l'addition est commutative — ce n'est pas une règle générale, c'est une propriété de cette fonction. Avec note_sur_six(40, 34) au lieu de note_sur_six(34, 40), on obtiendrait une note de 6,88, c'est-à-dire un résultat absurde mais silencieux. Remarquez au passage la troisième ligne de sortie: 10.0 et non 10, parce que les arguments étaient des nombres à virgule. La fonction n'impose aucun type à ses paramètres; elle reçoit ce qu'on lui donne, et le type du résultat suit celui des arguments, selon les règles du chapitre 1.
Que se passe-t-il si le nombre d'arguments ne correspond pas au nombre de paramètres? Python refuse l'appel, et le message est explicite. Un appel perimetre(3) produit TypeError: perimetre() missing 1 required positional argument: 'largeur', et perimetre(3, 5, 7) produit TypeError: perimetre() takes 2 positional arguments but 3 were given. Ce sont deux des messages les plus fréquents du débutant, et ils nomment précisément le paramètre manquant ou le nombre attendu.
Les valeurs par défaut
Une épreuve est souvent notée sur 100 points. Il serait commode de ne pas avoir à le répéter. On donne pour cela une valeur par défaut à un paramètre, avec un signe = dans la ligne def:
def note_sur_six(points, total=100):
return 1 + 5 * points / total
print(note_sur_six(85))
print(note_sur_six(34, 40))
5.25
5.25
Le premier appel ne donne qu'un argument: total prend sa valeur par défaut, 100, et l'on obtient . Le second en donne deux: la valeur fournie l'emporte sur la valeur par défaut. (La coïncidence des deux résultats est fortuite; elle est due au choix des nombres, 85 sur 100 et 34 sur 40 représentant la même proportion.)
Une règle de syntaxe accompagne les valeurs par défaut: les paramètres à valeur par défaut viennent après ceux qui n'en ont pas. Écrire def f(a=1, b): est refusé au chargement même du fichier, avec SyntaxError: parameter without a default follows parameter with a default. La raison est simple: sans cette règle, un appel f(7) serait ambigu.
Les arguments nommés
À l'appel, on peut désigner un paramètre par son nom au lieu de compter les positions:
def note_sur_six(points, total=100):
return 1 + 5 * points / total
print(note_sur_six(total=40, points=34))
print(note_sur_six(34, total=40))
5.25
5.25
Les arguments nommés (keyword arguments) rendent l'appel lisible et permettent de sauter des paramètres intermédiaires. Ils obéissent eux aussi à une règle d'ordre: une fois qu'on a commencé à nommer, on ne peut plus revenir au positionnel. Ce programme ne démarre même pas:
def note_sur_six(points, total=100):
return 1 + 5 * points / total
print(note_sur_six(points=34, 40))
File "notes.py", line 5
print(note_sur_six(points=34, 40))
^
SyntaxError: positional argument follows keyword argument
Notez la nature du message: SyntaxError. Il ne s'agit pas d'une erreur survenue pendant l'exécution, mais d'un texte que Python n'arrive pas à lire du tout. Rien n'a été exécuté, pas même les lignes précédentes.
Quand faut-il nommer ses arguments? La règle pratique est celle-ci: nommez dès que le lecteur ne peut pas deviner ce que signifie une valeur nue. note_sur_six(34, 40) se comprend; prix_billet(62, 0.25, True) ne se comprend pas, alors que prix_billet(62, demi_tarif=True) se lit.
Le piège: la valeur par défaut est calculée une seule fois
Voici le point que presque personne ne devine, et qu'il vaut mieux voir tôt. L'expression écrite comme valeur par défaut est évaluée une seule fois, au moment où Python lit la ligne def — pas à chaque appel.
capital_initial = 100
def restant(retire, capital=capital_initial):
return capital - retire
capital_initial = 500
print(restant(10))
print(restant(10, capital_initial))
90
490
Le premier appel affiche 90, c'est-à-dire , alors que capital_initial valait 500 depuis longtemps quand il a eu lieu. La raison est que la valeur par défaut a été figée à 100 à la lecture du def, avant la ligne capital_initial = 500. Le second appel, qui passe explicitement la valeur courante, donne bien 490.
Soit la fonction définie par la ligne def prix_remise(prix, remise=10) dont le corps est return prix * (1 - remise / 100). Quelle valeur retourne l'appel prix_remise(250, remise=20)?
return: ce que la fonction rend
Retourner une valeur
L'instruction return expression fait deux choses en même temps, et il faut les voir toutes les deux:
- elle évalue l'expression et la renvoie à l'appelant, où elle prend la place de l'appel;
- elle termine immédiatement la fonction. Les lignes qui suivent dans le corps ne sont pas exécutées.
Le second point est aussi important que le premier. Une fonction peut contenir plusieurs return; le premier atteint met fin à l'exécution. On tire parti de cette propriété pour écrire des fonctions qui décident, en sortant dès que la décision est prise.
Retourner tôt
def mention(note):
if note < 4:
return "insuffisant"
if note < 5:
return "suffisant"
if note < 5.5:
return "bien"
return "excellent"
print(mention(3.5))
print(mention(4.0))
print(mention(5.25))
print(mention(
insuffisant
suffisant
bien
excellent
Lisez le corps comme une cascade. Si la note est inférieure à 4, on sort tout de suite avec "insuffisant"; le reste de la fonction n'existe pas pour cet appel. Si l'on arrive à la deuxième ligne, c'est qu'on sait déjà que la note vaut au moins 4: le test note < 5 suffit donc à caractériser l'intervalle de 4 (inclus) à 5 (exclu), et il est inutile d'écrire 4 <= note < 5. Cette manière d'écrire — appelée retour anticipé (early return) — remplace avantageusement une cascade de if/elif/else imbriqués, parce qu'elle garde l'indentation plate.
Vérifiez les bords sur la sortie ci-dessus: mention(4.0) donne suffisant et non insuffisant, parce que 4.0 < 4 est faux. C'est exactement la limite de réussite du système suisse, et c'est le genre de détail qu'une fonction permet de vérifier une fois pour toutes au lieu de le redouter à chaque relecture.
Les quatre mentions et leurs seuils sont une convention propre à ce chapitre, choisie parce qu'elle donne quatre intervalles faciles à tester; d'autres chapitres de ce cours en utilisent une autre, avec d'autres libellés et d'autres bornes. Aucune n'est «la» bonne: ce qui est vrai en Suisse, c'est la limite de réussite à 4. C'est d'ailleurs la raison d'être de cette fonction — la règle vit à un seul endroit, elle porte un nom, et si votre école en utilise une autre, vous ne modifiez que ce corps-là.
Quand il n'y a pas de return
Une fonction n'est pas obligée de retourner quoi que ce soit. Dans ce cas, Python retourne quand même une valeur: la valeur spéciale None, qui signifie «rien».
def double_affiche(n):
print(2 * n)
def double_retourne(n):
return 2 * n
a = double_affiche(4)
b = double_retourne(4)
print(a)
print(b)
print(double_retourne(4) + 1)
8
None
8
9
Suivons la sortie ligne par ligne, car elle contient tout le sujet de la section suivante. Le 8 de la première ligne est affiché pendant l'appel double_affiche(4), par le print qui se trouve dans le corps. L'appel, lui, retourne None, qui est rangé dans a. Rien du tout n'est affiché pendant l'appel double_retourne(4): cette fonction ne contient aucun print; elle se contente de remettre 8 à l'appelant, qui le range dans b. Les deux print suivants affichent None puis 8. Et la dernière ligne montre ce qu'on peut faire d'une valeur retournée: l'utiliser dans un calcul.
La confusion la plus fréquente du chapitre
Voici le programme complet qui produit cette erreur, avec le message intégral:
def double_affiche(n):
print(2 * n)
total = double_affiche(4) + 1
8
Traceback (most recent call last):
File "double.py", line 5, in <module>
total = double_affiche(4) + 1
~~~~~~~~~~~~~~~~~~^~~
TypeError: unsupported operand type(s) for +: 'NoneType' and 'int'
Prenez le temps de lire cette trace, parce qu'elle a une structure que vous reverrez cent fois. Le 8 de la première ligne prouve que la fonction a bien été exécutée — l'erreur n'est pas dans le corps. Traceback (most recent call last) annonce la pile des appels, du plus ancien au plus récent. La ligne File …, line 5, in module donne le fichier et la ligne fautive, in module signifiant «au niveau principal du fichier, pas dans une fonction». Vient ensuite la ligne de code, puis des tildes et un accent circonflexe qui soulignent précisément la partie en cause: ici l'addition. La dernière ligne, enfin, est la seule qui dise quoi: type d'erreur, puis message.
Laquelle des deux écritures faut-il choisir? Presque toujours celle qui retourne, pour trois raisons: la valeur reste disponible, la fonction reste utilisable dans un programme qui n'affiche rien (par exemple pour écrire un fichier, au chapitre 8), et elle devient testable — on ne peut pas écrire d'assert sur quelque chose qui s'est contenté de traverser l'écran. Les fonctions qui affichent existent, bien sûr, mais elles sont la couche extérieure du programme: peu nombreuses, sans calcul à l'intérieur, tout en bas de la construction. Nous verrons cette organisation à l'œuvre dans le programme complet de la fin du chapitre.
Une fonction carre est définie par def carre(n): print(n * n), puis on exécute resultat = carre(5) suivi de print(resultat). Qu'affiche le programme?
Portée des variables
Ce que la fonction crée lui appartient
Un appel crée un espace de noms neuf, et cet espace disparaît au retour. Les noms qui y naissent — les paramètres, et toutes les variables affectées dans le corps — sont locaux: ils n'existent qu'entre le début et la fin de cet appel.
def surface(cote):
resultat = cote * cote
return resultat
print(surface(3))
print(resultat)
9
Traceback (most recent call last):
File "surface.py", line 7, in <module>
print(resultat)
^^^^^^^^
NameError: name 'resultat' is not defined
La première ligne a fonctionné: 9 s'affiche. La seconde échoue, et le message dit exactement la vérité: au moment où print(resultat) s'exécute, il n'existe aucun nom resultat. Il en a existé un, pendant l'appel, puis il a été abandonné avec le reste de l'espace local.
La conséquence est libératrice: vous pouvez appeler vos variables locales comme vous voulez. Deux fonctions écrites par deux personnes différentes peuvent toutes deux utiliser un resultat, un i ou un total sans jamais se gêner. C'est précisément ce qui rend une fonction réutilisable: elle n'exige rien de son environnement et ne lui laisse rien derrière elle.
Dans l'autre sens, une fonction peut lire un nom global:
limite = 4.0
def est_reussi(note):
return note >= limite
print(est_reussi(4.5))
print(est_reussi(3.5))
True
False
Cela marche, et c'est même parfois ce qu'on veut: limite est ici une constante du programme, écrite en un seul endroit. On réserve cet usage aux constantes, et rien d'autre. Une fonction qui lit une variable globale qui change au cours du programme ne donne plus la même réponse aux mêmes arguments, ce qui la rend impossible à tester et pénible à comprendre.
Un nom local qui masque un nom global
Que se passe-t-il quand un nom existe des deux côtés? Le nom local gagne, pendant l'appel.
taux = 0.08
def prix_ttc(prix_ht):
taux = 0.026
return prix_ht * (1 + taux)
print(prix_ttc(100))
print(taux)
102.60000000000001
0.08
Deux choses à observer. D'abord, dans le corps, taux désigne le taux local, 0,026: le taux global n'est pas consulté, il est masqué (shadowed). Ensuite — et c'est le point essentiel — l'affectation locale n'a pas touché la variable globale: après l'appel, taux vaut toujours 0,08 au niveau du fichier. Ce sont deux noms distincts qui s'écrivent pareil, comme deux personnes qui portent le même prénom dans deux classes différentes.
Au passage, admirez 102.60000000000001. Le chapitre 1 a montré que 0.1 + 0.2 ne donne pas exactement 0.3; ici, ne donne pas exactement 102,6. Ce n'est pas un défaut de votre machine, c'est la représentation binaire des nombres à virgule. Retenez la règle qui en découle et qui nous servira jusqu'à la fin du cours: on ne compare jamais deux nombres à virgule avec ==; on vérifie que leur écart est plus petit qu'une tolérance.
Affecter un nom global depuis une fonction: UnboundLocalError
Voici maintenant l'erreur la plus déroutante du chapitre. On veut compter les appels d'une fonction, ce qui semble parfaitement raisonnable:
appels = 0
def enregistrer(note):
appels = appels + 1
return note
print(enregistrer(4.5))
print(appels)
Traceback (most recent call last):
File "compteur.py", line 8, in <module>
print(enregistrer(4.5))
~~~~~~~~~~~^^^^^
File "compteur.py", line 4, in enregistrer
appels = appels + 1
^^^^^^
UnboundLocalError: cannot access local variable 'appels' where it is not associated with a value
Cette trace comporte deux blocs File, et c'est normal: l'erreur s'est produite dans enregistrer, qui avait été appelée depuis la ligne 8 du fichier. On lit une trace de bas en haut quand on cherche la cause — la dernière ligne dit quoi, l'avant-dernier bloc dit où exactement, et les blocs au-dessus disent comment on en est arrivé là.
Le message paraît absurde: appels existe, il vaut 0, il est juste au-dessus. Voici ce qui se passe réellement. Avant d'exécuter une fonction, Python lit tout son corps et dresse la liste des noms qui y sont affectés. Ces noms-là seront locaux, pour tout l'appel, du début à la fin. Or appels est affecté à la ligne 4 — donc appels est local dans enregistrer. Quand l'exécution arrive à appels = appels + 1, il faut évaluer le membre de droite: on demande la valeur du appels local, qui n'a pas encore été affecté. D'où le message, dont on peut maintenant traduire chaque mot: «impossible d'accéder à la variable locale appels, qui n'est associée à aucune valeur».
global, et pourquoi c'est presque toujours la mauvaise réponse
Python offre un moyen de forcer les choses: la déclaration global.
appels = 0
def enregistrer(note):
global appels
appels = appels + 1
return note
print(enregistrer(4.5))
print(enregistrer(5.0))
print(appels)
4.5
5.0
2
Cela fonctionne. Cela ne se fait pratiquement jamais, et il faut savoir pourquoi, sinon la tentation revient à chaque difficulté. Une fonction qui modifie une variable globale a un effet de bord: son appel change l'état du programme en dehors d'elle. Trois conséquences, toutes désagréables:
- elle n'est plus testable: son résultat ne dépend plus seulement de ses arguments, mais de l'ordre dans lequel on l'a appelée auparavant;
- elle n'est plus lisible depuis l'appel: la ligne
enregistrer(4.5)ne laisse pas deviner qu'une variable ailleurs vient de changer; - elle n'est plus réutilisable: pour l'employer dans un autre programme, il faut y recréer la variable globale qu'elle suppose.
La même intention s'écrit sans global, en faisant passer l'information par les arguments et par la valeur de retour:
def enregistrer(note, deja_vues):
return deja_vues + 1
vues = 0
vues = enregistrer(4.5, vues)
vues = enregistrer(5.0, vues)
print(vues)
2
C'est trois caractères de plus à l'appel, et le gain est considérable: la fonction ne dépend que de ce qu'on lui donne, elle ne modifie rien, et on peut l'interroger seule. Retenez cette transformation, on l'applique tout le temps: ce qui entre devient un paramètre, ce qui sort devient une valeur de retour.
Un fichier commence par total = 0. Une fonction contient la seule ligne total = total + 1, sans déclaration global. Que se passe-t-il quand on l'appelle?
Les paramètres sont des copies
Reste une question que l'on se pose dès qu'on a compris le reste: si une fonction modifie son paramètre, la variable de l'appelant change-t-elle? Non — du moins pour tous les types que nous connaissons à ce stade, les nombres, les booléens et les chaînes. Le paramètre est un nom local qui a reçu une copie de la valeur; le réaffecter ne touche pas le nom de l'appelant.
def salaire(heures, taux):
"""Salaire hebdomadaire, plafonne a 40 heures payees."""
if heures > 40:
heures = 40
return heures * taux
heures_semaine = 45
paye = salaire(heures_semaine, 32.5)
print(paye)
print(heures_semaine)
1300.0
45
La fonction a bien plafonné à 40 heures pour son calcul — — et pourtant heures_semaine vaut toujours 45 après l'appel. L'explorateur ci-dessous laisse jouer sur les deux variables de l'appelant et montre, à chaque instant, l'espace local de l'appel, la valeur retournée, et la variable de l'appelant qui ne bouge pas.
La fonction salaire(heures, taux) plafonne les heures payées à 40 puis retourne le salaire de la semaine (tarif fictif). Déplacez les curseurs: ils changent les variables du programme appelant. Observez l'espace local créé pour l'appel, la valeur retournée — et surtout la variable heures_semaine de l'appelant, qui ne bouge jamais, même quand la fonction réaffecte son paramètre heures.
Déplacez le curseur des heures au-delà de 40: la ligne heures = 40 s'exécute, la valeur locale change, le salaire plafonne — et la case «heures après l'appel» continue d'afficher la valeur du curseur. C'est la démonstration visuelle du fait que la fonction travaille sur une copie. (Nous verrons au chapitre 6 que le tableau se complique avec les listes, qui sont modifiables: la copie porte alors sur la référence, pas sur le contenu. C'est le seul point de ce chapitre qui demandera une nuance plus tard.)
Documenter: la docstring
Une fonction bien nommée dit ce qu'elle fait; elle ne dit pas ce qu'elle attend, ni ce qu'elle rend exactement, ni ce qu'elle refuse. C'est le rôle de la docstring (chaîne de documentation): une chaîne de caractères placée en première instruction du corps, entre triples guillemets.
def note_sur_six(points, total=100):
"""Convertit un nombre de points en note suisse entre 1 et 6.
points: nombre de points obtenus (0 <= points <= total)
total: nombre de points de l'epreuve, 100 par defaut
Retourne un nombre a virgule: 1 pour 0 point, 6 pour le maximum.
"""
return 1 + 5 * points / total
Les triples guillemets """ permettent d'écrire une chaîne sur plusieurs lignes. La convention, décrite dans le document PEP 257 de la documentation Python, tient en peu de règles:
- une première ligne courte, qui tient sur une ligne et commence par un verbe à l'indicatif décrivant l'effet: «Convertit…», «Retourne…», «Affiche…»;
- si le besoin s'en fait sentir, une ligne vide, puis les détails: le sens de chaque paramètre, la valeur retournée, les cas particuliers;
- des guillemets fermants sur leur propre ligne quand la docstring est longue.
Une docstring n'est pas un commentaire. Un commentaire # disparaît au chargement du fichier; la docstring, elle, reste attachée à la fonction pendant toute l'exécution, et les outils savent la lire. Le plus simple de ces outils est la fonction native help():
help(note_sur_six)
Help on function note_sur_six in module __main__:
note_sur_six(points, total=100)
Convertit un nombre de points en note suisse entre 1 et 6.
points: nombre de points obtenus (0 <= points <= total)
total: nombre de points de l'epreuve, 100 par defaut
Retourne un nombre a virgule: 1 pour 0 point, 6 pour le maximum.
Python affiche le nom, la signature — c'est-à-dire la liste des paramètres avec leurs valeurs par défaut — puis la docstring. C'est exactement ce que vous obtenez en tapant help(len) ou help(print) pour les fonctions natives: vos fonctions et celles de Python sont documentées par le même mécanisme, et se consultent de la même manière.
Premiers tests: assert
Nous voici au point le plus utile du chapitre. Vous savez maintenant écrire une fonction; il vous manque le moyen de savoir si elle est juste — et surtout de le savoir encore dans trois semaines, après l'avoir modifiée.
Dire ce que la fonction doit satisfaire
L'instruction assert prend une expression. Si l'expression est vraie, il ne se passe rien du tout: le programme continue, silencieusement. Si elle est fausse, le programme s'arrête immédiatement avec une AssertionError.
assert 2 + 2 == 4
assert 10 // 3 == 3
Ces deux lignes ne produisent aucune sortie: c'est le comportement normal d'un test qui passe. Le silence est le succès — l'idée déroute au début, puis devient confortable.
L'intérêt n'est pas de vérifier 2 + 2, qui n'a jamais surpris personne. Il est d'écrire, juste sous une fonction, les trois ou quatre phrases qui disent ce qu'elle promet:
def note_sur_six(points, total):
return 1 + 5 * points / total
assert note_sur_six(0, 40) == 1
assert note_sur_six(40, 40) == 6
assert note_sur_six(20, 40) == 3.5
Lisez ces trois lignes comme un contrat en français: «zéro point donne la note 1; le maximum des points donne 6; la moitié des points donne 3,5». Elles ne coûtent presque rien à écrire, elles sont vérifiées à chaque exécution du fichier, et surtout elles disent quelque chose que le corps de la fonction ne dit pas: l'intention. Le corps dit comment on calcule; les assertions disent ce que le calcul doit produire.
La troisième mérite un commentaire. Sur une échelle linéaire de 1 à 6, la moitié des points donne 3,5 — et non 4, comme on le croit souvent. Si le règlement veut que la moitié des points donne exactement 4, alors la formule (4.1) est la mauvaise formule, et l'assertion vient de le révéler avant que qui que ce soit ne reçoive une note fausse. C'est cela, écrire un test: forcer quelqu'un à décider ce que le programme doit faire, avant de vérifier qu'il le fait.
Quand une assertion échoue
Voici une fonction qui arrondit une note au demi-point le plus proche — l'opération que fait toute école suisse avant de publier des résultats. La méthode paraît évidente: multiplier par deux, arrondir à l'entier, diviser par deux.
def arrondi_demi_point(note):
return round(note * 2) / 2
assert arrondi_demi_point(4.2) == 4.0
assert arrondi_demi_point(4.5) == 4.5
assert arrondi_demi_point(4.25) == 4.5
print("Les trois tests passent.")
Traceback (most recent call last):
File "arrondi.py", line 7, in <module>
assert arrondi_demi_point(4.25) == 4.5
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError
Les deux premières assertions sont passées en silence. La troisième a échoué, le message la désigne caractère par caractère, et la ligne print n'a jamais été atteinte: assert arrête le programme.
Pourquoi arrondi_demi_point(4.25) ne donne-t-il pas 4,5? Parce que round de Python n'arrondit pas «0,5 vers le haut»: il arrondit vers l'entier pair le plus proche en cas d'égalité parfaite. C'est l'arrondi dit du banquier, choisi pour ne pas biaiser une longue série de valeurs vers le haut. Vérifiez-le vous-même: round(8.5) donne 8, round(9.5) donne 10, round(2.5) donne 2. Et , arrondi à 8, divisé par 2: 4,0.
Aucune documentation n'aurait attiré votre attention là-dessus. C'est le test qui l'a fait, et il l'a fait en trois lignes. Corrigeons la fonction. Pour arrondir «à partir de la moitié vers le haut» — la règle des notes — on ajoute 0,5 puis on tronque, ce que fait int pour un nombre positif:
def arrondi_demi_point(note):
return int(note * 2 + 0.5) / 2
assert arrondi_demi_point(4.2) == 4.0
assert arrondi_demi_point(4.5) == 4.5
assert arrondi_demi_point(4.25) == 4.5
print("Les trois tests passent.")
Les trois tests passent.
Le programme va jusqu'au bout et la ligne finale s'affiche. Notez que la ligne print n'est là que pour nous rassurer pendant l'apprentissage; dans un vrai programme, on la retire et on laisse le silence faire son travail.
Choisir ses trois cas
Trois assertions par fonction sont un minimum raisonnable. Encore faut-il choisir lesquelles. Une méthode qui marche, dans cet ordre:
- un cas ordinaire, celui auquel vous pensiez en écrivant la fonction:
arrondi_demi_point(4.2). Il vérifie que le cas normal fonctionne; - les bords, c'est-à-dire les valeurs qui séparent deux comportements: 4,25 (juste à la moitié entre deux demi-points), 4,0 et 6,0 pour une note, 0 et le total pour des points, la valeur limite d'une condition. La grande majorité des bogues vit sur un bord, parce que c'est là que la pensée hésite;
- un cas dégénéré ou extrême: zéro, une valeur négative, une chaîne vide, le maximum. Que doit faire la fonction? Souvent la réponse est «ce cas ne doit pas se produire», et c'est une bonne réponse — mais il vaut mieux l'avoir décidée que l'avoir subie.
Pour mention, cela donnerait: un cas ordinaire (mention(5.25) vaut "bien"), les bords (mention(4.0) vaut "suffisant" et pas "insuffisant"; mention(5.5) vaut "excellent"), et les extrêmes (mention(1.0) et mention(6.0)). Trois lignes qui, à elles seules, empêchent à jamais de se tromper de sens dans une inégalité.
Une assertion qui explique son échec
AssertionError toute seule est un peu sèche. On peut ajouter un message, après une virgule:
def note_sur_six(points, total):
return 1 + 5 * points / total
assert note_sur_six(0, 40) == 1, "zero point doit donner la note 1"
assert note_sur_six(40, 40) == 6, "le maximum doit donner la note 6"
assert note_sur_six(20, 40) == 4, "la moitie des points doit donner 4"
Traceback (most recent call last):
File "tests_notes.py", line 7, in <module>
assert note_sur_six(20, 40) == 4, "la moitie des points doit donner 4"
^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: la moitie des points doit donner 4
La dernière ligne porte maintenant une phrase lisible. Cette forme est particulièrement utile quand l'assertion vit loin de son auteur: dans un travail rendu, dans un fichier long, ou dans six mois.
Un fichier contient trois lignes assert à la suite. La deuxième est fausse. Que voit-on à l'écran?
Décomposer un programme
Le problème
Programmons maintenant quelque chose d'une taille réaliste. Un enseignant veut imprimer le bulletin d'un étudiant: trois épreuves notées sur des totaux différents, chacune convertie en note suisse, chaque note arrondie au demi-point, une moyenne elle aussi arrondie, la mention correspondante et la décision de réussite. Les données sont fictives, et volontairement les mêmes que celles du début du chapitre: 34 points sur 40, 51 sur 60, 18 sur 25.
Écrit d'un seul bloc, ce programme ferait une cinquantaine de lignes sans structure, où l'on chercherait la formule d'arrondi à la loupe. Écrit en fonctions, il se lit comme une table des matières. La méthode est toujours la même: on cherche les phrases. Chaque fois que vous pouvez dire «il faut convertir des points en note», «il faut arrondir au demi-point», «il faut décider de la mention», vous tenez une fonction, et son nom est dans la phrase.
Les fonctions de calcul
La fonction qui affiche
Le calcul étant fait et vérifié, il reste à montrer le résultat. Cette partie-là, et elle seule, a le droit d'appeler print. Une petite fonction intermédiaire fabrique une ligne de texte sans l'afficher, ce qui la rend testable elle aussi:
def ligne(titre, note):
"""Retourne une ligne de bulletin, sans l'afficher."""
return titre + " : " + str(arrondi_demi_point(note))
def afficher_bulletin(nom, note_1, note_2, note_3):
"""Affiche le bulletin complet d'un etudiant. Ne retourne rien."""
moyenne = arrondi_demi_point(moyenne_trois(note_1, note_2, note_3))
print("Bulletin de " + nom)
print(ligne("Epreuve 1", note_1))
print(ligne("Epreuve 2", note_2))
print(ligne("Epreuve 3", note_3))
print(
ligne utilise la fonction native str, vue au chapitre 1, pour transformer un nombre en chaîne: on ne peut pas concaténer directement une chaîne et un nombre. (Le chapitre 5 montrera une façon bien plus agréable d'écrire ces assemblages.) afficher_bulletin, elle, ne calcule presque rien: elle compose. Elle appelle moyenne_trois, puis arrondi_demi_point sur le résultat, puis ligne trois fois, puis mention, puis est_reussi. Six fonctions, chacune écrite et vérifiée séparément, s'emboîtent en dix lignes que l'on lit sans effort.
C'est l'idée qu'il faut emporter de ce chapitre: une fonction peut en appeler d'autres, et un programme n'est rien d'autre qu'un petit nombre de fonctions qui s'appellent. Le chapitre 9 ira jusqu'au bout de cette idée en montrant qu'une fonction peut même s'appeler elle-même.
Il ne reste qu'à lancer le tout:
afficher_bulletin("Alice", note_sur_six(34, 40), note_sur_six(51, 60), note_sur_six(18, 25))
print()
afficher_bulletin("Hugo", note_sur_six(18, 40), note_sur_six(30, 60), note_sur_six(12, 25))
Bulletin de Alice
Epreuve 1 : 5.5
Epreuve 2 : 5.5
Epreuve 3 : 4.5
Moyenne : 5.0 (bien)
Resultat : reussi
Bulletin de Hugo
Epreuve 1 : 3.5
Epreuve 2 : 3.5
Epreuve 3 : 3.5
Moyenne : 3.5 (insuffisant)
Resultat : echoue
Les trois appels imbriqués de la première ligne illustrent une dernière fois la règle: un appel qui retourne un nombre peut s'écrire partout où un nombre peut s'écrire, y compris comme argument d'un autre appel. Python évalue d'abord les trois note_sur_six, puis appelle afficher_bulletin avec les trois valeurs obtenues.
Ce que la décomposition a rendu possible
Comptons ce que nous avons gagné, parce que ce n'est pas seulement une question de goût:
- Chaque morceau est vérifié. Les quatorze assertions testent cinq fonctions séparément. Si le bulletin d'Alice était faux, il suffirait de relancer le fichier pour savoir laquelle des cinq est en cause — ou d'apprendre qu'elles sont toutes correctes et que l'erreur est dans la composition.
- Chaque morceau est réutilisable.
arrondi_demi_pointservira au chapitre 6 pour arrondir toute une liste de notes, sans être réécrite.mentionservira dans un autre programme. - Le changement est local. Passer la limite de réussite de 4 à 3,75 demande de modifier une ligne, dans
est_reussi, et de corriger deux assertions. Rien d'autre. - Le programme se lit.
afficher_bulletinse lit à voix haute, et cette lecture est une description correcte de ce que fait le programme.
Ce qui fait une bonne fonction
Vous savez désormais écrire des fonctions. Voici, en quatre règles, ce qui distingue une fonction dont on est content de celle qu'on regrette.
Un seul travail
Une fonction fait une chose, et son nom la nomme entièrement. Si vous devez décrire la fonction avec un «et» — «elle calcule la moyenne et l'affiche» — il y en a deux.
Un nom qui dit ce qu'elle retourne
Les noms qui marchent suivent deux formes simples. Une fonction qui calcule porte un nom de chose, celle du résultat: note_sur_six, moyenne_trois, prix_ttc, arrondi_demi_point. On peut alors lire l'appel comme la valeur qu'il vaut. Une fonction qui agit porte un verbe à l'infinitif: afficher_bulletin, enregistrer, lire_fichier.
Une fonction qui répond par oui ou par non porte un nom qui s'entend comme une question: est_reussi, contient_une_voyelle. On peut alors la lire à voix haute dans un if: «si est réussi…».
Les noms à fuir sont ceux qui ne disent rien (traiter, calcul, faire_le_truc, fonction2), et ceux qui mentent — le pire de tous étant une fonction nommée calculer_quelque_chose qui, en réalité, affiche.
Peu de paramètres
Deux ou trois paramètres se retiennent; six ne se retiennent pas, et l'on finit par inverser deux arguments du même type sans que rien ne le signale. Notre afficher_bulletin(nom, note_1, note_2, note_3) est déjà à la limite du confortable. Quand la liste s'allonge, c'est souvent que plusieurs paramètres vont ensemble et forment en réalité une seule chose que le programme n'a pas encore nommée: trois notes, c'est un relevé de notes. Les chapitres 6 et 7 donneront les outils — listes et dictionnaires — pour grouper ces valeurs et ramener la fonction à un ou deux paramètres.
Pas d'effet de surprise
Une fonction qui calcule ne doit modifier ni l'état du programme ni l'écran. Une fonction qui affiche ne doit pas calculer en cachette. Cette discipline porte un nom dans le métier: on dit qu'une fonction sans effet de bord est pure. Les fonctions pures sont celles qu'on peut tester par un assert, appeler dans n'importe quel ordre, réutiliser ailleurs, et remplacer par une meilleure version sans rien casser autour.
Synthèse
- Une fonction nomme un morceau de raisonnement: elle supprime les répétitions, dit l'intention à la place d'une formule, et rend une partie du programme vérifiable isolément.
def nom(parametres):définit et n'exécute rien; ce sont les parenthèses de l'appel qui déclenchent l'exécution du corps. Les paramètres sont les noms de la définition, les arguments les valeurs de l'appel; ils se passent par position ou par nom, et un paramètre peut avoir une valeur par défaut — évaluée une seule fois, à la définition, donc réservée aux constantes littérales.returnrend une valeur et termine la fonction; sansreturn, la fonction rendNone. Retourner n'est pas afficher: la valeur retournée se réutilise, la valeur affichée est perdue, et uneTypeErrorqui parle deNoneTypesignale presque toujours cette confusion.- Les noms affectés dans une fonction sont locaux: ils disparaissent au retour, peuvent masquer un nom global de même orthographe, et affecter un nom global sans le déclarer produit une
UnboundLocalError.globalexiste, ne se justifie presque jamais, et se remplace par un paramètre plus une valeur de retour. - Une docstring en première ligne du corps dit ce que la fonction retourne et ce qu'elle attend;
help()la restitue, comme pour les fonctions natives. asserténonce ce que la fonction doit satisfaire: silencieux quand c'est vrai,AssertionErrorquand c'est faux. Trois assertions par fonction — un cas ordinaire, un bord, un extrême — écrites avant de faire confiance au code, valent mieux qu'une longue relecture.- Un programme se décompose en petites fonctions qui calculent et retournent, et en une mince couche qui affiche. Une bonne fonction fait un seul travail, porte un nom qui dit son résultat, prend peu de paramètres et ne surprend personne.
Les trois programmes suivants sont utilisés par la série d'exercices ci-dessous.
Programme A
def incremente(x):
x = x + 1
return x
y = 3
incremente(y)
print(y)
Programme B
def somme(a, b=2):
return a + b
print(somme(5))
print(somme(5, 3))
print(somme(b=5, a=1))
Programme C
def carre(n):
print(n * n)
resultat = carre(5)
print(resultat + 1)
Qu'affiche le programme A?
Exercices
Vous pouvez afficher le corrigé directement sous chaque énoncé après avoir cherché la solution.
Écrivez deux fonctions, chacune munie d'une docstring d'une ligne:
celsius_en_fahrenheit(celsius), qui applique la formule ;fahrenheit_en_celsius(fahrenheit), qui applique la conversion inverse.
Écrivez ensuite au moins cinq assertions: les deux points de référence de l'eau (0 °C et 100 °C), la valeur remarquable qui est la même dans les deux échelles, un point de la conversion inverse, et un aller-retour. Pour l'aller-retour, réfléchissez au piège des nombres à virgule signalé au chapitre 1. Affichez enfin la conversion des deux températures les plus froides de la semaine fictive du cours, °C et °C.
On donne ce programme, qui calcule le montant à payer pour une consommation d'électricité (tarif fictif de 0,28 franc par kilowattheure) sur trois mois:
def facture_energie(kwh):
print("A payer: " + str(kwh * 0.28) + " CHF")
facture_energie(120)
facture_energie(95)
facture_energie(140)A payer: 33.6 CHF
A payer: 26.6 CHF
A payer: 39.2 CHF
- Expliquez pourquoi il est impossible, avec cette fonction telle qu'elle est écrite, de calculer le total du trimestre.
- Réécrivez la fonction pour qu'elle retourne le montant au lieu de l'afficher, en donnant au prix du kilowattheure une valeur par défaut, et calculez le total.
- Affichez ce total arrondi aux 5 centimes.
Solution
- Prévoyez la sortie de ce programme, puis exécutez-le pour vérifier.
message = "global"
def afficher():
message = "local"
print(message)
afficher()
print(message)- Le programme suivant devait cumuler des montants. Exécutez-le, lisez l'erreur, expliquez-la, puis réparez-le sans utiliser
global.
total = 0
def ajouter(montant):
total = total + montant
return total
En Suisse, les montants en espèces sont arrondis aux 5 centimes. Avant d'écrire la moindre ligne de corps, écrivez cinq assertions qui décrivent complètement ce que doit faire une fonction arrondi_5_centimes(prix): un montant déjà multiple de 5 centimes, un montant qui doit descendre, un montant qui doit monter, un montant à trois décimales, et un montant proche de zéro. Écrivez ensuite le corps, et faites passer les cinq assertions.
Solution
Les assertions d'abord — c'est tout l'exercice. Il faut décider, avant de coder, que 2,02 descend à 2,00, que 2,03 monte à 2,05, et que la règle est «à partir de la moitié d'un pas, on monte».
def arrondi_5_centimes(prix):
"""Arrondit un prix en francs aux 5 centimes les plus proches."""
return int(prix * 20 + 0.5) / 20
assert arrondi_5_centimes(2.02) ==
Construisez un petit programme complet, décomposé en quatre fonctions de calcul plus une fonction de comparaison, et testé. Les tarifs sont fictifs: 0,28 franc par kilowattheure, 7,50 francs d'abonnement mensuel, et un taux de TVA de 8,1 %, qui est le taux normal suisse depuis le 1er janvier 2024.
presque_egal(a, b, tolerance=1e-9), qui compare deux nombres à virgule à une tolérance près;cout_energie(kwh, prix_kwh=0.28)etcout_abonnement(mois, prix_mois=7.5), hors taxe;montant_ttc(montant_ht, taux_tva=8.1), qui ajoute la TVA et arrondit aux 5 centimes;facture(kwh, mois), qui compose les précédentes.
Testez chaque fonction séparément, puis affichez la facture d'un trimestre à 450 kWh.
Solution
Les quatre fonctions de calcul, chacune d'une ou deux lignes:
def presque_egal(a, b, tolerance=1e-9):
"""Compare deux nombres a virgule a une tolerance pres."""
return abs(a - b) < tolerance
def
Références
- Downey, A. B., Think Python, 3e éd., O'Reilly, Sebastopol, chap. 3 («Functions») et chap. 6 («Return values»); une traduction française, Pensez en Python, est disponible librement.
- Swinnen, G., Apprendre à programmer avec Python 3, Eyrolles, Paris, chap. 7 («Fonctions originales»); l'ouvrage est disponible librement en ligne.
- Matthes, E., Python Crash Course, 3e éd., No Starch Press, San Francisco, chap. 8 («Functions»), pour les arguments nommés et les valeurs par défaut.
- Guttag, J. V., Introduction to Computation and Programming Using Python, 3e éd., MIT Press, Cambridge, chap. 4 («Functions, scoping, and abstraction»), pour la portée et l'abstraction.
- La documentation officielle Python, section «Defining Functions» du tutoriel, et le document PEP 257 pour les conventions de docstring (docs.python.org).