Le module functools

Optimisez vos performances avec le module functools.

Publié le par Gabriel Trouvé (mis à jour le )

43 minutes

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)
PYTHON

La méthode cache_info() permet de voir ce qui se passe en arrière-plan :

  • hits le nombre de fois où le résultat a été trouvé dans le cache

  • misses le nombre de fois où la fonction a exécuté son code pour calculer le résultat

  • maxsize la limite fixée au cache (toujours None avec @cache)

  • currsize le 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)
PYTHON

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'
PYTHON

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)
PYTHON

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}
PYTHON

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

  • @cache stocke les données dans un dictionnaire interne au wrapper accessible via cache_info()

  • Si vous utilisez @cache sur une méthode, le dictionnaire global du cache garde une référence vers self

  • Préférez @cached_property avec 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 ⚠️
PYTHON

À 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>>
PYTHON

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

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
PYTHON

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

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
PYTHON
  • 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
PYTHON

@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
PYTHON

@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]'}]
PYTHON

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__)
PYTHON

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 a doit être placé avant b

  • 0 si a et b sont égaux

  • Un nombre positif si a doit être placé après b

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)
PYTHON

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'}]
PYTHON

Bravo, tu es prêt à passer à la suite

Rechercher sur le site

Inscris-toi à Docstring

Pour commencer ton apprentissage.

Tu as déjà un compte ? Connecte-toi.