Décorateurs
Python
Modifier le comportement d'une fonction sans toucher à son code — @property, @classmethod, functools et décorateurs custom.
Fonctions comme objets
En Python, les fonctions sont des objets de première classe — on peut les passer en argument, les retourner, les stocker dans une variable. Les décorateurs reposent entièrement sur ce principe.
def saluer(nom: str) -> str:
return f"Bonjour, {nom}!"
def crier(nom: str) -> str:
return f"BONJOUR, {nom.upper()}!"
def appliquer(fn, valeur: str) -> str:
"""Appelle fn avec valeur."""
return fn(valeur)
print(appliquer(saluer, "Alice")) # Bonjour, Alice!
print(appliquer(crier, "Alice")) # BONJOUR, ALICE!
# Fonctions stockées dans une structure
actions = {
"saluer": saluer,
"crier": crier,
}
print(actions["crier"]("Bob")) # BONJOUR, BOB!
def multiplicateur(facteur: int):
"""Retourne une fonction qui multiplie par facteur."""
def multiplier(x: int) -> int:
return x * facteur # capture facteur
return multiplier # retourne la fonction
doubler = multiplicateur(2)
tripler = multiplicateur(3)
print(doubler(5)) # 10
print(tripler(5)) # 15
# La closure "se souvient" de facteur
print(doubler.__closure__[0].cell_contents) # 2
Une closure est une fonction interne qui capture les variables de sa fonction externe. C'est le mécanisme sous-jacent de tous les décorateurs.
Principe du décorateur
Un décorateur est une fonction qui prend une fonction en entrée et retourne une nouvelle fonction — généralement un wrapper qui ajoute un comportement avant et/ou après l'appel.
def ma_fn()@mon_decodef wrapper()# Étape 1 : décorateur = fonction qui prend une fonction
def logger(fn):
def wrapper(*args, **kwargs):
print(f"→ Appel de {fn.__name__}")
resultat = fn(*args, **kwargs) # appel original
print(f"← Fin de {fn.__name__}")
return resultat
return wrapper
# Étape 2 : application manuelle
def additionner(a, b):
return a + b
additionner = logger(additionner) # équivalent à @logger
print(additionner(3, 4))
# → Appel de additionner
# ← Fin de additionner
# 7
# Étape 3 : syntaxe @ (sucre syntaxique)
@logger
def additionner(a, b):
return a + b
# Identique à additionner = logger(additionner)
import time
from functools import wraps
def chrono(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
debut = time.perf_counter()
resultat = fn(*args, **kwargs)
duree = time.perf_counter() - debut
print(f"{fn.__name__} : {duree:.4f}s")
return resultat
return wrapper
@chrono
def calcul_long(n: int) -> int:
return sum(range(n))
calcul_long(1_000_000)
# calcul_long : 0.0312s
*args, **kwargs dans le wrapper est indispensable pour que le décorateur fonctionne avec n'importe quelle signature — fonctions sans argument, avec arguments positionnels ou nommés.
functools.wraps — préserver les métadonnées
def logger(fn):
def wrapper(*args, **kwargs):
return fn(*args, **kwargs)
return wrapper
@logger
def additionner(a: int, b: int) -> int:
"""Additionne deux entiers."""
return a + b
print(additionner.__name__) # "wrapper" ✗
print(additionner.__doc__) # None ✗
help(additionner) # montre wrapper, pas additionner
from functools import wraps
def logger(fn):
@wraps(fn) # ← copie __name__, __doc__, etc.
def wrapper(*args, **kwargs):
return fn(*args, **kwargs)
return wrapper
@logger
def additionner(a: int, b: int) -> int:
"""Additionne deux entiers."""
return a + b
print(additionner.__name__) # "additionner" ✓
print(additionner.__doc__) # "Additionne…" ✓
help(additionner) # docstring correcte
Toujours mettre @wraps(fn) sur le wrapper interne. Sans ça, les outils de debug, la génération de documentation et help() montrent les informations du wrapper au lieu de la fonction originale.
Décorateur avec arguments
Pour qu'un décorateur accepte des paramètres (@repeter(3)), il faut ajouter un niveau d'imbrication supplémentaire — une fonction qui retourne le décorateur.
from functools import wraps
def repeter(n: int):
"""Décorateur factory — retourne un décorateur."""
def decorateur(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
for _ in range(n):
resultat = fn(*args, **kwargs)
return resultat
return wrapper
return decorateur # retourne le décorateur
@repeter(3)
def saluer(nom: str) -> None:
print(f"Bonjour {nom}!")
saluer("Alice")
# Bonjour Alice!
# Bonjour Alice!
# Bonjour Alice!
from functools import wraps
import inspect
def valider_types(fn):
"""Vérifie les annotations de type à l'exécution."""
hints = fn.__annotations__
@wraps(fn)
def wrapper(*args, **kwargs):
sig = inspect.signature(fn)
params = list(sig.parameters.keys())
for i, val in enumerate(args):
nom = params[i]
if nom in hints:
attendu = hints[nom]
if not isinstance(val, attendu):
raise TypeError(
f"{nom} : attendu {attendu.__name__}, "
f"reçu {type(val).__name__}"
)
return fn(*args, **kwargs)
return wrapper
@valider_types
def diviser(a: float, b: float) -> float:
return a / b
diviser(10.0, 2.0) # OK
diviser("dix", 2) # TypeError: a : attendu float, reçu str
Décorateur implémenté comme classe
from functools import wraps
class Compteur:
"""Compte le nombre d'appels à une fonction."""
def __init__(self, fn) -> None:
wraps(fn)(self) # copier les métadonnées
self.fn = fn
self.appels = 0
def __call__(self, *args, **kwargs):
self.appels += 1
print(f"[Appel #{self.appels}] {self.fn.__name__}")
return self.fn(*args, **kwargs)
@Compteur
def traiter(x: int) -> int:
return x * 2
traiter(5) # [Appel #1] traiter
traiter(3) # [Appel #2] traiter
print(traiter.appels) # 2
Un décorateur implémenté comme classe est utile quand il faut maintenir un état entre les appels (compteur, cache, historique). La méthode __call__ est appelée à chaque invocation de la fonction décorée.
Comparaison des approches :
Fonction → cas général, simple, sans état
Classe → quand on a besoin d'un état persistant entre les appels
Empilage de décorateurs
@logger
@chrono
@valider_types
def ma_fonction(x: int) -> int:
return x * 2
# Équivalent à :
ma_fonction = logger(chrono(valider_types(ma_fonction)))
# Ordre d'exécution à l'appel :
# 1. wrapper de logger (avant)
# 2. wrapper de chrono (avant)
# 3. wrapper de valider_types (avant)
# 4. ma_fonction ← corps
# 5. wrapper de valider_types (après)
# 6. wrapper de chrono (après)
# 7. wrapper de logger (après)
L'ordre des décorateurs empilés a de l'importance. @chrono au-dessus de @logger mesure le temps incluant le log. En dessous, il ne mesure que la fonction nue.
@valider_types — appliqué en 1er@chrono — appliqué en 2e@logger — appliqué en dernier (couche externe)@property
@property transforme une méthode en attribut calculé. On peut accéder à objet.valeur sans parenthèses, tout en exécutant du code (validation, calcul).
class Temperature:
def __init__(self, celsius: float) -> None:
self._celsius = celsius # _ = convention "privé"
@property
def celsius(self) -> float:
"""Getter — lecture de la température."""
return self._celsius
@celsius.setter
def celsius(self, valeur: float) -> None:
"""Setter — validation à l'écriture."""
if valeur < -273.15:
raise ValueError("En dessous du zéro absolu !")
self._celsius = valeur
@property
def fahrenheit(self) -> float:
"""Propriété calculée — pas de setter."""
return self._celsius * 9 / 5 + 32
t = Temperature(100)
print(t.celsius) # 100 — appel du getter
print(t.fahrenheit) # 212.0 — calculé
t.celsius = 20 # appel du setter
t.celsius = -300 # ValueError !
Quand utiliser @property ? Quand un attribut nécessite une validation, un calcul, ou quand on veut changer l'implémentation interne sans modifier l'interface publique.
class Cercle:
def __init__(self, rayon: float):
self.rayon = rayon
@property
def aire(self) -> float:
import math
return math.pi * self.rayon ** 2
@property
def perimetre(self) -> float:
import math
return 2 * math.pi * self.rayon
c = Cercle(5)
print(c.aire) # 78.53... — syntaxe attribut
print(c.perimetre) # 31.41...
c.rayon = 10 # aire et périmètre se recalculent
@staticmethod
Une méthode statique ne reçoit ni self ni cls. C'est une fonction ordinaire logée dans la classe pour des raisons d'organisation — elle ne peut pas accéder à l'instance ni à la classe.
class Validateur:
@staticmethod
def est_email_valide(email: str) -> bool:
import re
pattern = r"^[\w.+-]+@[\w-]+\.[a-z]{2,}$"
return bool(re.match(pattern, email))
@staticmethod
def normaliser_telephone(tel: str) -> str:
return "".join(filter(str.isdigit, tel))
# Appel sans instancier la classe
Validateur.est_email_valide("alice@heh.be") # True
Validateur.normaliser_telephone("+32 496 12-34-56")
# Appel depuis une instance aussi
v = Validateur()
v.est_email_valide("test@test.com") # True
@staticmethod est utile pour les fonctions utilitaires fortement liées à une classe mais qui n'ont pas besoin d'accéder à son état. Si la fonction n'utilise aucun attribut ni méthode de la classe, envisager de la sortir comme fonction module.
@classmethod
Une méthode de classe reçoit cls (la classe elle-même) au lieu de self. Très utilisée pour les constructeurs alternatifs et les méthodes factory.
from __future__ import annotations
from datetime import date
class Personne:
def __init__(self, nom: str, annee_naissance: int):
self.nom = nom
self.annee = annee_naissance
@classmethod
def depuis_chaine(cls, chaine: str) -> Personne:
"""'Alice,1994' → Personne('Alice', 1994)"""
nom, annee = chaine.split(",")
return cls(nom.strip(), int(annee))
@classmethod
def depuis_dict(cls, data: dict) -> Personne:
return cls(data["nom"], data["annee"])
@property
def age(self) -> int:
return date.today().year - self.annee
p1 = Personne("Alice", 1994)
p2 = Personne.depuis_chaine("Bob, 1998")
p3 = Personne.depuis_dict({"nom": "Carol", "annee": 2000})
| self (instance) | cls (classmethod) | staticmethod | |
|---|---|---|---|
| Reçoit | self | cls | rien |
| Accès instance | ✓ | ✗ | ✗ |
| Accès classe | Via self.__class__ | ✓ direct | ✗ |
| Usage typique | Méthode normale | Constructeur alternatif | Utilitaire lié |
@classmethod utilise cls (et non la classe hardcodée) — ainsi, si on hérite de la classe, le constructeur alternatif retourne une instance de la sous-classe, pas de la classe parente.
@dataclass
@dataclass génère automatiquement __init__, __repr__ et __eq__ à partir des annotations de type — moins de code répétitif pour les classes de données.
# ✗ Classe classique : beaucoup de répétition
class Point:
def __init__(self, x: float, y: float):
self.x = x
self.y = y
def __repr__(self):
return f"Point(x={self.x}, y={self.y})"
def __eq__(self, other):
return self.x == other.x and self.y == other.y
# ✓ Avec @dataclass : identique en 3 lignes
from dataclasses import dataclass, field
@dataclass
class Point:
x: float
y: float
p1 = Point(1.0, 2.0)
p2 = Point(1.0, 2.0)
print(p1) # Point(x=1.0, y=2.0) ← __repr__ auto
print(p1 == p2) # True ← __eq__ auto
from dataclasses import dataclass, field
from typing import ClassVar
@dataclass(order=True, frozen=True)
class Produit:
# order=True : génère __lt__, __gt__…
# frozen=True : immutable (comme un namedtuple)
nom: str
prix: float
stock: int = 0 # valeur par défaut
tags: list = field(default_factory=list)
# ← liste mutable : toujours field()
def en_stock(self) -> bool:
return self.stock > 0 # méthodes OK
p = Produit("Clavier", 49.99, stock=10)
print(p) # Produit(nom='Clavier', prix=49.99, stock=10, tags=[])
p.prix = 39.99 # FrozenInstanceError (frozen=True)
Ne jamais utiliser une liste ou un dict comme valeur par défaut directement — utiliser field(default_factory=list). Sinon, toutes les instances partagent la même liste.
Cache & memoization
from functools import lru_cache, cache
# lru_cache : cache limité (Least Recently Used)
@lru_cache(maxsize=128)
def fibonacci(n: int) -> int:
if n < 2: return n
return fibonacci(n - 1) + fibonacci(n - 2)
fibonacci(100) # quasi-instantané (sans cache : 2^100 appels)
fibonacci.cache_info() # hits=98, misses=101, maxsize=128
fibonacci.cache_clear() # vider le cache
# cache : illimité (Python 3.9+)
@cache
def factorielle(n: int) -> int:
if n == 0: return 1
return n * factorielle(n - 1)
import time
from functools import wraps
def cache_ttl(duree_sec: int):
"""Cache avec expiration en secondes."""
def decorateur(fn):
_cache = {}
@wraps(fn)
def wrapper(*args):
maintenant = time.time()
if args in _cache:
resultat, expiration = _cache[args]
if maintenant < expiration:
return resultat
resultat = fn(*args)
_cache[args] = (resultat, maintenant + duree_sec)
return resultat
return wrapper
return decorateur
@cache_ttl(60) # cache pendant 60 secondes
def taux_de_change(devise: str) -> float:
# Simule un appel API lent
return 1.08
Retry & autres patterns utiles
import time
from functools import wraps
def retry(max_tentatives: int = 3, delai: float = 1.0,
exceptions: tuple = (Exception,)):
def decorateur(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
for tentative in range(1, max_tentatives + 1):
try:
return fn(*args, **kwargs)
except exceptions as e:
if tentative == max_tentatives:
raise
print(f"Tentative {tentative} échouée: {e}")
time.sleep(delai * tentative)
return wrapper
return decorateur
@retry(max_tentatives=3, delai=0.5,
exceptions=(ConnectionError, TimeoutError))
def appel_api(url: str) -> dict:
import requests
return requests.get(url).json()
from functools import singledispatch
@singledispatch
def afficher(valeur) -> str:
return f"Inconnu : {valeur}"
@afficher.register(int)
def _(valeur: int) -> str:
return f"Entier : {valeur:,}"
@afficher.register(list)
def _(valeur: list) -> str:
return f"Liste de {len(valeur)} éléments"
@afficher.register(str)
def _(valeur: str) -> str:
return f"Texte ({len(valeur)} cars)"
print(afficher(1_000_000)) # Entier : 1,000,000
print(afficher([1, 2, 3])) # Liste de 3 éléments
print(afficher("hello")) # Texte (5 cars)
Cheat sheet
Structure de base
| def deco(fn): | Décorateur simple |
| @wraps(fn) | Préserver nom/doc |
| def wrapper(*args, **kw) | Wrapper universel |
| return wrapper | Retourner le wrapper |
| def factory(arg): def deco(fn): | Décorateur avec args |
Décorateurs built-in
| @property | Getter calculé |
| @x.setter | Setter avec validation |
| @staticmethod | Pas de self/cls |
| @classmethod | Reçoit cls |
| @dataclass | __init__/__repr__/__eq__ auto |
| @dataclass(frozen=True) | Immuable |
functools
| @lru_cache(maxsize=128) | Cache LRU |
| @cache | Cache illimité |
| @singledispatch | Surcharge par type |
| fn.cache_info() | Stats du cache |
| fn.cache_clear() | Vider le cache |