def saluer(nom):
return f"Bonjour {nom}"
# Attribuer la fonction à une autre variable
hello = saluer # attention : pas de parenthèses !
print(hello("Alice"))
print(hello is saluer) # même objetBonjour Alice
True
Les décorateurs sont une des fonctionnalités les plus emblématiques de Python. On en a déjà rencontré plusieurs : @property, @staticmethod, @dataclass, @classmethod. Vous allez maintenant comprendre comment ils fonctionnent et comment écrire les vôtres. C’est un passage obligé pour le niveau Expert du TOSA.
Ce chapitre suppose que vous maîtrisez les fonctions et *args / **kwargs (Partie 3 chapitre 2). Si vous hésitez, révisez avant.
En Python, les fonctions sont des objets comme les autres. On peut :
def saluer(nom):
return f"Bonjour {nom}"
# Attribuer la fonction à une autre variable
hello = saluer # attention : pas de parenthèses !
print(hello("Alice"))
print(hello is saluer) # même objetBonjour Alice
True
def appliquer_deux_fois(fonction, x):
return fonction(fonction(x))
def ajouter_5(n):
return n + 5
print(appliquer_deux_fois(ajouter_5, 10)) # 10 → 15 → 2020
On appelle ça une fonction d’ordre supérieur. Vous en avez déjà utilisé : sorted(..., key=...), filter(fonction, ...), map(fonction, ...).
Une fonction peut créer et renvoyer une autre fonction.
def multiplicateur(facteur):
"""Renvoie une fonction qui multiplie par facteur."""
def multiplier(x):
return x * facteur
return multiplier
doubler = multiplicateur(2)
tripler = multiplicateur(3)
print(doubler(5))
print(tripler(5))10
15
Regardez attentivement l’exemple ci-dessus : comment multiplier connaît-il la valeur de facteur alors que multiplicateur a fini son exécution ?
Réponse : c’est une closure (fermeture lexicale). La fonction interne capture les variables de la fonction englobante.
def compteur():
"""Renvoie une fonction qui compte les appels."""
n = 0
def incrementer():
nonlocal n # ← pour pouvoir modifier n
n += 1
return n
return incrementer
c1 = compteur()
c2 = compteur()
print(c1()) # 1
print(c1()) # 2
print(c1()) # 3
print(c2()) # 1 (compteur indépendant)1
2
3
1
nonlocalDans une fonction imbriquée, lire une variable englobante est automatique. Mais la modifier nécessite nonlocal :
def externe():
x = 10
def interne():
x += 1 # ❌ erreur : Python croit que x est local
interne()
externe()--------------------------------------------------------------------------- UnboundLocalError Traceback (most recent call last) Cell In[5], line 7 3 def interne(): 4 x += 1 # ❌ erreur : Python croit que x est local 5 interne() 6 ----> 7 externe() Cell In[5], line 5, in externe() 1 def externe(): 2 x = 10 3 def interne(): 4 x += 1 # ❌ erreur : Python croit que x est local ----> 5 interne() Cell In[5], line 4, in externe.<locals>.interne() 3 def interne(): ----> 4 x += 1 # ❌ erreur : Python croit que x est local UnboundLocalError: cannot access local variable 'x' where it is not associated with a value
def externe():
x = 10
def interne():
nonlocal x # ✅ explicite que x est dans le scope englobant
x += 1
return x
return interne()
print(externe())11
nonlocal vs global
global x : la variable est dans le scope global du module.nonlocal x : la variable est dans un scope englobant (pas global, pas local).On reviendra sur les scopes au chapitre suivant (LEGB).
Un décorateur est une fonction qui prend une fonction en argument et renvoie une nouvelle fonction qui étend ou modifie le comportement de l’originale.
def tracer(fonction):
"""Affiche le nom et les arguments de la fonction, puis l'exécute."""
def wrapper(*args, **kwargs):
print(f">>> {fonction.__name__}({args}, {kwargs})")
resultat = fonction(*args, **kwargs)
print(f" = {resultat}")
return resultat
return wrapper
def additionner(a, b):
return a + b
# Manuellement : on remplace additionner par sa version tracée
additionner = tracer(additionner)
additionner(3, 5)
additionner(10, 20)>>> additionner((3, 5), {})
= 8
>>> additionner((10, 20), {})
= 30
30
@La syntaxe @tracer au-dessus d’une fonction fait la même chose automatiquement :
def tracer(fonction):
def wrapper(*args, **kwargs):
print(f">>> {fonction.__name__}({args}, {kwargs})")
resultat = fonction(*args, **kwargs)
print(f" = {resultat}")
return resultat
return wrapper
@tracer
def additionner(a, b):
return a + b
# @tracer équivaut à : additionner = tracer(additionner)
additionner(3, 5)>>> additionner((3, 5), {})
= 8
8
C’est tout : @decorateur est du sucre syntaxique pour fonction = decorateur(fonction).
Structure générique d’un décorateur :
def mon_decorateur(fonction):
def wrapper(*args, **kwargs):
# avant l'appel
resultat = fonction(*args, **kwargs)
# après l'appel
return resultat
return wrapperPoints à comprendre :
fonction : la fonction décorée (ex: additionner).wrapper : la nouvelle fonction qu’on renvoie à la place.*args, **kwargs : pour supporter toutes les signatures possibles.wrapper — c’est elle qui remplace fonction.import time
def chronometre(fonction):
def wrapper(*args, **kwargs):
debut = time.time()
resultat = fonction(*args, **kwargs)
duree = time.time() - debut
print(f"{fonction.__name__} exécutée en {duree*1000:.1f} ms")
return resultat
return wrapper
@chronometre
def calcul_long(n):
return sum(i ** 2 for i in range(n))
r = calcul_long(1_000_000)
print(f"Résultat : {r:,}")calcul_long exécutée en 73.9 ms
Résultat : 333,332,833,333,500,000
def cacher(fonction):
cache = {}
def wrapper(*args):
if args not in cache:
cache[args] = fonction(*args)
else:
print(f" [cache hit pour {args}]")
return cache[args]
return wrapper
@cacher
def fib(n):
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
print(fib(10))
print(fib(10)) # 2e appel = cache hit
print(fib(5)) # déjà calculé via fib(10) [cache hit pour (1,)]
[cache hit pour (2,)]
[cache hit pour (3,)]
[cache hit pour (4,)]
[cache hit pour (5,)]
[cache hit pour (6,)]
[cache hit pour (7,)]
[cache hit pour (8,)]
55
[cache hit pour (10,)]
55
[cache hit pour (5,)]
5
@functools.cache fait ça pour vous
Python propose ce décorateur tout prêt :
from functools import cache
@cache
def fib(n):
return n if n < 2 else fib(n-1) + fib(n-2)
print(fib(100)) # instantané grâce au cache354224848179261915075
Pour une version avec taille limite, utilisez @lru_cache(maxsize=128).
def types_stricts(**types_attendus):
"""Décorateur paramétré qui vérifie les types."""
def decorateur(fonction):
def wrapper(*args, **kwargs):
# Vérifier les kwargs
for nom, type_attendu in types_attendus.items():
if nom in kwargs and not isinstance(kwargs[nom], type_attendu):
raise TypeError(
f"{nom} doit être {type_attendu.__name__}, "
f"pas {type(kwargs[nom]).__name__}"
)
return fonction(*args, **kwargs)
return wrapper
return decorateur
@types_stricts(nom=str, age=int)
def creer_utilisateur(nom, age):
return f"{nom}, {age} ans"
print(creer_utilisateur(nom="Alice", age=30))Alice, 30 ans
@types_stricts(nom=str, age=int)
def creer_utilisateur(nom, age):
return f"{nom}, {age} ans"
creer_utilisateur(nom="Alice", age="trente") # TypeError--------------------------------------------------------------------------- TypeError Traceback (most recent call last) Cell In[13], line 5 1 @types_stricts(nom=str, age=int) 2 def creer_utilisateur(nom, age): 3 return f"{nom}, {age} ans" 4 ----> 5 creer_utilisateur(nom="Alice", age="trente") # TypeError Cell In[12], line 8, in types_stricts.<locals>.decorateur.<locals>.wrapper(*args, **kwargs) 4 def wrapper(*args, **kwargs): 5 # Vérifier les kwargs 6 for nom, type_attendu in types_attendus.items(): 7 if nom in kwargs and not isinstance(kwargs[nom], type_attendu): ----> 8 raise TypeError( 9 f"{nom} doit être {type_attendu.__name__}, " 10 f"pas {type(kwargs[nom]).__name__}" 11 ) TypeError: age doit être int, pas str
@functools.wrapsUn piège classique : quand on décore une fonction, son nom et sa docstring disparaissent.
def tracer(fonction):
def wrapper(*args, **kwargs):
return fonction(*args, **kwargs)
return wrapper
@tracer
def additionner(a, b):
"""Additionne deux nombres."""
return a + b
print(additionner.__name__) # 'wrapper' !
print(additionner.__doc__) # None !wrapper
None
Solution : utiliser @functools.wraps dans votre décorateur pour préserver les métadonnées.
from functools import wraps
def tracer(fonction):
@wraps(fonction) # préserve __name__, __doc__, etc.
def wrapper(*args, **kwargs):
return fonction(*args, **kwargs)
return wrapper
@tracer
def additionner(a, b):
"""Additionne deux nombres."""
return a + b
print(additionner.__name__) # 'additionner' ✅
print(additionner.__doc__) # 'Additionne deux nombres.' ✅additionner
Additionne deux nombres.
@wraps
Sans @wraps, votre code a l’air de fonctionner, mais :
wrapper).Règle d’or : @wraps(fonction) sur chaque wrapper. C’est une ligne quasi gratuite.
Un décorateur « simple » prend une fonction. Pour lui passer des arguments (comme @types_stricts(...) vu plus haut), il faut une fabrique de décorateurs — trois niveaux d’imbrication.
def decorateur_avec_args(argument): # niveau 1 : reçoit l'argument
def decorateur(fonction): # niveau 2 : reçoit la fonction
@wraps(fonction)
def wrapper(*args, **kwargs): # niveau 3 : l'appel réel
# utiliser argument et appeler fonction
return fonction(*args, **kwargs)
return wrapper
return decorateurrepeter(n)from functools import wraps
def repeter(n):
"""Répète l'appel n fois, renvoie une liste des résultats."""
def decorateur(fonction):
@wraps(fonction)
def wrapper(*args, **kwargs):
return [fonction(*args, **kwargs) for _ in range(n)]
return wrapper
return decorateur
@repeter(3)
def dire_bonjour():
return "Bonjour !"
print(dire_bonjour())['Bonjour !', 'Bonjour !', 'Bonjour !']
@repeter(3)Python interprète : dire_bonjour = repeter(3)(dire_bonjour).
repeter(3) renvoie decorateur (avec n=3 capturé en closure).decorateur(dire_bonjour) renvoie wrapper.dire_bonjour devient ce wrapper.On peut empiler plusieurs décorateurs. Ils s’appliquent de bas en haut.
from functools import wraps
def majuscules(fonction):
@wraps(fonction)
def wrapper(*args, **kwargs):
return fonction(*args, **kwargs).upper()
return wrapper
def exclamation(fonction):
@wraps(fonction)
def wrapper(*args, **kwargs):
return fonction(*args, **kwargs) + " !!!"
return wrapper
@majuscules
@exclamation
def saluer(nom):
return f"Bonjour {nom}"
print(saluer("Alice"))BONJOUR ALICE !!!
Ordre d’application : saluer → exclamation(saluer) → majuscules(exclamation(saluer)).
Donc @majuscules (le plus haut) s’applique en dernier (en externe).
La syntaxe @ marche aussi sur les classes. Vous en avez déjà vu : @dataclass.
def ajouter_info(cls):
cls.version = "1.0"
cls.decouvert = True
return cls
@ajouter_info
class Produit:
def __init__(self, nom):
self.nom = nom
p = Produit("Widget")
print(p.nom)
print(Produit.version)
print(Produit.decouvert)Widget
1.0
True
C’est la même mécanique : Produit = ajouter_info(Produit).
Python en fournit de très utiles dans functools :
| Décorateur | Rôle |
|---|---|
@functools.wraps |
Préserve les métadonnées du décoré |
@functools.cache |
Cache illimité (Python 3.9+) |
@functools.lru_cache(maxsize=128) |
Cache avec taille maximale |
@functools.total_ordering |
Génère les opérateurs de comparaison à partir de == et < |
@functools.singledispatch |
Dispatch selon le type du 1er argument |
Autres classiques :
| Décorateur | Rôle |
|---|---|
@staticmethod |
Méthode qui n’utilise ni self ni cls |
@classmethod |
Méthode qui reçoit cls |
@property |
Méthode qui se comporte comme un attribut |
@dataclass |
Génère __init__, __repr__, __eq__ |
@staticmethod et @classmethodOn les a survolés en Partie 3. Précisons :
class Calculatrice:
@staticmethod
def carre(x):
"""N'a pas besoin de self ni de cls."""
return x ** 2
@classmethod
def depuis_deux_nombres(cls, a, b):
"""Constructeur alternatif : reçoit la classe."""
instance = cls()
instance.resultat = a + b
return instance
# Appel sans instance
print(Calculatrice.carre(5))
# Constructeur alternatif
c = Calculatrice.depuis_deux_nombres(3, 4)
print(c.resultat)25
7
Distinction :
@staticmethod : pas de paramètre implicite. Juste une fonction dans le namespace de la classe.@classmethod : reçoit cls (la classe elle-même). Utile pour des constructeurs alternatifs.self (l’instance).Que fait la syntaxe @decorateur au-dessus d’une fonction ?
f = decorateur(f)b) — @decorateur est du sucre syntaxique pour remplacer f par le résultat de decorateur(f).
Qu’est-ce qu’une closure ?
b) — la closure « enferme » les variables du scope externe, leur donnant une durée de vie au-delà de la fonction englobante.
Que fait le mot-clé nonlocal ?
b) — sans nonlocal, une affectation dans une fonction interne crée une nouvelle variable locale. nonlocal signale à Python de modifier la variable du scope englobant.
Pourquoi utiliser @functools.wraps ?
__name__, __doc__ et autres métadonnées de la fonction décoréeb) — sans @wraps, toutes vos fonctions décorées apparaissent comme wrapper dans le débogage et les introspections.
Dans quel ordre s’appliquent les décorateurs empilés ?
@A
@B
def f(): passA puis BB puis Ab) B puis A — équivalent à f = A(B(f)). Le plus proche de la fonction (@B) est appliqué en premier.
Un décorateur paramétré est :
a) — trois niveaux : decorator_factory(args) → decorator(f) → wrapper(...).
Que fait @functools.cache ?
@propertyb) — mémoïsation : si la fonction est appelée avec les mêmes arguments, le résultat est réutilisé sans recalcul. Très utile pour les fonctions récursives lourdes (Fibonacci, etc.).
Que s’affiche-t-il ?
from functools import wraps
def double(f):
@wraps(f)
def wrapper(*args, **kwargs):
return f(*args, **kwargs) * 2
return wrapper
@double
def valeur():
return 5
print(valeur())51055b) 10 — le wrapper appelle valeur() qui renvoie 5, puis multiplie par 2.
from functools import wraps
def double(f):
@wraps(f)
def wrapper(*args, **kwargs):
return f(*args, **kwargs) * 2
return wrapper
@double
def valeur():
return 5
print(valeur())10
@loggerÉcrivez un décorateur @logger qui affiche "APPEL : nom_fonction(args, kwargs)" avant chaque appel, et "RETOUR : valeur" après.
from functools import wraps
def logger(fonction):
...
@logger
def addition(a, b):
return a + b
@logger
def saluer(nom, politesse="Bonjour"):
return f"{politesse}, {nom}"
addition(3, 5)
saluer("Alice")
saluer("Bob", politesse="Salut")from functools import wraps
def logger(fonction):
@wraps(fonction)
def wrapper(*args, **kwargs):
# Formatage des arguments pour affichage
args_str = ", ".join(repr(a) for a in args)
kwargs_str = ", ".join(f"{k}={v!r}" for k, v in kwargs.items())
signature = ", ".join(filter(None, [args_str, kwargs_str]))
print(f"APPEL : {fonction.__name__}({signature})")
resultat = fonction(*args, **kwargs)
print(f"RETOUR : {resultat!r}")
return resultat
return wrapper
@logger
def addition(a, b):
return a + b
@logger
def saluer(nom, politesse="Bonjour"):
return f"{politesse}, {nom}"
addition(3, 5)
print()
saluer("Alice")
print()
saluer("Bob", politesse="Salut")APPEL : addition(3, 5)
RETOUR : 8
APPEL : saluer('Alice')
RETOUR : 'Bonjour, Alice'
APPEL : saluer('Bob', politesse='Salut')
RETOUR : 'Salut, Bob'
'Salut, Bob'
@timer avec seuilÉcrivez un décorateur paramétré @alerte_lente(seuil) qui affiche un message si et seulement si la fonction met plus de seuil secondes à s’exécuter.
import time
from functools import wraps
def alerte_lente(seuil):
...
@alerte_lente(0.1)
def calcul_rapide():
return 42
@alerte_lente(0.1)
def calcul_lent():
time.sleep(0.2)
return "fini"
calcul_rapide() # pas d'alerte
calcul_lent() # alerteimport time
from functools import wraps
def alerte_lente(seuil):
def decorateur(fonction):
@wraps(fonction)
def wrapper(*args, **kwargs):
debut = time.time()
resultat = fonction(*args, **kwargs)
duree = time.time() - debut
if duree > seuil:
print(f"⚠️ {fonction.__name__} lente : {duree*1000:.0f} ms (seuil {seuil*1000:.0f} ms)")
return resultat
return wrapper
return decorateur
@alerte_lente(0.1)
def calcul_rapide():
return 42
@alerte_lente(0.1)
def calcul_lent():
time.sleep(0.2)
return "fini"
print(calcul_rapide())
print(calcul_lent())42
⚠️ calcul_lent lente : 201 ms (seuil 100 ms)
fini
@retryÉcrivez un décorateur @retry(n_essais) qui réessaie une fonction en cas d’exception, jusqu’à n_essais fois.
import random
from functools import wraps
def retry(n_essais):
...
@retry(3)
def operation_instable():
if random.random() < 0.7:
raise ValueError("Échec aléatoire")
return "succès"
operation_instable()import random
from functools import wraps
def retry(n_essais):
def decorateur(fonction):
@wraps(fonction)
def wrapper(*args, **kwargs):
derniere_exception = None
for essai in range(1, n_essais + 1):
try:
return fonction(*args, **kwargs)
except Exception as e:
derniere_exception = e
print(f" [Essai {essai}/{n_essais} échoué : {e}]")
raise derniere_exception
return wrapper
return decorateur
random.seed(42)
@retry(5)
def operation_instable():
if random.random() < 0.7:
raise ValueError("Échec aléatoire")
return "succès"
try:
print("Résultat :", operation_instable())
except Exception as e:
print(f"Abandonné après 5 essais : {e}") [Essai 1/5 échoué : Échec aléatoire]
[Essai 2/5 échoué : Échec aléatoire]
[Essai 3/5 échoué : Échec aléatoire]
[Essai 4/5 échoué : Échec aléatoire]
Résultat : succès
import random
import time
from functools import wraps
def retry(n_essais, delai=0.1):
def decorateur(fonction):
@wraps(fonction)
def wrapper(*args, **kwargs):
for essai in range(1, n_essais + 1):
try:
return fonction(*args, **kwargs)
except Exception as e:
print(f" [Essai {essai} échoué : {e}]")
if essai < n_essais:
time.sleep(delai)
raise RuntimeError(f"Abandonné après {n_essais} essais")
return wrapper
return decorateur@deprecatedCréez @deprecated(message) qui affiche un avertissement avant chaque appel, pour marquer une fonction obsolète.
from functools import wraps
import warnings
def deprecated(message=""):
...
@deprecated("Utilisez nouvelle_fonction() à la place")
def ancienne_fonction():
return "hello"
ancienne_fonction()from functools import wraps
import warnings
def deprecated(message=""):
def decorateur(fonction):
@wraps(fonction)
def wrapper(*args, **kwargs):
full_msg = f"{fonction.__name__}() est dépréciée"
if message:
full_msg += f". {message}"
warnings.warn(full_msg, DeprecationWarning, stacklevel=2)
return fonction(*args, **kwargs)
return wrapper
return decorateur
# Activer l'affichage des DeprecationWarning (par défaut elles sont masquées)
warnings.simplefilter("always", DeprecationWarning)
@deprecated("Utilisez nouvelle_fonction() à la place")
def ancienne_fonction():
return "hello"
import warnings
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
ancienne_fonction()
if w:
print(f"⚠️ {w[-1].category.__name__}: {w[-1].message}")⚠️ DeprecationWarning: ancienne_fonction() est dépréciée. Utilisez nouvelle_fonction() à la place
warnings.warn est préférable à print pour les avertissements : on peut les filtrer, les rediriger, les transformer en erreurs pour les tests…DeprecationWarning est la classe standard pour signaler qu’une fonctionnalité est obsolète.nonlocal pour modifier une variable du scope englobant (global pour le global).@decorateur est du sucre syntaxique pour f = decorateur(f).def wrapper(*args, **kwargs): ... return fonction(*args, **kwargs).@functools.wraps(fonction) pour préserver les métadonnées.@A @B f = f = A(B(f)). Le plus proche s’applique en premier.functools : wraps, cache, lru_cache, total_ordering, singledispatch.@staticmethod, @classmethod, @property (vus en POO).← Chapitre précédent : Itérateurs et générateurs • Chapitre suivant : Scopes et closures →