Décorateurs#
Fonctions de première classe et fermetures#
Les décorateurs reposent sur deux propriétés du langage que nous avons introduites dans les chapitres précédents et qu’il convient ici de rappeler avec précision, car leur compréhension est un prérequis absolu.
En Python, les fonctions sont des objets de première classe (first-class objects) : elles peuvent être passées en argument à d’autres fonctions, retournées comme valeur de retour, stockées dans des variables et dans des structures de données. Une fonction est un objet comme les autres, avec ses attributs (__name__, __doc__, __annotations__, __module__…).
Une fermeture (closure) est une fonction interne qui capture des variables de la portée englobante dans laquelle elle a été définie, même après que cette portée ait disparu de la pile d’appels. C’est le mécanisme qui permet à une fonction retournée de « se souvenir » de son contexte de création.
def créer_salutateur(salutation: str):
"""Retourne une fonction qui salue avec la salutation donnée."""
def saluer(nom: str) -> str:
# 'salutation' est capturée depuis la portée englobante
return f"{salutation}, {nom} !"
return saluer
bonjour = créer_salutateur("Bonjour")
bonsoir = créer_salutateur("Bonsoir")
print(bonjour("Alice"))
print(bonsoir("Bob"))
# La cellule de fermeture est accessible
print(bonjour.__closure__[0].cell_contents) # "Bonjour"
Bonjour, Alice !
Bonsoir, Bob !
Bonjour
Décorateur simple#
Un décorateur est une fonction qui prend une fonction en entrée, en retourne une nouvelle (généralement en enveloppant l’originale dans une fermeture), et est appliquée via la syntaxe @nom_du_décorateur placée immédiatement avant la définition de la fonction cible.
Définition 24 (Décorateur)
Un décorateur est un callable qui prend un callable en argument et retourne un callable. La syntaxe @décorateur appliquée à une définition de fonction def f(...) est strictement équivalente à f = décorateur(f). Un décorateur peut modifier le comportement de la fonction enveloppée, ajouter de la logique avant ou après son exécution, ou l’instrumenter sans modifier son code source.
def journaliser(fonction):
"""Décorateur qui journalise chaque appel à la fonction."""
def enveloppe(*args, **kwargs):
print(f"Appel de {fonction.__name__!r} avec args={args}, kwargs={kwargs}")
résultat = fonction(*args, **kwargs)
print(f"{fonction.__name__!r} a retourné {résultat!r}")
return résultat
return enveloppe
@journaliser
def additionner(a: float, b: float) -> float:
"""Additionne deux nombres."""
return a + b
@journaliser
def saluer(nom: str) -> str:
return f"Bonjour, {nom} !"
additionner(3, 4)
saluer("Alice")
Appel de 'additionner' avec args=(3, 4), kwargs={}
'additionner' a retourné 7
Appel de 'saluer' avec args=('Alice',), kwargs={}
'saluer' a retourné 'Bonjour, Alice !'
'Bonjour, Alice !'
Appliquons la syntaxe longue pour bien voir ce qui se passe :
def multiplier(a: float, b: float) -> float:
return a * b
# Ces deux formes sont strictement équivalentes :
# @journaliser ←→ multiplier = journaliser(multiplier)
multiplier = journaliser(multiplier)
multiplier(5, 6)
Appel de 'multiplier' avec args=(5, 6), kwargs={}
'multiplier' a retourné 30
30
functools.wraps#
La décoration naïve présentée ci-dessus a un défaut : elle remplace la fonction originale par enveloppe, ce qui fait que les métadonnées de la fonction (son nom, sa docstring, ses annotations) sont perdues.
def journaliser_naïf(fonction):
def enveloppe(*args, **kwargs):
return fonction(*args, **kwargs)
return enveloppe
@journaliser_naïf
def ma_fonction():
"""Une docstring importante."""
pass
print(ma_fonction.__name__) # "enveloppe" — pas "ma_fonction" !
print(ma_fonction.__doc__) # None — la docstring est perdue !
enveloppe
None
from functools import wraps
def journaliser_correct(fonction):
"""Décorateur qui préserve les métadonnées grâce à @wraps."""
@wraps(fonction) # Copie __name__, __doc__, __annotations__, __module__…
def enveloppe(*args, **kwargs):
print(f"Appel de {fonction.__name__!r}")
return fonction(*args, **kwargs)
return enveloppe
@journaliser_correct
def ma_fonction():
"""Une docstring importante."""
pass
print(ma_fonction.__name__) # "ma_fonction" — correct !
print(ma_fonction.__doc__) # "Une docstring importante." — préservée !
print(ma_fonction.__wrapped__) # La fonction originale, accessible via __wrapped__
ma_fonction
Une docstring importante.
<function ma_fonction at 0x7fd474f93e20>
Remarque 23
functools.wraps doit être appliqué systématiquement dans tout décorateur bien écrit. Il copie les attributs __name__, __qualname__, __doc__, __dict__, __module__, __annotations__ et __wrapped__ depuis la fonction originale vers la fonction enveloppe. L’attribut __wrapped__ est particulièrement utile : il permet d’accéder directement à la fonction originale non décorée, ce qui est indispensable pour les tests unitaires.
Décorateur avec arguments#
Un décorateur simple prend une fonction et retourne une fonction. Un décorateur paramétré prend des arguments et retourne un décorateur. On obtient alors une fabrique de décorateurs : une fonction qui, une fois appelée avec les arguments souhaités, retourne un décorateur.
Définition 25 (Décorateur paramétré)
Un décorateur paramétré est une fonction à trois niveaux d’imbrication : le premier niveau reçoit les arguments du décorateur, le second reçoit la fonction à décorer, et le troisième est la fonction enveloppe. La syntaxe @décorateur(args) est équivalente à f = décorateur(args)(f).
from functools import wraps
def répéter(n: int):
"""Fabrique un décorateur qui exécute la fonction n fois."""
def décorateur(fonction):
@wraps(fonction)
def enveloppe(*args, **kwargs):
résultat = None
for _ in range(n):
résultat = fonction(*args, **kwargs)
return résultat
return enveloppe
return décorateur
@répéter(3)
def dire_bonjour(nom: str) -> str:
print(f"Bonjour, {nom} !")
return f"Bonjour, {nom} !"
dire_bonjour("Alice")
Bonjour, Alice !
Bonjour, Alice !
Bonjour, Alice !
'Bonjour, Alice !'
def valider_types(**types_attendus):
"""Décorateur paramétré qui valide les types des arguments par nom."""
def décorateur(fonction):
@wraps(fonction)
def enveloppe(*args, **kwargs):
import inspect
sig = inspect.signature(fonction)
paramètres = list(sig.parameters.keys())
# Vérifier les arguments positionnels
for i, (param, valeur) in enumerate(zip(paramètres, args)):
if param in types_attendus:
if not isinstance(valeur, types_attendus[param]):
raise TypeError(
f"Paramètre '{param}' : attendu {types_attendus[param].__name__}, "
f"reçu {type(valeur).__name__}"
)
# Vérifier les arguments par mot-clé
for param, valeur in kwargs.items():
if param in types_attendus:
if not isinstance(valeur, types_attendus[param]):
raise TypeError(
f"Paramètre '{param}' : attendu {types_attendus[param].__name__}, "
f"reçu {type(valeur).__name__}"
)
return fonction(*args, **kwargs)
return enveloppe
return décorateur
@valider_types(nom=str, âge=int)
def créer_profil(nom: str, âge: int) -> dict:
return {"nom": nom, "âge": âge}
print(créer_profil("Alice", 30))
try:
créer_profil("Bob", "trente") # âge doit être un int
except TypeError as e:
print(f"TypeError : {e}")
{'nom': 'Alice', 'âge': 30}
TypeError : Paramètre 'âge' : attendu int, reçu str
Cas d’usage classiques#
Cache et mémoïsation#
Le cache de résultats (mémoïsation) est l’un des cas d’usage les plus emblématiques des décorateurs. La bibliothèque standard fournit functools.lru_cache (et son alias functools.cache depuis Python 3.9).
from functools import lru_cache, cache
import time
@cache # Cache illimité (équivalent à lru_cache(maxsize=None))
def fibonacci(n: int) -> int:
"""Fibonacci récursif, optimisé par mémoïsation."""
if n <= 1:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
t0 = time.perf_counter()
print(fibonacci(40))
t1 = time.perf_counter()
print(f"Durée (premier appel) : {(t1 - t0) * 1000:.3f} ms")
t0 = time.perf_counter()
print(fibonacci(40)) # Depuis le cache
t1 = time.perf_counter()
print(f"Durée (appel en cache) : {(t1 - t0) * 1000:.3f} ms")
print(f"Informations du cache : {fibonacci.cache_info()}")
102334155
Durée (premier appel) : 0.124 ms
102334155
Durée (appel en cache) : 0.059 ms
Informations du cache : CacheInfo(hits=39, misses=41, maxsize=None, currsize=41)
Minuterie (timer)#
import time
from functools import wraps
def minuterie(fonction):
"""Mesure et affiche le temps d'exécution d'une fonction."""
@wraps(fonction)
def enveloppe(*args, **kwargs):
début = time.perf_counter()
résultat = fonction(*args, **kwargs)
durée = time.perf_counter() - début
print(f"{fonction.__name__!r} exécutée en {durée * 1000:.3f} ms")
return résultat
return enveloppe
@minuterie
def tri_lent(n: int) -> list:
"""Trie une liste de n nombres aléatoires."""
import random
données = [random.random() for _ in range(n)]
return sorted(données)
résultat = tri_lent(100_000)
print(f"Résultat : liste de {len(résultat)} éléments")
'tri_lent' exécutée en 24.626 ms
Résultat : liste de 100000 éléments
Réessai (retry)#
import time
import random
from functools import wraps
def réessayer(tentatives: int = 3, délai: float = 0.1, exceptions=(Exception,)):
"""Réessaie la fonction en cas d'échec, jusqu'à 'tentatives' fois."""
def décorateur(fonction):
@wraps(fonction)
def enveloppe(*args, **kwargs):
dernière_exception = None
for i in range(1, tentatives + 1):
try:
return fonction(*args, **kwargs)
except exceptions as e:
dernière_exception = e
print(f"Tentative {i}/{tentatives} échouée : {e}")
if i < tentatives:
time.sleep(délai)
raise dernière_exception
return enveloppe
return décorateur
compteur_appels = 0
@réessayer(tentatives=4, délai=0.01, exceptions=(ValueError,))
def service_instable() -> str:
"""Simule un service qui échoue les premières fois."""
global compteur_appels
compteur_appels += 1
if compteur_appels < 3:
raise ValueError(f"Échec temporaire (appel #{compteur_appels})")
return f"Succès à l'appel #{compteur_appels}"
print(service_instable())
Tentative 1/4 échouée : Échec temporaire (appel #1)
Tentative 2/4 échouée : Échec temporaire (appel #2)
Succès à l'appel #3
Empilement de décorateurs#
Plusieurs décorateurs peuvent être empilés sur une même fonction. L’ordre d’application suit la règle de bas en haut : le décorateur le plus proche de def est appliqué en premier.
from functools import wraps
def décorateur_A(fn):
@wraps(fn)
def enveloppe(*args, **kwargs):
print("A — avant")
résultat = fn(*args, **kwargs)
print("A — après")
return résultat
return enveloppe
def décorateur_B(fn):
@wraps(fn)
def enveloppe(*args, **kwargs):
print("B — avant")
résultat = fn(*args, **kwargs)
print("B — après")
return résultat
return enveloppe
@décorateur_A
@décorateur_B
def ma_fonction():
print("Corps de la fonction")
# Équivalent à : ma_fonction = décorateur_A(décorateur_B(ma_fonction))
ma_fonction()
A — avant
B — avant
Corps de la fonction
B — après
A — après
# Exemple pratique : cache + minuterie
@minuterie
@lru_cache(maxsize=256)
def suite_collatz(n: int) -> int:
"""Longueur de la suite de Collatz pour n."""
if n == 1:
return 1
if n % 2 == 0:
return 1 + suite_collatz(n // 2)
return 1 + suite_collatz(3 * n + 1)
print(suite_collatz(27)) # Premier appel : calcule
print(suite_collatz(27)) # Deuxième appel : depuis le cache
'suite_collatz' exécutée en 0.002 ms
'suite_collatz' exécutée en 0.104 ms
'suite_collatz' exécutée en 0.144 ms
'suite_collatz' exécutée en 0.168 ms
'suite_collatz' exécutée en 0.191 ms
'suite_collatz' exécutée en 0.212 ms
'suite_collatz' exécutée en 0.279 ms
'suite_collatz' exécutée en 0.361 ms
'suite_collatz' exécutée en 0.471 ms
'suite_collatz' exécutée en 0.548 ms
'suite_collatz' exécutée en 0.577 ms
'suite_collatz' exécutée en 0.597 ms
'suite_collatz' exécutée en 0.617 ms
'suite_collatz' exécutée en 0.637 ms
'suite_collatz' exécutée en 0.656 ms
'suite_collatz' exécutée en 0.677 ms
'suite_collatz' exécutée en 0.696 ms
'suite_collatz' exécutée en 0.720 ms
'suite_collatz' exécutée en 0.739 ms
'suite_collatz' exécutée en 0.759 ms
'suite_collatz' exécutée en 0.782 ms
'suite_collatz' exécutée en 0.802 ms
'suite_collatz' exécutée en 0.823 ms
'suite_collatz' exécutée en 0.842 ms
'suite_collatz' exécutée en 0.862 ms
'suite_collatz' exécutée en 0.882 ms
'suite_collatz' exécutée en 0.901 ms
'suite_collatz' exécutée en 0.921 ms
'suite_collatz' exécutée en 0.939 ms
'suite_collatz' exécutée en 0.958 ms
'suite_collatz' exécutée en 0.978 ms
'suite_collatz' exécutée en 0.996 ms
'suite_collatz' exécutée en 1.014 ms
'suite_collatz' exécutée en 1.033 ms
'suite_collatz' exécutée en 1.052 ms
'suite_collatz' exécutée en 1.076 ms
'suite_collatz' exécutée en 1.099 ms
'suite_collatz' exécutée en 1.117 ms
'suite_collatz' exécutée en 1.135 ms
'suite_collatz' exécutée en 1.154 ms
'suite_collatz' exécutée en 1.173 ms
'suite_collatz' exécutée en 1.192 ms
'suite_collatz' exécutée en 1.213 ms
'suite_collatz' exécutée en 1.232 ms
'suite_collatz' exécutée en 1.250 ms
'suite_collatz' exécutée en 1.268 ms
'suite_collatz' exécutée en 1.286 ms
'suite_collatz' exécutée en 1.305 ms
'suite_collatz' exécutée en 1.331 ms
'suite_collatz' exécutée en 1.354 ms
'suite_collatz' exécutée en 1.372 ms
'suite_collatz' exécutée en 1.390 ms
'suite_collatz' exécutée en 1.409 ms
'suite_collatz' exécutée en 1.435 ms
'suite_collatz' exécutée en 1.453 ms
'suite_collatz' exécutée en 1.472 ms
'suite_collatz' exécutée en 1.490 ms
'suite_collatz' exécutée en 1.508 ms
'suite_collatz' exécutée en 1.526 ms
'suite_collatz' exécutée en 1.544 ms
'suite_collatz' exécutée en 1.563 ms
'suite_collatz' exécutée en 1.581 ms
'suite_collatz' exécutée en 1.599 ms
'suite_collatz' exécutée en 1.663 ms
'suite_collatz' exécutée en 1.734 ms
'suite_collatz' exécutée en 1.949 ms
'suite_collatz' exécutée en 1.973 ms
'suite_collatz' exécutée en 1.992 ms
'suite_collatz' exécutée en 2.022 ms
'suite_collatz' exécutée en 2.041 ms
'suite_collatz' exécutée en 2.059 ms
'suite_collatz' exécutée en 2.078 ms
'suite_collatz' exécutée en 2.096 ms
'suite_collatz' exécutée en 2.114 ms
'suite_collatz' exécutée en 2.132 ms
'suite_collatz' exécutée en 2.152 ms
'suite_collatz' exécutée en 2.170 ms
'suite_collatz' exécutée en 2.189 ms
'suite_collatz' exécutée en 2.207 ms
'suite_collatz' exécutée en 2.225 ms
'suite_collatz' exécutée en 2.243 ms
'suite_collatz' exécutée en 2.260 ms
'suite_collatz' exécutée en 2.278 ms
'suite_collatz' exécutée en 2.297 ms
'suite_collatz' exécutée en 2.314 ms
'suite_collatz' exécutée en 2.335 ms
'suite_collatz' exécutée en 2.376 ms
'suite_collatz' exécutée en 2.396 ms
'suite_collatz' exécutée en 2.415 ms
'suite_collatz' exécutée en 2.434 ms
'suite_collatz' exécutée en 2.452 ms
'suite_collatz' exécutée en 2.471 ms
'suite_collatz' exécutée en 2.489 ms
'suite_collatz' exécutée en 2.507 ms
'suite_collatz' exécutée en 2.526 ms
'suite_collatz' exécutée en 2.544 ms
'suite_collatz' exécutée en 2.563 ms
'suite_collatz' exécutée en 2.581 ms
'suite_collatz' exécutée en 2.599 ms
'suite_collatz' exécutée en 2.617 ms
'suite_collatz' exécutée en 2.636 ms
'suite_collatz' exécutée en 2.654 ms
'suite_collatz' exécutée en 2.672 ms
'suite_collatz' exécutée en 2.690 ms
'suite_collatz' exécutée en 2.708 ms
'suite_collatz' exécutée en 2.726 ms
'suite_collatz' exécutée en 2.745 ms
'suite_collatz' exécutée en 2.763 ms
'suite_collatz' exécutée en 2.782 ms
'suite_collatz' exécutée en 2.801 ms
'suite_collatz' exécutée en 2.820 ms
'suite_collatz' exécutée en 2.840 ms
112
'suite_collatz' exécutée en 0.001 ms
112
Décorateurs de classe#
Un décorateur peut aussi s’appliquer à une classe entière. Il reçoit la classe comme argument et retourne une classe modifiée (ou une nouvelle classe). Ce mécanisme est notamment utilisé par @dataclass de la bibliothèque standard pour générer automatiquement __init__, __repr__ et __eq__.
def singleton(classe):
"""Décorateur de classe garantissant qu'une seule instance est créée."""
instances: dict = {}
@wraps(classe)
def obtenir_instance(*args, **kwargs):
if classe not in instances:
instances[classe] = classe(*args, **kwargs)
return instances[classe]
return obtenir_instance
@singleton
class Configuration:
"""Configuration globale de l'application."""
def __init__(self) -> None:
self.debug = False
self.version = "1.0.0"
print("Configuration créée")
def __repr__(self) -> str:
return f"Configuration(debug={self.debug}, version={self.version!r})"
cfg1 = Configuration()
cfg2 = Configuration() # Ne crée pas de nouvelle instance
print(cfg1 is cfg2) # True — même objet
cfg1.debug = True
print(cfg2.debug) # True — le même objet
Configuration créée
True
True
def enregistrer(registre: dict):
"""Décorateur de classe qui enregistre la classe dans un registre."""
def décorateur(classe):
registre[classe.__name__] = classe
return classe
return décorateur
PLUGINS: dict = {}
@enregistrer(PLUGINS)
class PluginA:
def exécuter(self): return "Plugin A exécuté"
@enregistrer(PLUGINS)
class PluginB:
def exécuter(self): return "Plugin B exécuté"
print(f"Plugins enregistrés : {list(PLUGINS.keys())}")
for nom, cls in PLUGINS.items():
print(f" {nom} → {cls().exécuter()}")
Plugins enregistrés : ['PluginA', 'PluginB']
PluginA → Plugin A exécuté
PluginB → Plugin B exécuté
Visualisation du mécanisme de décoration#
Résumé#
Ce chapitre a couvert les décorateurs dans leur globalité, de leurs fondements théoriques à leurs applications pratiques :
Les fonctions de première classe et les fermetures sont les deux mécanismes sur lesquels reposent les décorateurs : une fonction peut retourner une autre fonction qui capture des variables de sa portée.
Un décorateur simple est une fonction qui prend une fonction et retourne une fonction enveloppe. La syntaxe
@décorateurest un sucre syntaxique pourf = décorateur(f).functools.wrapsdoit toujours être utilisé dans la fonction enveloppe pour préserver les métadonnées (__name__,__doc__,__annotations__,__wrapped__) de la fonction originale.Un décorateur paramétré ajoute un niveau d’imbrication supplémentaire : une fabrique de décorateurs accepte les arguments et retourne le décorateur proprement dit.
Les cas d’usage les plus courants sont le cache (
functools.lru_cache,functools.cache), la minuterie, la réessai sur erreur, et la validation de types.L”empilement de décorateurs s’effectue de bas en haut : le plus proche de
defest appliqué en premier.Les décorateurs de classe transforment une classe entière et sont utilisés notamment pour implémenter le patron de conception Singleton ou pour enregistrer des classes dans un registre.
Dans le chapitre suivant, nous aborderons les modules, paquets et l’outil uv — la façon dont Python organise le code à grande échelle et comment les projets modernes gèrent leurs dépendances.