Voici un guide que je voulais écrire depuis quelque temps. Il emboîte le pas au guide sur itertools. Si ce dernier se concentrait sur les itérateurs, functools est LA boîte à outils intégrée à Python conçue pour manipuler les fonctions.
Dans ce guide, nous allons parler des fonctionnalités qu'offre ce module.
Optimiser vos fonctions avec la mise en cache
La mémoïsation est sans doute la fonctionnalité la plus utilisée avec functools. C'est une technique d'optimisation qui consiste à mettre en cache le résultat d'un appel de fonction selon ses arguments, afin d'éviter de recalculer ce résultat si les mêmes arguments sont fournis à nouveau.
Vous avez certainement entendu parler de lru_cache ?
Qu'est-ce que @lru_cache et @cache ?
Lorsqu'on décore une fonction avec @cache ou @lru_cache, Python stocke les arguments utilisés et le résultat renvoyé. En arrière-plan, le cache utilise un dictionnaire avec :
-
Un tuple des arguments passés à la fonction en clé
-
La valeur retournée par la fonction en valeur
Lors du prochain appel, Python regarde si le tuple des arguments existe déjà. Si oui, il renvoie la valeur associée sans exécuter le code de la fonction.
Apparu avec la version 3.9 de Python, le décorateur @cache est la version la plus directe de la mémoïsation. Il stocke tout sans jamais rien supprimer.
from functools import cache @cache def factorielle(n): if n <= 1: return 1 print(f"Calcul de factorielle({n})...") return n * factorielle(n - 1) # Premier appel : les calculs s'exécutent print(factorielle(5)) print("---") # Deuxième appel : résultat instantané récupéré en mémoire print(factorielle(5)) # Affiche : 120 (sans aucun print !) print(factorielle.cache_info()) # CacheInfo(hits=1, misses=5, maxsize=None, currsize=5)
La méthode cache_info() permet de voir ce qui se passe en arrière-plan :
-
hitsle nombre de fois où le résultat a été trouvé dans le cache -
missesle nombre de fois où la fonction a exécuté son code pour calculer le résultat -
maxsizela limite fixée au cache (toujoursNoneavec@cache) -
currsizele nombre d'éléments stockés dans le dictionnaire interne du cache
Donc, si j'appelle la factorielle de 7 :
from functools import cache @cache def factorielle(n): if n <= 1: return 1 print(f"Calcul de factorielle({n})...") return n * factorielle(n - 1) # Premier appel : les calculs s'exécutent print(factorielle(5)) print("---") # Deuxième appel : résultat instantané récupéré en mémoire print(factorielle(5)) # Affiche : 120 (sans aucun print !) print(factorielle.cache_info()) # CacheInfo(hits=1, misses=5, maxsize=None, currsize=5) print("---") print(factorielle(7)) # Affiche : 5040 (calcul de factorielle(6) et factorielle(7) seulement, les autres sont en cache) print(factorielle.cache_info()) # CacheInfo(hits=2, misses=7, maxsize=None, currsize=7)
Ici, nous remarquons que les résultats de 6 et 7 sont venus s'ajouter en mémoire.
Attention
Comme la clé du cache est basée sur les arguments, vous ne pouvez pas mettre en cache une fonction qui prend des arguments mutables. En effet, les arguments doivent toujours être hachables.
from functools import cache @cache def somme(nombres): return sum(nombres) somme([1, 2, 3]) # TypeError: unhashable type: 'list'
Contrairement à @cache, @lru_cache (Least Recently Used) permet de définir une limite. Dès que le cache est plein, Python supprime les résultats les moins récemment utilisés.
from functools import lru_cache # On limite le cache à 2 entrées seulement pour protéger la mémoire @lru_cache(maxsize=2) def dire_bonjour(nom): print(f"Exécution pour {nom}...") return f"Bonjour {nom}" dire_bonjour("Patrick") dire_bonjour("Sebastien") print(dire_bonjour.cache_info()) # CacheInfo(hits=0, misses=2, maxsize=2, currsize=2) # Le cache est maintenant PLEIN. print("---") # On rappelle Patrick : succès ! dire_bonjour("Patrick") print(dire_bonjour.cache_info()) # CacheInfo(hits=1, misses=2, maxsize=2, currsize=2) # Patrick devient la valeur "la plus récemment utilisée". print("---") # On ajoute Charlie. La limite de 2 est atteinte. # Sebastien étant le moins récent, il est supprimé pour faire de la place ! dire_bonjour("Charlie") print(dire_bonjour.cache_info()) # CacheInfo(hits=1, misses=3, maxsize=2, currsize=2) print("---") # La preuve : si on rappelle Sebastien, la fonction s'exécute à nouveau ! dire_bonjour("Sebastien") # Affiche : Exécution pour Sebastien... print(dire_bonjour.cache_info()) # CacheInfo(hits=1, misses=4, maxsize=2, currsize=2)
Dans cet exemple, maxsize est volontairement petit. On remplit le cache, on réutilise Patrick qui monte en priorité, et Charlie pousse Sébastien hors du cache. En rappelant Sébastien, le code est de nouveau exécuté.
Qu'est-ce que @cached_property ?
@cached_property est un décorateur spécifique aux classes. Il ne fonctionne que sur des méthodes prenant uniquement self en argument. Comme son nom l'indique, il transforme votre méthode en propriété... mise en cache ! On y accède donc sans parenthèses et le résultat est stocké dans l'instance.
from functools import cached_property class SuperAnalyseur: def __init__(self, donnees): self.donnees = donnees @cached_property def total(self): print("Calcul complexe...") return sum(self.donnees) obj = SuperAnalyseur([10, 20, 30]) print(obj.total) # Calcule et écrit le résultat dans le dictionnaire de l'objet 'obj' print("---") print(obj.total) # Lit simplement la valeur dans 'obj.__dict__' print("---") print(obj.__dict__) # {'donnees': [10, 20, 30], 'total': 60}
Si Python accède de nouveau à la propriété total, ce sera la valeur contenue dans le dictionnaire obj.__dict__ qui sera retournée. Comme le résultat est stocké dans l'instance, la valeur est conservée tant que l'instance existe.
À noter
-
@cachestocke les données dans un dictionnaire interne au wrapper accessible viacache_info() -
Si vous utilisez
@cachesur une méthode, le dictionnaire global du cache garde une référence versself -
Préférez
@cached_propertyavec une méthode d'instance
import gc from functools import cache class Analyseur: def __init__(self, donnees): self.donnees = donnees @cache def total(self): print("Calcul complexe...") return sum(self.donnees) obj = Analyseur([10, 20, 30]) print(obj.total()) # Calcul complexe... → miss print(obj.total()) # hit, pas de recalcul print(obj.__dict__) # {'donnees': [10, 20, 30]} — 'total' est absent print(obj.total.cache_info()) # hits=1, misses=1, currsize=1 del obj gc.collect() # Je force le garbage collector pour appuyer mon propos 😛 print(Analyseur.total.cache_info()) # currsize=1 → l'objet est mort, le cache survit ⚠️
À noter
Pourquoi peut-on accéder à total via la classe alors que l'instance n'existe plus ? En fait, une fonction définie dans une classe est d'abord une fonction. C'est lorsqu'on y accède via une instance que la fonction se transforme en méthode.
class Analyseur: def __init__(self, donnees): self.donnees = donnees def total(self): print("Calcul complexe...") return sum(self.donnees) obj = Analyseur([10, 20, 30]) print(Analyseur.total) # <function Analyseur.total at 0x1009e71c0> print(obj.total) # <bound method Analyseur.total of <__main__.Analyseur object at 0x1009cc830>>
Figer les arguments avec partial
partial permet de pré-remplir certains arguments d'une fonction et d'en créer une version spécialisée prête à l'emploi.
from functools import partial def envoyer_notification(canal, destinataire, message): print(f"[{canal}] Message pour {destinataire} : {message}") # Patrick est notre admin système, il reçoit toujours les alertes sur le Discord de Docstring alerter_patrick = partial(envoyer_notification, canal="Discord", destinataire="Patrick") # Sébastien préfère qu'on le contacte par SMS sms_sebastien = partial(envoyer_notification, canal="SMS", destinataire="Sébastien") # Plus besoin de préciser le canal ou le destinataire à chaque fois ! alerter_patrick(message="Le serveur est en feu 🔥 !") # Affiche : [Discord] Message pour Patrick : Le serveur est en feu 🔥 ! sms_sebastien(message="N'oublie pas la réunion de 10h.") # Affiche : [SMS] Message pour Sébastien : N'oublie pas la réunion de 10h.
Pourquoi utiliser @wraps dans vos décorateurs ?
Lorsque vous créez un décorateur, sans @wraps, la fonction décorée perd son nom et sa documentation au profit de ceux du wrapper.
Prenons un exemple sans @wraps :
def decorateur_patrick_sebastien(func): def wrapper(): print("Patrick dit : On commence !") func() print("Sébastien dit : C'est terminé !") return wrapper @decorateur_patrick_sebastien def fete(): """Lance la fête.""" print("Tout le monde danse !") fete() print(fete.__name__) # Oups : wrapper print(fete.__doc__) # Oups : None
Et avec @wraps :
from functools import wraps def decorateur_patrick_sebastien(func): @wraps(func) def wrapper(): print("Patrick dit : On commence !") func() print("Sébastien dit : C'est terminé !") return wrapper @decorateur_patrick_sebastien def fete(): """Lance la fête.""" print("Tout le monde danse !") fete() print(fete.__name__) # fete print(fete.__doc__) # Lance la fête.
La fonction reduce
reduce prend une séquence et la réduit à une seule valeur en appliquant une fonction.
Multiplions tous les nombres d'une liste entre eux :
from functools import reduce nombres = [1, 2, 3, 4] # Notre fonction lambda multiplie simplement x et y produit = reduce(lambda x, y: x * y, nombres) # Voici ce qu'il s'est passé exactement : # Étape 1 : x=1, y=2 -> résultat = 2 # Étape 2 : x=2, y=3 -> résultat = 6 # Étape 3 : x=6, y=4 -> résultat = 24 print(produit) # 24
-
Prend les deux premiers éléments de la liste pour leur appliquer la fonction (multiplication)
-
Prend le résultat de cette opération et l'applique avec le troisième élément
-
Ainsi de suite jusqu'à obtenir une valeur finale
À noter
Moment culture générale : reduce faisait partie des fonctions intégrées au même titre que sum ou max sous Python 2.
Il faut avouer qu'une simple boucle for est plus explicite :
nombres = [1, 2, 3, 4] produit = 1 for n in nombres: produit *= n print(produit) # 24
@singledispatch et @singledispatchmethod : le dispatch par type en Python
Python ne supporte pas la surcharge de fonctions nativement. Pour adapter le comportement d'une fonction selon le type de son argument, on se retrouve vite à écrire une suite de if isinstance(). Ça fonctionne, mais functools propose une solution bien plus maintenable.
@singledispatch pour les fonctions
Il est possible d'enregistrer un type de deux façons : soit en le passant en argument, soit en utilisant les annotations de type.
from functools import singledispatch # Fonction par défaut si le type n'est pas géré @singledispatch def extraire_nom(utilisateur): return "Format non reconnu" # Syntaxe classique : register avec le type en argument @extraire_nom.register(dict) def _(utilisateur): return utilisateur.get("nom", "Anonyme") # Syntaxe moderne (Python 3.7+) : register sans argument, type via annotation @extraire_nom.register def _(utilisateur: list): return " ".join(utilisateur) # Et si c'est déjà une chaîne de caractères @extraire_nom.register def _(utilisateur: str): return utilisateur # Testons la normalisation avec différents formats en entrée : print(extraire_nom({"id": 1, "nom": "Patrick"})) # Affiche : Patrick print(extraire_nom(["Patrick", "Sebastien"])) # Affiche : Patrick Sebastien print(extraire_nom("Patrick")) # Affiche : Patrick print(extraire_nom(42)) # Affiche : Format non reconnu
@singledispatchmethod pour les classes
@singledispatch ne fonctionne pas directement sur les méthodes d'une classe. functools fournit @singledispatchmethod pour combler ce manque.
Imaginons un carnet de contacts où l'on peut ajouter un contact soit en passant une simple chaîne, soit un dictionnaire. Je ne sais pas pour vous, mais moi, j'ai bien envie d'ajouter Patrick dans mes contacts !
from functools import singledispatchmethod class CarnetContacts: def __init__(self): self.contacts = [] @singledispatchmethod def ajouter(self, contact): raise ValueError("Format de contact invalide") @ajouter.register def _(self, contact: str): # Si on passe juste une chaîne, on met un email par défaut self.contacts.append({"nom": contact, "email": "Inconnu"}) @ajouter.register def _(self, contact: dict): # Si on passe un dictionnaire complet self.contacts.append(contact) carnet = CarnetContacts() # On ajoute via une simple chaîne carnet.ajouter("Patrick") # On ajoute via un dictionnaire carnet.ajouter({"nom": "Sébastien", "email": "[email protected]"}) print(carnet.contacts) # Affiche : [{'nom': 'Patrick', 'email': 'Inconnu'}, {'nom': 'Sébastien', 'email': '[email protected]'}]
Simplifier les classes avec @total_ordering
Lorsque vous créez une classe, vous pourriez avoir besoin de comparer les instances entre elles. Pour ce faire, il existe plusieurs méthodes magiques : __lt__ (<), __le__ (<=), __gt__ (>), __ge__ (>=) et __eq__ (==).
Avec @total_ordering, il suffit de définir __eq__ et une seule autre méthode de comparaison. Le décorateur déduit et génère les autres !
from functools import total_ordering @total_ordering class Joueur: def __init__(self, nom, score): self.nom = nom self.score = score def __eq__(self, autre): return self.score == autre.score def __lt__(self, autre): # "lt" signifie "less than" (plus petit que) return self.score < autre.score # Créons nos joueurs pour le test j1 = Joueur("Patrick", 150) j2 = Joueur("Sébastien", 200) # Magie ! Nous avons accès à toutes les comparaisons possibles : print(j1 < j2) # Affiche : True (Grâce à notre méthode __lt__) print(j1 >= j2) # Affiche : False (Généré automatiquement par total_ordering !) print(j1 == j2) # Affiche : False (Grâce à notre méthode __eq__)
Le décorateur vous fait gagner des lignes de code !
Adapter une ancienne fonction de comparaison avec cmp_to_key
Avec Python 2, la logique de tri fonctionnait différemment. On écrivait une fonction qui comparait directement deux éléments a et b et qui renvoyait :
-
Un nombre négatif si
adoit être placé avantb -
0siaetbsont égaux -
Un nombre positif si
adoit être placé aprèsb
J'en profite pour faire un coucou à tous ceux qui font du JavaScript 😛.
En Python 3, le paramètre cmp= de sorted() a disparu au profit de key=.
Si vous avez une ancienne fonction de comparaison à réutiliser, cmp_to_key permet de l'adapter sans la réécrire.
# Python 2 # -*- coding: utf-8 -*- def comparer_contacts(a, b): if a["nom"] < b["nom"]: return -1 if a["nom"] > b["nom"]: return 1 return 0 contacts = [ {"nom": "Sebastien"}, {"nom": "Patrick"}, {"nom": "Alice"}, ] # En Python 2, sorted() acceptait directement cmp= print sorted(contacts, cmp=comparer_contacts)
Sans toucher à la fonction comparer_contacts, on migre vers Python 3 sans réécrire la logique :
from functools import cmp_to_key def comparer_contacts(a, b): if a["nom"] < b["nom"]: return -1 if a["nom"] > b["nom"]: return 1 return 0 contacts = [ {"nom": "Sébastien"}, {"nom": "Patrick"}, {"nom": "Alice"}, ] # En Python 3, cmp= n'existe plus, on adapte avec cmp_to_key print(sorted(contacts, key=cmp_to_key(comparer_contacts))) # Affiche : [{'nom': 'Alice'}, {'nom': 'Patrick'}, {'nom': 'Sébastien'}]