Workshop - Fonctions avancées
Ce que ce chapitre apporte
- Relire et écrire une fonction Python sans hésiter sur la syntaxe.
- Maîtriser les arguments : nommés, par défaut, en nombre variable, et le déballage à l'appel.
- Manipuler une fonction comme une valeur : la ranger dans une variable, la passer en paramètre, la renvoyer.
- Écrire des fermetures, des fonctions qui se souviennent du contexte où elles sont nées.
- Écrire et comprendre un décorateur, qui réunit les trois points précédents.
Atelier à trous. Chaque bloc contient des
TODOà compléter : remplacer les points de suspension, puis exécuter. Un bloc s'exécute seul, sans voir les précédents : les données dont il a besoin sont recopiées en tête. Les lignesassertdu bas servent de juge : si aucune ne proteste et que le message final s'affiche, la phase est validée.
Bienvenue chez Da Lambda.
Da Lambda est une pizzeria qui ouvre la commande en ligne. Son logiciel de caisse tient dans un script de six cents lignes écrit d'un seul tenant, où le calcul du prix est recopié à onze endroits. Chaque promotion ajoutée par le patron a demandé de retoucher toutes les copies, et la dernière en a oublié deux : pendant une semaine, la calzone était à moitié prix le mardi.
La mission consiste à réécrire le moteur de commande avec des fonctions, puis avec les formes avancées que Python offre : celles qui permettent d'ajouter une promotion, un journal ou un contrôle sans toucher au code qui marche déjà.
On suppose ici qu'on a déjà programmé, en Python ou dans un autre langage, mais que la syntaxe est un peu loin. La phase 0 la remet en place.
Déroulé
| Durée | Phase | Notions |
|---|---|---|
| 10 min | 0. Remise en route | def, return, dictionnaires, f-strings |
| 10 min | 1. Appeler proprement | Arguments nommés, valeurs par défaut, défaut mutable |
| 15 min | 2. Un nombre d'arguments variable | *args, **kwargs, déballage |
| 20 min | 3. La fonction est une valeur | Fonction en paramètre, lambda, key= |
| 15 min | 4. Des fonctions qui fabriquent des fonctions | Fermetures, nonlocal |
| 15 min | 5. Les décorateurs | @, functools.wraps |
| 5 min | Vérification | Quiz et synthèse |
Deux défis bonus attendent ceux qui terminent en avance : un décorateur paramétré et un générateur.
Phase 0 : Remise en route
Pour qui vient d'un autre langage, voici ce qui change en Python.
| Ailleurs | En Python |
|---|---|
Accolades { } autour d'un bloc | Deux-points en fin d'en-tête, puis indentation de 4 espaces |
int prix(int x) | def prix(x):, aucun type à déclarer |
null | None |
cond ? a : b | a if cond else b |
&& et ! | and, or, not |
for (i = 0; i < n; i++) | for i in range(n): |
"Total : " + total | f"Total : {total}" |
Table associative, Map | Dictionnaire {"clé": valeur} |
Le bloc suivant rassemble l'essentiel. On le lit, on l'exécute, on vérifie qu'il ne reste aucune ligne mystérieuse.
1.Une fonction contient total * 2 sur sa dernière ligne, sans return. Que vaut f(10) ?
2.Que fait la ligne prix_pizza seule, sans parenthèses ?
3.Que renvoie CARTE["hawaienne"] si la clé n'existe pas ?
Mission 0. Écrire prix_pizza(nom, taille), qui renvoie le prix d'une pizza selon sa taille. Une pizza absente de la carte doit lever une ValueError, pas une KeyError que le serveur ne comprendrait pas.
Phase 1 : Appeler proprement
Python associe les arguments aux paramètres de deux façons : par position, dans l'ordre, ou par nom, dans n'importe quel ordre. Les deux se mélangent, à condition que les positionnels viennent d'abord.
Dans une signature, une étoile seule * sépare les paramètres ordinaires des paramètres nommés uniquement : tout ce qui la suit ne peut être passé que sous la forme nom=valeur.
L'intérêt se voit à l'appel. Dans commander("regina", "L", 2, True), personne ne devine ce que signifie True sans ouvrir la définition. Python refuse donc cet appel :
Ce bloc-ci n'a pas d'erreur. Il fait pourtant quelque chose d'inattendu : on l'exécute avant de lire la suite.
def est une instruction exécutée une fois, au chargement du programme. La liste vide [] est fabriquée à ce moment-là, puis réutilisée à chaque appel qui ne fournit pas l'argument.Alice et Bob partagent donc la même liste, et chaque client hérite des suppléments du précédent.
La règle : jamais de liste, de dictionnaire ou d'ensemble comme valeur par défaut. On met
None, et on crée l'objet dans le corps de la fonction.
Mission 1. Corriger ajouter_supplement pour que chaque appel sans liste reparte d'une liste neuve.
Phase 2 : Un nombre d'arguments variable
Un ticket de caisse peut compter une pizza ou douze. Plutôt que d'exiger une liste, la fonction peut accepter autant d'arguments qu'on veut.
Dans une signature, *args ramasse tous les arguments positionnels en trop dans un tuple, et **kwargs ramasse tous les arguments nommés en trop dans un dictionnaire. Les noms args et kwargs sont une convention : ce sont les étoiles qui comptent.
print est déclarée ainsi : print(*objets, sep=" ", end="\n"). C'est pour cela qu'elle accepte n'importe quel nombre de valeurs, et que sep doit être nommé : il vient après *objets.
L'ordre complet d'une signature est toujours le même : def f(obligatoire, avec_defaut=0, *args, nomme_seul, **kwargs).
Les étoiles fonctionnent aussi dans l'autre sens, à l'appel : elles déballent une séquence en arguments positionnels, un dictionnaire en arguments nommés. C'est ainsi qu'on branche une fonction sur des données reçues d'ailleurs, comme une commande arrivée en JSON.
Si le JSON contient une clé que la fonction ne connaît pas, Python lève une TypeError. C'est une protection : une faute de frappe dans le nom d'un champ se voit tout de suite.
Mission 2. Écrire total_ticket, qui accepte autant de prix qu'on veut et une remise nommée, puis creer_pizza, qui décrit une pizza par un dictionnaire. Enfin, appeler creer_pizza en déballant les données fournies.
Phase 3 : La fonction est une valeur
En Python, une fonction est un objet comme un autre, au même titre qu'un nombre ou une liste. happy_hour désigne la fonction, happy_hour(25) l'appelle. On peut donc la ranger dans une variable, dans un dictionnaire, ou la passer en paramètre.
Le jour où le patron invente une promotion, on écrit une fonction et on l'ajoute au dictionnaire. encaisser ne change pas d'une ligne. Pour qui connaît les patrons de conception, c'est le patron Stratégie, sans interface ni classe.
L'erreur la plus fréquente consiste à mettre des parenthèses là où il n'en faut pas :
happy_hour() tente d'appeler la fonction tout de suite, sans argument, avant même d'entrer dans encaisser. On voulait lui confier la fonction, pas son résultat.
Les fonctions anonymes : lambda
Écrire un def complet pour une promotion d'une ligne est un peu lourd. lambda crée une fonction sur place, sans nom.
lambda paramètres: expression crée une fonction qui renvoie la valeur de l'expression. Le corps tient en une seule expression : ni return, ni if sur plusieurs lignes, ni boucle.
promo = lambda t: t * 0.9 fonctionne, mais la fonction s'appellera <lambda> dans les messages d'erreur. Dès qu'on veut un nom, on écrit un def.La lambda est faite pour une petite fonction jetable, passée directement en argument.
Trier et chercher avec key=
C'est là que les lambdas servent le plus. sorted, min et max acceptent un paramètre key : une fonction appliquée à chaque élément, dont le résultat sert à comparer.
map(f, liste) et filter(f, liste) existent et prennent une fonction. En Python, on leur préfère presque toujours une compréhension, plus lisible :[round(p * 0.8, 2) for p in prix] plutôt que list(map(lambda p: round(p * 0.8, 2), prix)).
Mission 3. Trier la carte, écrire une fonction qui applique n'importe quelle promotion à toute la carte, et écrire une promotion sous forme de lambda.
Phase 4 : Des fonctions qui fabriquent des fonctions
Le patron veut des codes promo : moins 10 %, moins 15 %, moins 25 %… Écrire une fonction par pourcentage recréerait exactement les copies qu'on cherche à supprimer. On écrit plutôt une fonction qui fabrique la promotion.
creer_promo(25) a terminé son exécution depuis longtemps quand on appelle moins_25(40). Pourtant promo connaît toujours pourcentage.
Une fermeture (closure) est une fonction définie à l'intérieur d'une autre, qui utilise une variable de la fonction englobante. Cette variable reste vivante tant que la fonction intérieure existe, même après la fin de la fonction englobante.
Modifier la variable mémorisée : nonlocal
Le logiciel doit numéroter les tickets. Une fermeture peut garder un compteur, mais ce premier essai échoue :
C'est la règle de portée des fonctions : affecter une variable dans une fonction en fait une variable locale. numero = numero + 1 tente donc de lire une locale qui n'a pas encore de valeur. Le mot-clé nonlocal indique qu'on parle de la variable de la fonction englobante.
Chaque appel à creer_distributeur_tickets crée son propre numero : la caisse et la livraison ne se marchent pas dessus. Une fermeture avec état, c'est un objet à une seule méthode, sans classe.
[lambda t: t * (1 - p / 100) for p in (10, 20, 30)] semble créer trois promotions. Les trois appliquent pourtant 30 % : elles lisent toutes la même variable p, au moment de l'appel, quand la boucle est finie depuis longtemps.La correction propre passe par une fabrique :
[creer_promo(p) for p in (10, 20, 30)]. Chaque appel à creer_promo crée son propre pourcentage.
Mission 4. Écrire une fabrique de tarifs de livraison, puis une fabrique de fours qui refusent les pizzas une fois pleins.
Phase 5 : Les décorateurs
Des tickets faux sont sortis hier soir. Le patron veut un journal de chaque appel aux fonctions de caisse : les arguments reçus, le résultat rendu. Il y a quinze fonctions. Ajouter deux print dans chacune, puis les retirer, puis les remettre au prochain incident ? Non.
Tout ce qu'il faut a déjà été vu. On écrit une fonction qui reçoit une fonction, et renvoie une version enveloppée.
journaliser reçoit une fonction et en renvoie une autre (phase 3).enveloppe est une fermeture qui se souvient de fonction (phase 4).enveloppe accepte n'importe quels arguments avec *args, **kwargs et les retransmet tels quels (phase 2). C'est ce qui lui permet d'envelopper n'importe quelle fonction.
La ligne prix_pizza = journaliser(prix_pizza) est si courante que Python lui donne une syntaxe dédiée.
Écrire @journaliser juste au-dessus de def prix_pizza(...) équivaut exactement à écrire prix_pizza = journaliser(prix_pizza) juste après la définition. Un décorateur est donc simplement une fonction qui prend une fonction et en renvoie une.
Il reste un défaut. La fonction enveloppée a perdu son identité : son nom et sa docstring sont ceux de enveloppe. Les messages d'erreur, help() et les outils de test s'y trompent. functools.wraps recopie l'identité de la fonction d'origine.
La règle est simple : tout décorateur commence par @functools.wraps(fonction) sur son enveloppe.
Mission 5. Écrire un décorateur qui chronomètre la fonction décorée, puis un décorateur qui refuse tout argument numérique négatif ou nul.
Bonus
Bonus 1 : un décorateur qui prend des paramètres
L'offre « pizza du jour » est limitée à un nombre de clients qui change chaque jour. On veut écrire @limiter(3). Comme @ attend un décorateur, limiter(3) doit renvoyer un décorateur : il y a donc trois fonctions imbriquées.
Bonus 2 : une fonction qui rend ses résultats un par un
Une fonction qui contient yield au lieu de return est un générateur. L'appeler n'exécute rien : on obtient un objet qui produit les valeurs à la demande, en reprenant là où il s'était arrêté.
Le four peut cuire quatre pizzas à la fois. Écrire par_fournee, qui produit les commandes par paquets.
Vérification
1.Pourquoi def f(supplements=[]) est-il dangereux ?
2.Dans def f(*args, **kwargs), quels sont les types de args et kwargs ?
3.Pourquoi sorted(commandes, key=prix()) échoue-t-il ?
4.Qu'écrit-on à la place de @journaliser au-dessus de def f ?
5.Une fonction porte @journaliser puis, sur la ligne suivante, @chronometrer. Qu'obtient-on ?
6.À quoi sert functools.wraps dans un décorateur ?
Synthèse
| Forme | Syntaxe | À quoi elle sert |
|---|---|---|
| Paramètre nommé uniquement | def f(a, *, option=False) | Rendre l'appel lisible, interdire les booléens anonymes |
| Défaut sûr | def f(liste=None) | Ne pas partager un objet entre les appels |
| Arguments variables | def f(*args, **kwargs) | Accepter n'importe quels arguments, et les retransmettre |
| Déballage | f(*sequence, **dictionnaire) | Appeler une fonction à partir de données |
| Fonction en valeur | encaisser(total, happy_hour) | Choisir un comportement à l'exécution |
lambda | key=lambda c: c["prix"] | Petite fonction jetable passée en argument |
| Fermeture | creer_promo(10) renvoie une fonction | Fabriquer des fonctions configurées, garder un état |
| Décorateur | @journaliser | Ajouter un comportement sans modifier la fonction |
| Générateur | yield | Produire des valeurs une à une, à la demande |