23. Design Patterns Pythoniques

Singleton, Factory, Strategy, Observer, Repository, Adapter, Decorator (pattern), context manager pattern, patterns idiomatiques Python.


23.1 Singleton

Garantir une seule instance d’une classe.

Via métaclasse

class SingletonMeta(type):
    _instances = {}
 
    def __call__(cls, *args, **kwargs):
        if cls not in cls._instances:
            cls._instances[cls] = super().__call__(*args, **kwargs)
        return cls._instances[cls]
 
class Config(metaclass=SingletonMeta):
    def __init__(self):
        self.paramètres = {}

Via __new__

class Config:
    _instance = None
 
    def __new__(cls, *args, **kwargs):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
            cls._instance._initialisé = False
        return cls._instance
 
    def __init__(self):
        if not self._initialisé:
            self.paramètres = {}
            self._initialisé = True

Via module (le plus pythonique)

# config.py — un module est un singleton par nature
class _Config:
    ...
 
config = _Config()

Utilisation : from config import config.


23.2 Factory

Créer des objets sans exposer la logique d’instanciation.

Factory simple

class Animal:
    def parler(self): ...
 
class Chien(Animal):
    def parler(self): return "Woof"
 
class Chat(Animal):
    def parler(self): return "Miaou"
 
def créer_animal(type_animal: str) -> Animal:
    animaux = {"chien": Chien, "chat": Chat}
    cls = animaux.get(type_animal)
    if cls is None:
        raise ValueError(f"Type inconnu: {type_animal}")
    return cls()

Factory avec registre (pattern plus flexible)

class AnimalFactory:
    _registry: dict[str, type[Animal]] = {}
 
    @classmethod
    def register(cls, nom: str):
        def décorateur(animal_cls: type[Animal]) -> type[Animal]:
            cls._registry[nom] = animal_cls
            return animal_cls
        return décorateur
 
    @classmethod
    def créer(cls, nom: str, **kwargs) -> Animal:
        animal_cls = cls._registry.get(nom)
        if animal_cls is None:
            raise ValueError(f"Type inconnu: {nom}")
        return animal_cls(**kwargs)
 
@AnimalFactory.register("chien")
class Chien(Animal):
    def __init__(self, race="berger"):
        self.race = race

23.3 Strategy

Sélectionner un algorithme à l’exécution.

from typing import Protocol
 
class StratégieTri(Protocol):
    def trier(self, données: list[int]) -> list[int]: ...
 
class TriBulles:
    def trier(self, données: list[int]) -> list[int]:
        ...
 
class TriRapide:
    def trier(self, données: list[int]) -> list[int]:
        ...
 
class ContexteTri:
    def __init__(self, stratégie: StratégieTri):
        self.stratégie = stratégie
 
    def exécuter(self, données: list[int]) -> list[int]:
        return self.stratégie.trier(données)

Version fonctionnelle (plus pythonique) :

from typing import Callable
 
def tri_bulles(données: list[int]) -> list[int]: ...
def tri_rapide(données: list[int]) -> list[int]: ...
 
class ContexteTri:
    def __init__(self, stratégie: Callable[[list[int]], list[int]]):
        self.stratégie = stratégie
 
    def exécuter(self, données: list[int]) -> list[int]:
        return self.stratégie(données)

Cas concret : GAR en ML distribué.

from typing import Protocol
import numpy as np
 
class Agrégateur(Protocol):
    """Stratégie d'agrégation de gradients byzantins."""
    def agréger(self, gradients: np.ndarray) -> np.ndarray: ...
 
class Médiane:
    def agréger(self, gradients: np.ndarray) -> np.ndarray:
        return np.median(gradients, axis=0)
 
class Krum:
    def agréger(self, gradients: np.ndarray) -> np.ndarray:
        ...
 
class Moyenne:
    def agréger(self, gradients: np.ndarray) -> np.ndarray:
        return np.mean(gradients, axis=0)
 
class ApprentissageDistribué:
    def __init__(self, agrégateur: Agrégateur):
        self.agrégateur = agrégateur

23.4 Observer

Notifier des dépendants d’un changement d’état.

from typing import Protocol
 
class Observateur(Protocol):
    def mettre_à_jour(self, sujet: "Sujet") -> None: ...
 
class Sujet:
    def __init__(self):
        self._observateurs: list[Observateur] = []
 
    def attacher(self, obs: Observateur) -> None:
        self._observateurs.append(obs)
 
    def détacher(self, obs: Observateur) -> None:
        self._observateurs.remove(obs)
 
    def notifier(self) -> None:
        for obs in self._observateurs:
            obs.mettre_à_jour(self)

Version avec événements (plus pythonique) :

from dataclasses import dataclass, field
from typing import Callable
 
@dataclass
class ÉmetteurÉvénements:
    _callbacks: dict[str, list[Callable]] = field(default_factory=lambda: {})
 
    def on(self, événement: str, callback: Callable):
        self._callbacks.setdefault(événement, []).append(callback)
 
    def émettre(self, événement: str, **données):
        for cb in self._callbacks.get(événement, []):
            cb(**données)
 
emetteur = ÉmetteurÉvénements()
emetteur.on("train:epoch_end", lambda epoch, loss: print(f"Epoch {epoch}: {loss}"))
emetteur.émettre("train:epoch_end", epoch=5, loss=0.02)

23.5 Repository

Abstraction de la couche de stockage.

from typing import Protocol, Optional
 
class Repository(Protocol):
    def get(self, id: str) -> Optional[dict]: ...
    def save(self, entity: dict) -> None: ...
    def delete(self, id: str) -> None: ...
    def list(self) -> list[dict]: ...
 
class MémoireRepo:
    def __init__(self):
        self._data: dict[str, dict] = {}
 
    def get(self, id: str) -> Optional[dict]:
        return self._data.get(id)
 
    def save(self, entity: dict) -> None:
        self._data[entity["id"]] = entity
 
    def delete(self, id: str) -> None:
        self._data.pop(id, None)
 
    def list(self) -> list[dict]:
        return list(self._data.values())
 
class JSONRepo:
    def __init__(self, chemin: str):
        self.chemin = chemin
 
    def get(self, id: str) -> Optional[dict]: ...
    def save(self, entity: dict) -> None: ...
    def delete(self, id: str) -> None: ...
    def list(self) -> list[dict]: ...

23.6 Adapter

Rendre une interface incompatible compatible.

# Interface cible
class APIEntraînement(Protocol):
    def fit(self, X, y) -> None: ...
    def predict(self, X): ...
 
# Bibliothèque externe avec interface différente
class SklearnModel:
    def fit_transform(self, X, y): ...
    def infer(self, X): ...
 
# Adaptateur
class AdaptateurSKLearn:
    def __init__(self, model):
        self._model = model
 
    def fit(self, X, y):
        self._model.fit_transform(X, y)
 
    def predict(self, X):
        return self._model.infer(X)

23.7 Decorator (pattern)

Ajouter des responsabilités dynamiquement. Ne pas confondre avec les décorateurs Python (leçon 14).

from typing import Protocol
 
class Notifieur(Protocol):
    def envoyer(self, message: str) -> None: ...
 
class NotifieurBase:
    def envoyer(self, message: str) -> None:
        print(f"Notification: {message}")
 
class NotifieurDecorator:
    def __init__(self, wrappé: Notifieur):
        self._wrappé = wrappé
 
class NotifieurEmail(NotifieurDecorator):
    def envoyer(self, message: str) -> None:
        self._wrappé.envoyer(message)
        print(f"  + Email envoyé")
 
class NotifieurSMS(NotifieurDecorator):
    def envoyer(self, message: str) -> None:
        self._wrappé.envoyer(message)
        print(f"  + SMS envoyé")
 
n = NotifieurSMS(NotifieurEmail(NotifieurBase()))
n.envoyer("Entraînement terminé")
# Notification: Entraînement terminé
#   + Email envoyé
#   + SMS envoyé

23.8 Patterns idiomatiques Python

Context manager pour resource management

from contextlib import contextmanager
 
@contextmanager
def session_db(url):
    conn = créer_connexion(url)
    try:
        yield conn
    finally:
        conn.fermer()

EAFP vs LBYL

# LBYL (Look Before You Leap) — Java-style
if "clé" in d and isinstance(d["clé"], int):
    valeur = d["clé"]
else:
    valeur = 0
 
# EAFP (Easier to Ask for Forgiveness than Permission) — Pythonic
try:
    valeur = d["clé"]
except (KeyError, TypeError):
    valeur = 0

Duck typing

def sauvegarder(obj):
    # Pas de vérification de type — on appelle la méthode
    sérialisé = obj.to_dict()  # si ça a to_dict(), ça marche
    ...

Descripteur pour validation réutilisable

class Validé:
    def __set_name__(self, owner, name):
        self.nom = f"_{name}"
 
    def __get__(self, obj, objtype=None):
        return getattr(obj, self.nom)
 
    def __set__(self, obj, valeur):
        self.valider(valeur)
        setattr(obj, self.nom, valeur)
 
    def valider(self, valeur):
        ...
 
class Positif(Validé):
    def valider(self, valeur):
        if valeur < 0:
            raise ValueError(f"Doit être positif, reçu {valeur}")
 
class Point:
    x = Positif()
    y = Positif()

23.9 Tableau récapitulatif

PatternProblèmeSolution Pythonique
SingletonInstance uniqueModule (import) ou métaclasse
FactoryCréation conditionnelleFonction + dict de classes, __init_subclass__
StrategyAlgorithme interchangeableProtocol + classes, ou Callable
ObserverNotification de changementCallbacks / événements
RepositoryAbstraction stockageProtocol + implémentations concrètes
AdapterInterface incompatibleClasse wrapper
Decorator (GoF)Ajout dynamique de responsabilitéWrapper avec composition
EAFPGestion d’erreurstry / except

🔗 ← Retour au cours · ← précédent · Suivant →