Aller au contenu principal

Atelier : les fonctions avancées, chez Da Lambda

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.

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.

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 lignes assert du bas servent de juge : si aucune ne proteste et que le message final s'affiche, la phase est validée.

Déroulé

DuréePhaseNotions
10 min0. Remise en routedef, return, dictionnaires, f-strings
10 min1. Appeler proprementArguments nommés, valeurs par défaut, défaut mutable
15 min2. Un nombre d'arguments variable*args, **kwargs, déballage
20 min3. La fonction est une valeurFonction en paramètre, lambda, key=
15 min4. Des fonctions qui fabriquent des fonctionsFermetures, nonlocal
15 min5. Les décorateurs@, functools.wraps
5 minVérificationQuiz 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.

AilleursEn Python
Accolades { } autour d'un blocDeux-points en fin d'en-tête, puis indentation de 4 espaces
int prix(int x)def prix(x):, aucun type à déclarer
nullNone
cond ? a : ba if cond else b
&& et !and, or, not
for (i = 0; i < n; i++)for i in range(n):
"Total : " + totalf"Total : {total}"
Table associative, MapDictionnaire {"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.

main.py
Sortie
>_ Prêt à exécuter…
Vérification rapideon peut se reprendre

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.

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

Le raise vient en premier, avant tout calcul : une fonction vérifie ce qu'elle reçoit avant de s'en servir, et sort tôt plutôt que d'imbriquer un else.

Le round(..., 2) n'est pas cosmétique. 11.5 * 1.3 vaut 14.949999999999999 en virgule flottante, et l'égalité du test échouerait sans lui. Sur des prix, on arrondit au centime, et le plus tard possible.

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.

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

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 :

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

Ce bloc-ci n'a pas d'erreur. Il fait pourtant quelque chose d'inattendu : on l'exécute avant de lire la suite.

main.py
Sortie
>_ Prêt à exécuter…
La valeur par défaut est créée une seule fois
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.

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

is None et non == None : on compare une identité, pas une valeur, et certains objets redéfinissent == au point de se prétendre égaux à None.

C'est le remède exact au piège du bloc précédent. None existe une fois pour toutes et ne peut pas être modifié ; la liste, elle, naît à chaque appel qui en a besoin. Alice et Bob repartent chacun avec la leur.

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.

Définition

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.

main.py
Sortie
>_ Prêt à exécuter…
On s'en sert depuis le début
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.

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

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.

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

*prix rassemble les arguments positionnels dans un tuple, d'où le list(garnitures) : le test attend une liste, et un tuple ne lui est pas égal.

total_ticket() sans aucun prix rend 0, parce que sum(()) vaut 0. Une fonction qui traite naturellement le cas vide est une fonction de moins à protéger.

L'étoile change de sens selon l'endroit : dans la signature elle rassemble, à l'appel elle déballe. creer_pizza("tomate", *garnitures, **options) passe trois garnitures et deux options nommées, exactement comme si on les avait écrites une à une.

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.

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

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 :

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

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.

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

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.

Une lambda qu'on nomme est un def déguisé
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.

main.py
Sortie
>_ Prêt à exécuter…
map et filter, ou une compréhension ?
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.

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

key= reçoit une fonction, que sorted appelle sur chaque élément pour savoir sur quoi comparer. Ici couple[1], le prix. Sans quoi le tri se ferait sur le nom.

appliquer_promo construit un nouveau dictionnaire par compréhension au lieu de modifier celui qu'on lui passe. C'est ce que vérifie la dernière assertion, et c'est la règle : une fonction qui calcule ne touche pas à ses données d'entrée.

Le plancher s'écrit max(p - 2, 7), pas une condition : « jamais moins de 7 » est un maximum.

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.

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

creer_promo(25) a terminé son exécution depuis longtemps quand on appelle moins_25(40). Pourtant promo connaît toujours pourcentage.

Définition

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 :

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

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.

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

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.

La fermeture se souvient de la variable, pas de sa valeur
[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.
main.py
Sortie
>_ Prêt à exécuter…

Mission 4. Écrire une fabrique de tarifs de livraison, puis une fabrique de fours qui refusent les pizzas une fois pleins.

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

tarif se souvient de frais_fixes et de prix_km après que la fabrique a rendu la main. C'est une fermeture : la fonction emporte le contexte où elle est née, et deux appels à la fabrique donnent deux fonctions aux tarifs distincts.

nonlocal est indispensable dans enfourner. Sans lui, enfournees += 1 créerait une variable locale à enfourner, laissant celle de la fermeture à zéro. Et le four accepterait des pizzas indéfiniment. nonlocal dit : cette variable est celle d'au-dessus, pas une nouvelle.

La dernière assertion vérifie que chaque four a son propre compteur. C'est ce qui distingue une fermeture d'une variable globale, et ce qui la rend utilisable.

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.

main.py
Sortie
>_ Prêt à exécuter…
Un décorateur réunit les trois phases précédentes
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.

Définition

É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.

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

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.

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

Un décorateur réunit les trois phases précédentes : il reçoit une fonction (phase 3), en fabrique une autre par fermeture (phase 4), et l'enveloppe accepte *args, **kwargs pour relayer n'importe quel appel (phase 2). @chronometrer n'est qu'une écriture abrégée de preparer_patons = chronometrer(preparer_patons).

resultat doit être renvoyé. L'oublier est l'erreur la plus fréquente : la fonction décorée rend alors None, le chronomètre s'affiche quand même, et rien ne signale le problème avant que le résultat serve.

isinstance(valeur, (int, float)) filtre les nombres, sinon la comparaison porterait sur le nom de la pizza et lèverait un TypeError. Et il faut parcourir args et kwargs.values() : le test passe une quantité négative par position, puis un prix nul par nom.

Sans functools.wraps, commander.__name__ vaudrait "enveloppe". Le décorateur remplace la fonction, et l'identité de l'originale (nom, docstring, signature) disparaîtrait des traces d'erreur et de la documentation.

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.

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

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é.

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

Le four peut cuire quatre pizzas à la fois. Écrire par_fournee, qui produit les commandes par paquets.

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

Vérification

Vérification rapideon peut se reprendre

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

FormeSyntaxeÀ quoi elle sert
Paramètre nommé uniquementdef f(a, *, option=False)Rendre l'appel lisible, interdire les booléens anonymes
Défaut sûrdef f(liste=None)Ne pas partager un objet entre les appels
Arguments variablesdef f(*args, **kwargs)Accepter n'importe quels arguments, et les retransmettre
Déballagef(*sequence, **dictionnaire)Appeler une fonction à partir de données
Fonction en valeurencaisser(total, happy_hour)Choisir un comportement à l'exécution
lambdakey=lambda c: c["prix"]Petite fonction jetable passée en argument
Fermeturecreer_promo(10) renvoie une fonctionFabriquer des fonctions configurées, garder un état
Décorateur@journaliserAjouter un comportement sans modifier la fonction
GénérateuryieldProduire des valeurs une à une, à la demande

Mettre en pratique