Programmation Python

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.

Fonctions passées comme arguments
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!
Fonctions imbriquées — closures
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.

fonction originale
def ma_fn()
décorateur
@mon_deco
fonction améliorée
def wrapper()
Décorateur minimal — étape par étape
# É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)
Décorateur de timing
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

Sans wraps — métadonnées perdues
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
Avec @wraps — métadonnées préservées
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.

@repeter(n) — 3 niveaux d'imbrication
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!
@valider_types — vérification des types
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

Classe comme décorateur avec état
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

Ordre d'application — de bas en haut
@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.

Application (de bas en haut)
@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).

@property, @setter, @deleter
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.

@property vs attribut simple
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.

@staticmethod — utilitaires de 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.

@classmethod — constructeurs alternatifs
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çoitselfclsrien
Accès instance
Accès classeVia self.__class__✓ direct
Usage typiqueMéthode normaleConstructeur alternatifUtilitaire 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.

@dataclass — comparaison avec classe classique
# ✗ 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
@dataclass — options avancées
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

functools.lru_cache & cache
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)
Cache personnalisé avec TTL
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

@retry — réessayer en cas d'erreur
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()
functools.singledispatch — surcharge par type
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 wrapperRetourner le wrapper
def factory(arg): def deco(fn):Décorateur avec args

Décorateurs built-in

@propertyGetter calculé
@x.setterSetter avec validation
@staticmethodPas de self/cls
@classmethodReçoit cls
@dataclass__init__/__repr__/__eq__ auto
@dataclass(frozen=True)Immuable

functools

@lru_cache(maxsize=128)Cache LRU
@cacheCache illimité
@singledispatchSurcharge par type
fn.cache_info()Stats du cache
fn.cache_clear()Vider le cache