---
jupytext:
  text_representation:
    extension: .md
    format_name: myst
    format_version: 0.13
    jupytext_version: 1.16.0
kernelspec:
  name: python3
  display_name: Python 3
---

# Décorateurs

```{code-cell} python
:tags: [hide-input]

import matplotlib.pyplot as plt
import matplotlib.patches as patches
import numpy as np
import seaborn as sns

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
```

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

```{code-cell} python
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"
```

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

```{prf:definition} Décorateur
:label: definition-11-01
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.
```

```{code-cell} python
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")
```

Appliquons la syntaxe longue pour bien voir ce qui se passe :

```{code-cell} python
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)
```

## `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.

```{code-cell} python
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 !
```

```{code-cell} python
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__
```

```{prf:remark}
:label: remark-11-01
`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.

```{prf:definition} Décorateur paramétré
:label: definition-11-02
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)`.
```

```{code-cell} python
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")
```

```{code-cell} python
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}")
```

## 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).

```{code-cell} python
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()}")
```

### Minuterie (*timer*)

```{code-cell} python
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")
```

### Réessai (*retry*)

```{code-cell} python
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())
```

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

```{code-cell} python
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()
```

```{code-cell} python
# 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
```

## 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__`.

```{code-cell} python
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
```

```{code-cell} python
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()}")
```

## Visualisation du mécanisme de décoration

```{code-cell} python
:tags: [hide-input]

fig, axes = plt.subplots(1, 2, figsize=(14, 7))
palette = sns.color_palette("Set2", 5)

# ─── Diagramme 1 : mécanisme d'un décorateur simple ───
ax = axes[0]
ax.set_xlim(0, 10)
ax.set_ylim(0, 10)
ax.axis('off')
ax.set_title("Décorateur simple", fontsize=13, fontweight='bold')

blocs = [
    (1.0, 7.5, 8.0, 1.5, palette[0], "def décorateur(fonction):"),
    (1.5, 5.5, 7.0, 1.5, palette[1], "def enveloppe(*args, **kwargs):"),
    (2.0, 3.8, 6.0, 1.2, palette[2], "# logique avant"),
    (2.0, 2.5, 6.0, 1.2, palette[3], "résultat = fonction(*args, **kwargs)"),
    (2.0, 1.2, 6.0, 1.2, palette[4], "# logique après / return résultat"),
]

for (x, y, w, h, col, txt) in blocs:
    box = patches.FancyBboxPatch((x, y), w, h,
        boxstyle="round,pad=0.1", linewidth=1.5,
        edgecolor=col, facecolor=col, alpha=0.2)
    ax.add_patch(box)
    brd = patches.FancyBboxPatch((x, y), w, h,
        boxstyle="round,pad=0.1", linewidth=1.5,
        edgecolor=col, facecolor='none')
    ax.add_patch(brd)
    ax.text(x + w / 2, y + h / 2, txt,
            ha='center', va='center', fontsize=9,
            fontfamily='monospace', color='#222')

ax.text(5, 9.3, "@décorateur", ha='center', fontsize=11,
        fontweight='bold', color=palette[0],
        bbox=dict(boxstyle='round,pad=0.3', facecolor=palette[0], alpha=0.15))
ax.annotate('', xy=(5, 9.0), xytext=(5, 8.8),
            arrowprops=dict(arrowstyle='->', color='#555', lw=1.5))

# ─── Diagramme 2 : ordre d'empilement ───
ax2 = axes[1]
ax2.set_xlim(0, 10)
ax2.set_ylim(0, 10)
ax2.axis('off')
ax2.set_title("Empilement de décorateurs (ordre d'application)", fontsize=13, fontweight='bold')

couches = [
    (0.5, 7.5, 9.0, 1.5, palette[0], "@décorateur_A", "1ᵉʳ appliqué (externe)"),
    (1.2, 5.5, 7.6, 1.5, palette[1], "@décorateur_B", "2ᵉ appliqué (interne)"),
    (2.2, 3.5, 5.6, 1.5, palette[2], "def f(...):", "Fonction originale"),
]

for (x, y, w, h, col, titre, note) in couches:
    box = patches.FancyBboxPatch((x, y), w, h,
        boxstyle="round,pad=0.12", linewidth=1.8,
        edgecolor=col, facecolor=col, alpha=0.18)
    ax2.add_patch(box)
    brd = patches.FancyBboxPatch((x, y), w, h,
        boxstyle="round,pad=0.12", linewidth=1.8,
        edgecolor=col, facecolor='none')
    ax2.add_patch(brd)
    ax2.text(x + w / 2, y + h - 0.45, titre,
             ha='center', va='center', fontsize=11,
             fontfamily='monospace', fontweight='bold', color=col)
    ax2.text(x + w / 2, y + 0.4, note,
             ha='center', va='center', fontsize=8.5,
             color='#555', style='italic')

ax2.text(5, 1.8, "f = décorateur_A(décorateur_B(f))", ha='center',
         fontsize=10, fontfamily='monospace',
         bbox=dict(boxstyle='round,pad=0.3', facecolor='#eee', alpha=0.8))

ax2.annotate('', xy=(5, 2.6), xytext=(5, 3.5),
             arrowprops=dict(arrowstyle='<-', color='#888', lw=1.5))

plt.tight_layout()
plt.show()
```

## 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écorateur` est un sucre syntaxique pour `f = décorateur(f)`.
- `functools.wraps` doit 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 `def` est 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.
