Aller au contenu principal

Bien écrire une fonction

Ce que ce chapitre apporte6 points
  • Écrire des fonctions qu'on relit sans effort, documentées par une docstring et annotées.
  • Refuser une valeur absurde avec raise, plutôt que de rendre un résultat faux.
  • Choisir entre ValueError et TypeError, et intercepter l'exception du côté de l'appelant.
  • Vérifier une fonction par des assert, y compris quand elle calcule avec des nombres à virgule.
  • Passer une fonction en paramètre à sorted, min ou max, et écrire une lambda.
  • Reconnaître une fonction récursive et son cas d'arrêt.

Une fonction qui calcule juste ne suffit pas. Celui qui la relit dans six mois, ou qui l'appelle depuis un autre fichier, a besoin de savoir ce qu'elle attend, ce qu'elle rend, et ce qu'elle fait d'une valeur absurde. Sans quoi elle rendra un résultat faux sans rien signaler, et l'erreur se découvrira très loin de sa cause.

Le chapitre précédent a posé la mécanique : def, paramètres, return. Celui-ci ajoute ce qui sépare une fonction qui marche d'une fonction sur laquelle on peut compter : refuser ce qui n'a pas de sens, se documenter, se vérifier seule, et se laisser passer en paramètre à une autre fonction.

Les règles d'or d'une bonne fonction

Une seule chose. Si on ne peut pas dire ce que fait la fonction en une phrase sans « et », c'est qu'il y en a deux.

Un nom qui dit le résultat. calculer_moyenne vaut mieux que traiter. Une fonction qui renvoie un booléen se nomme volontiers est_pair, contient, peut_ouvrir.

Tout par les paramètres. Ce dont elle a besoin entre par la porte, pas par une globale.

Un résultat par return. L'affichage appartient à celui qui appelle, pas à elle. Une fonction qui calcule et affiche est deux fois moins réutilisable.

Courte. Au-delà d'une vingtaine de lignes, il y a presque toujours une deuxième fonction cachée dedans.

Vérification rapideon peut se reprendre

Que fait return au milieu d'une fonction ?

Documenter avec une docstring

Un texte entre triples guillemets, juste après le def, dit ce que fait la fonction. C'est ce que renvoie help(), et ce que l'éditeur affichera quand on la réutilisera dans six mois.

main.py
Sortie
>_ Prêt à exécuter…

Une bonne docstring dit ce que la fonction renvoie, pas comment elle s'y prend : le comment est déjà dans le code, juste en dessous.

Annoter les types

Python n'impose aucun type, mais une fonction peut annoncer ceux qu'elle attend et celui qu'elle rend. L'annotation ne change rien à l'exécution : elle documente, et l'éditeur s'en sert pour signaler une erreur avant même de lancer le programme.

main.py
Sortie
>_ Prêt à exécuter…

Rien n'empêche d'appeler est_majeur("vingt") : Python n'y voit aucune objection et plantera plus loin, sur la comparaison. L'annotation sert l'humain et les outils, pas l'interpréteur.

Elle gagne surtout à être mise là où le doute coûte cher : une fonction qui rend None en cas d'échec et un nombre sinon s'annote -> float | None, ce qui prévient l'appelant qu'il devra vérifier.

Refuser une valeur absurde : raise

Une fonction reçoit parfois une valeur qui n'a aucun sens : une résistance négative, une note de 25 sur 20, une durée nulle par laquelle il faudrait diviser. Elle a alors trois options, et une seule est bonne.

Calculer quand même, c'est rendre un courant négatif que personne ne remarquera avant le banc d'essai. Renvoyer None, c'est reporter le problème : l'appelant qui oublie de vérifier se retrouve trois lignes plus loin avec la TypeError que le chapitre précédent montrait sur une fonction sans return, loin de la cause. La troisième option est de lever une exception avec raise : la fonction s'arrête sur-le-champ, et l'erreur porte un message qui dit exactement ce qui cloche.

Le bloc suivant est faux exprès : le second appel passe une résistance négative.

main.py
Sortie
>_ Prêt à exécuter…

Le premier appel affiche 0.022727272727272728, le second arrête tout sur ValueError: resistance impossible : -220 ohms. Le message nomme la valeur reçue : c'est elle qu'on cherchera en premier en lisant l'erreur.

raise, comme return, sort de la fonction
raise arrête la fonction aussi net que return, mais sans rien renvoyer : aucune variable de l'appelant ne reçoit de valeur, et le programme s'arrête, sauf si l'appelant a prévu d'intercepter l'erreur.
On place donc la vérification en tête de la fonction, avant tout calcul. Une fois la ligne du raise passée, le reste du corps peut compter sur une valeur saine.

Intercepter, c'est le rôle du try / except rencontré au chapitre Conditions et boucles. Les deux gestes se répondent : la fonction lève, l'appelant intercepte et décide quoi faire, ici passer à la résistance suivante. as e donne un nom à l'erreur interceptée, et l'afficher rend le message écrit dans le raise.

main.py
Sortie
>_ Prêt à exécuter…

Reste à choisir l'exception. Deux suffisent presque toujours :

ExceptionQuand la leverExemple
ValueErrorle type est le bon, mais la valeur est impossiblecourant(5, -220), une note de 25
TypeErrorc'est le type lui-même qui ne convient pascourant(5, "220")

Python suit lui-même cette règle : int("douze") lève une ValueError, car int accepte bien une chaîne, mais celle-ci ne représente aucun nombre ; "12" + 1 lève une TypeError, car on n'additionne pas une chaîne et un entier. En pratique, ValueError est de loin la plus fréquente : on vérifie des bornes bien plus souvent que des types.

Vérification rapideon peut se reprendre

Une fonction vitesse(distance, duree) reçoit une durée de -3 secondes. Que doit-elle faire ?

Vérifier une fonction par des assert

Une fonction qu'on vient d'écrire, on l'essaie : on l'appelle sur deux ou trois cas dont on connaît la réponse, et on regarde. assert fait de ce coup d'œil une vérification que le programme mène seul. assert condition, "message" ne fait rien si la condition est vraie, et arrête tout sur une AssertionError portant le message si elle est fausse.

main.py
Sortie
>_ Prêt à exécuter…

Le même test, sur une version fautive de la fonction :

main.py
Sortie
>_ Prêt à exécuter…

AssertionError: 6 x 4 / 2 doit donner 12, recu 24. Un bon message dit ce qui était attendu et, si possible, ce qu'on a obtenu : l'erreur se lit alors sans rouvrir le code. Un assert sans message ne dit que la ligne où il a échoué.

C'est l'outil qui juge tous les ateliers du parcours, l'écurie, NumPy et Matplotlib et les fonctions avancées : chaque étape se termine par une série d'assert, et elle est validée quand aucun ne proteste.

Une précaution, dès que la fonction calcule avec des nombres à virgule : 0.1 * 3 vaut 0.30000000000000004, et un assert tension(0.1, 3) == 0.3 échouerait sur une fonction parfaitement juste. On compare l'écart à une tolérance, abs(a - b) < 1e-9, ou l'on confie ce travail à math.isclose(a, b). Le chapitre sur les nombres à virgule explique d'où vient cette poussière d'arrondi.

main.py
Sortie
>_ Prêt à exécuter…
assert vérifie le programme, raise refuse les données
Un assert contrôle ce que le programmeur croit vrai : que sa fonction calcule juste. Il ne remplace pas le raise ValueError d'une fonction qui reçoit une valeur venue de l'extérieur, saisie ou fichier.
Python peut être lancé avec l'option -O, qui supprime tous les assert, et la vérification disparaîtrait avec eux.
Vérification rapideon peut se reprendre

Que fait assert moyenne([10, 20]) == 15, "moyenne fausse" quand la fonction est juste ?

Passer une fonction en paramètre

Au chapitre sur les bases, une variable est une étiquette collée sur une valeur. Une fonction n'échappe pas à la règle : def carre fabrique une fonction et colle dessus l'étiquette carre. Tout ce qui se fait avec une valeur se fait donc avec elle, y compris la passer à une autre fonction.

Tout tient dans les parenthèses : carre(4) appelle la fonction et vaut 16, carre sans parenthèses désigne la fonction elle-même, sans la lancer.

main.py
Sortie
>_ Prêt à exécuter…

La deuxième ligne affiche quelque chose comme <function carre at 0x...> : pas un résultat, la fonction. Et appliquer ne sait rien de ce qu'on lui passe ; elle l'appelle sur chaque valeur, et ce qui est calculé dépend entièrement de la fonction reçue.

Trier selon un critère : key=

C'est avec sorted, min et max qu'on se sert de ce geste tous les jours. Sur une liste de nombres, l'ordre va de soi. Sur une liste de relevés, il faut dire selon quoi comparer : c'est le rôle du paramètre key, qui attend une fonction. sorted l'appelle sur chaque élément et trie selon ce qu'elle renvoie ; reverse=True inverse l'ordre.

main.py
Sortie
>_ Prêt à exécuter…

max et min renvoient l'élément entier, le dictionnaire du relevé, et non la température qui a servi à comparer : d'où le ["capteur"] au bout de la ligne. C'est exactement le classement de l'atelier sur l'écurie, sorted(ecurie, key=score, reverse=True).

key=temperature, pas key=temperature()
Avec les parenthèses, on appelle la fonction tout de suite, sans argument, et Python proteste : TypeError: temperature() missing 1 required positional argument: 'releve'.
sorted ne veut pas un résultat, il veut la fonction, pour l'appeler lui-même sur chaque élément.

La fonction d'une ligne : lambda

Écrire un def pour un critère de tri qui ne sert qu'une fois est un peu lourd. lambda fabrique une fonction sans nom, réduite à une seule expression, directement là où on en a besoin : lambda c: c[1] se lit « la fonction qui, à c, associe c[1] ».

main.py
Sortie
>_ Prêt à exécuter…

lambda c: c[1] fait exactement ce que ferait def valeur(c): return c[1], sans le nom. La dernière ligne rappelle qu'il n'en faut même pas toujours : len est déjà une fonction, on la passe telle quelle.

lambda ou def ?
Une lambda convient à un critère court, lisible d'un coup d'œil, qui ne sert qu'à l'endroit où il est écrit.
Dès que le calcul dépasse une expression, qu'il sert deux fois, ou qu'on voudrait le tester seul, un def avec un nom qui dit ce qu'il calcule vaut mieux : key=score se relit mieux qu'une lambda de trois termes. Et une lambda rangée dans une variable, f = lambda x: 2 * x, est un def qui n'ose pas dire son nom : autant l'écrire.

Ce geste dépasse de loin le tri. Les bibliothèques scientifiques sont bâties dessus : on leur donne une fonction, et ce sont elles qui l'appellent autant de fois qu'il le faut. Au chapitre sur SciPy, brentq(f, 2, 3) cherche un zéro de f entre 2 et 3 en l'évaluant aux points que choisit l'algorithme, et quad(lambda x: np.exp(-x**2), 0, 2) calcule une intégrale à partir de la fonction qu'on lui passe. Écrire brentq(f(x), 2, 3) serait la même faute que key=temperature().

Vérification rapideon peut se reprendre

On veut les relevés du plus chaud au plus froid. Quelle écriture est juste ?

Une fonction qui s'appelle elle-même

Rien n'interdit à une fonction de s'appeler dans son propre corps : on la dit récursive. L'idée est de ramener un problème à un problème plus petit du même genre. La factorielle de 5, c'est 5 fois la factorielle de 4, et ainsi de suite jusqu'à un cas si petit qu'on connaît la réponse sans calcul.

main.py
Sortie
>_ Prêt à exécuter…

Ce cas-là, le cas d'arrêt, est la ligne dont tout dépend. Chaque appel attend le résultat du suivant : factorielle(5) attend factorielle(4), qui attend factorielle(3), jusqu'à factorielle(1) qui répond 1 sans rien demander. Les multiplications se font alors en remontant : 1, 2, 6, 24, 120.

Sans cas d'arrêt, la descente ne finit jamais. Python ne tourne pas indéfiniment pour autant : chaque appel en attente occupe un peu de mémoire, et au-delà d'environ un millier d'appels empilés, il abandonne. Le bloc suivant est faux exprès.

main.py
Sortie
>_ Prêt à exécuter…

RecursionError: maximum recursion depth exceeded. Devant cette erreur, on cherche d'abord le cas d'arrêt : absent, ou jamais atteint parce que l'appel récursif ne s'en rapproche pas.

La récursivité se pense indépendamment de Python : le chapitre d'algorithmique sur la récursivité la déroule pas à pas, pile des appels comprise. En Python, pour un calcul comme la factorielle, une boucle for fait le même travail sans limite de profondeur ; la récursivité prend l'avantage sur les structures qui se ramifient, comme une arborescence de dossiers.

À mettre en pratique

La moyenne pondérée. Écrire moyenne_ponderee(notes, coefficients) qui renvoie la moyenne des notes pesées par leurs coefficients. La fonction refuse deux listes de longueurs différentes, et refuse une somme de coefficients nulle, qui ferait une division par zéro. Les assert du bas jugent le résultat.

main.py
Sortie
>_ Prêt à exécuter…
Afficher la solution
main.py
Sortie
>_ Prêt à exécuter…

Trois points valent qu'on s'y arrête.

Les deux vérifications sont en tête, avant tout calcul : une fois ces deux lignes passées, le reste du corps peut compter sur des données saines. Chaque message cite la valeur reçue, ce qui évite de rouvrir le code pour comprendre l'erreur.

La division par la somme des coefficients ne peut plus échouer, puisque le cas zéro a été écarté. C'est ce que vaut un raise posé au bon endroit : il retire un cas à traiter au lieu d'en ajouter un.

Et la comparaison finale se fait par math.isclose, non par == : 12.5 tombe juste ici, mais le jour où les coefficients donnent un quotient périodique, l'égalité stricte échouerait sur une fonction pourtant correcte.

Synthèse

  • Une bonne fonction fait une seule chose, reçoit tout par ses paramètres, et rend par return.
  • Une docstring, entre triples guillemets sous le def, dit ce que la fonction renvoie ; help() la relit.
  • Une annotation de type documente et outille, mais n'empêche rien à l'exécution.
  • raise ValueError(...) refuse une valeur impossible : la fonction s'arrête sans rien renvoyer. TypeError quand c'est le type qui ne va pas. La vérification se place en tête.
  • La fonction lève, l'appelant intercepte avec try / except.
  • assert condition, "message" vérifie une fonction ; deux flottants se comparent avec une tolérance ou math.isclose, jamais avec ==.
  • assert contrôle le programme, raise refuse les données venues du dehors.
  • Une fonction est une valeur : f la désigne, f() l'appelle. sorted, min et max la reçoivent par key= ; une lambda suffit pour un critère d'une ligne.
  • Une fonction récursive s'appelle elle-même et exige un cas d'arrêt, sans quoi c'est la RecursionError.

Mettre en pratique