Quelques design patterns commentés en python
Qu’est-ce qu’un design pattern ?
Traduisible par patron (ou modèle) de conception, ce principe connaît de nombreuses définitions. On pourrait en faire une synthèse en disant qu’il s’agit d’une réponse à un problème, décrit par du code.
Le problème peut autant être générique que spécifique et peut se formuler comme ceci : “Comment faire pour… (quand/si…)”
La réponse peut être définie en pseudo-code ou dans une implémentation propre à un langage.
Il s’agit souvent de code à fort niveau d’abstraction qui permet un large domaine d’application. Afin de mieux s’y retrouver, on les classe souvent en trois catégories : création, structure et comportement. La plupart des développeurs les utilisent souvent de façon intuitive sans forcément en connaître la nature car le langage qu’ils utilisent intègre ces patterns dans la syntaxe basique (built-in).
Prenons un exemple introductif, celui du décorateur (“Comment faire pour appliquer un comportement à un objet sans toucher à sa structure”). Sans entrer dans les détails, le décorateur permet d’éviter de créer des sous-classes ayant le comportement désiré, ce qui n’impacte pas la compilation (ce qui peut se révéler important avec les langages qui ont une séparation forte entre ce concept et l’exécution).
Son implémentation requiert d’envelopper (wrap) un objet, en prenant éventuellement des paramètres externes en compte, d’appliquer le comportement voulu puis de l’intégrer au comportement initial. L’intérêt qui est le plus souvent retenu est la simplification syntaxique (on parle souvent de “sucre syntaxique”) sans qu’il soit pour autant nécessaire d’en comprendre tous les détails pour en profiter.
def objection(func):
def wrap(*args):
return not func(*args)
return wrap
@objection
def coupable(verdict: bool) -> bool:
return verdict
coupable(verdict=True)
>> False
Ayant posé cette base, nous évoquerons dans cet article les éléments suivants :
En premier lieu, deux patterns permettant de découpler du code rigide se basant sur de l’héritage :
Puis :
- Le composite, un pattern permettant une représentation hiérarchique
Et enfin, deux patterns qui sont fondés sur des besoins de modularité :
En quoi est-ce utile d’entrer dans les détails ?
Une des règles implicites du métier de développeur consiste à éviter de vouloir réinventer la roue. Cela se tient car dans la majeure partie des cas, les langages (ou frameworks) proposent une implémentation de la plupart des patterns courants, généralement très performante.
Si l’on excepte des cas extrêmes comme la fusion de patterns, l’optimisation à très bas niveau ou certaines spécificités (dette technique, éléments propres au métier ou à une stack technologique…), on peut distinguer deux principales raisons pour s’intéresser au sujet :
La diffusion des bonnes pratiques
Sur le principe du consensus scientifique, si la plupart des design patterns sont relativement anciens, la raison en est que les spécialistes et chercheurs en ingénierie informatique (ou toute personne apportant sa pierre à l’édifice), se sont accordés sur une représentation du problème et la solution la plus adaptée pour y répondre.
La référence en la matière s’appelle Design Patterns: Elements of Reusable Object-Oriented Software par Erich Gamma, Richard Helm, Ralph Johnson et John Vlissides (auteurs connus sous le nom Gang of Four). Édité en 1995, vous pouvez le trouver gratuitement (et légalement), le plus souvent formulé par rapport à un langage (le plus couramment en Java) même si l’original utilisait des exemples en C++.
La qualité d’une solution, que l’on parle de sa représentation (diagramme UML par exemple) ou de son implémentation, repose sur des principes couramment partagés sous des acronymes divers (KISS, SOLID…) mais que l’on peut tenter de résumer par :
- simplicité (atomicité du problème)
- lisibilité (compréhension immédiate des enjeux et de l’intention)
- applicabilité (généricité de la solution pour en faciliter l’extension)
La compréhension personnelle
Bien évidemment, cette notion va être propre à chaque lecteur-trice. Le comportement d’un bloc de code qui repose sur un pattern peut paraître opaque, complexe, voire nébuleux du fait du fort niveau d’abstraction et d’une implémentation dont la clarté n’est pas l’objectif premier.
Si l’on veut prendre l’exemple d’un célèbre jeu de construction, le design pattern serait un assemblage de briques élémentaires que l’on peut utiliser ou exploiter de différentes manières comme l’intersection d’un mur, une pente de toit ou une voûte.
Le fait de comprendre comment quelque chose de si simple peut devenir fondamental par sa polyvalence est un atout quand l’on souhaite avoir une approche critique de son propre code.
Du bridge à l’adapter
Comment faire pour réduire le besoin de créer de nouvelles classes quand elles ont un certain degré de similarité ?
Bridge
La manière classique de formuler ce problème est de parler de découplage entre abstraction et implémentation. Prenons les termes un par un.
L’abstraction consiste à définir le concept d’un objet (ses caractéristiques, spécificités, son domaine de responsabilité…).
L’implémentation consiste à décrire concrètement ces variations.
Le couplage, enfin, désigne le niveau de dépendance entre les deux notions précédentes. Par exemple un fort couplage rend difficile la possibilité de modifier une partie du code sans altérer le comportement de l’autre. Il n’y a pas de règle tacite sur un degré de couplage optimal, mais le plus souvent, un couplage trop fort introduit des problèmes (maintenabilité, contraintes d’architecture…). Bien entendu, l’inverse possède aussi ses défauts (cohérence entre les différentes parties…). Le principe de maintien d’une forme d’indépendance est un sujet en soi référencé sous le terme anglo-saxon loose coupling.
Par exemple, on pourrait exprimer un fruit comme un aliment (abstraction) qui peut être issu de l’agriculture biologique ou non, autant que de la viande peut l’être (implémentation) :
class Aliment:
cuit: bool = False
def __init__(self, nom: str, consommation: Literal["cru", "cuit"]):
self.nom = nom
self.consommation = consommation
class FruitBio(Aliment):
def cuisiner(self, peau_comestible: bool):
if not peau_comestible:
eplucher()
if self.consommation == "cuit":
self.cuit = True
return self
class FruitConventionnel(Aliment):
def cuisiner(self, peau_comestible: bool):
laver()
if not peau_comestible:
eplucher()
if self.consommation == "cuit":
self.cuit = True
return self
class ViandeBio(Aliment):
pass
class ViandeConventionnelle(Aliment):
pass
Le principe du produit cartésien suggère le souci planant à l’horizon : le nombre de classes à définir double pour chaque
nouvel aliment (pour lequel la distinction est pertinente). On parle alors d’augmentation de la complexité, voire
d’explosion. Imaginons maintenant qu’on veuille parler de FruitBioLocal
et le facteur de croissance augmente d’un cran.
Une solution dans ce cas est de découpler les notions en créant de l’abstraction depuis l’implémentation. Nous allons créer un “pont” entre les abstractions en les matérialisant par des classes différentes. Dans le monde des patterns, ce principe est référencé sous l’expression “favor composition over inheritance” (préférer la composition -de classes- à l’héritage).
Visualisons cette application afin de l’analyser.
class Agriculture:
pass
class Bio(Agriculture):
laver: bool = False
class Conventionnelle(Agriculture):
laver: bool = True
class Aliment:
cuit: bool = False
def __init__(self, production: Agriculture, consommation: Literal["cru", "cuit"]):
self.production = production
self.consommation = consommation
def cuisiner(self):
if self.consommation == "cuit":
self.cuit = True
return self
class Fruit(Aliment):
def __init__(self, production: Agriculture, consommation: Literal["cru", "cuit"], peau_comestible: bool):
self.peau_comestible = peau_comestible
super().__init__(production)
def cuisiner(self):
if isinstance(self.production, Conventionnelle):
laver()
if not self.peau_comestible:
eplucher()
return super().cuisiner()
class Viande(Aliment):
def __init__(self, production: Agriculture, consommation: Literal["cru", "cuit"]):
if self.consommation == "cru":
raise Toxoplasmose("⚰️")
super().__init__(production, consommation)
porc_bio = Viande(Bio(), "cuit")
orange_vrac = Fruit(Conventionnelle(), "cru", peau_comestible=False)
Sans aller jusqu’au bout de l’implémentation (il faudra quand même cuisiner la viande…) ni tenter d’évaluer objectivement
l’apport en lisibilité et en cohérence de cette implémentation, l’intérêt apparaît assez vite. Si l’on souhaite
introduire la notion de circuit de production comme évoqué plus haut (court ou long), il suffit juste de créer cette
classe et ses sous-classes, si tant est que cette notion influe sur la classe Aliment
de manière significative, et
d’en modifier le constructeur pour accepter une instance de plus.
Adapter
La méthode précédente demande néanmoins de pouvoir changer plus ou moins en profondeur la logique et l’implémentation. Il est tout à fait possible de se trouver dans une situation ou le maintien des classes historiques telles qu’elles sont définies est nécessaire (ou dans un cas où le changement serait trop long et/ou coûteux). Par exemple, une classe qui définit tout ou partie d’une API utilisée par des tiers ou utilisée par du code historique (concept aussi connu comme legacy) rend risqué un changement qui peut perturber la continuité d’usage. Bien entendu, ce critère doit s’analyser dans un contexte qu’il serait trop long de couvrir en totalité, mais on peut citer la couverture de tests, la maîtrise de la dette technique, le capital humain…
Le design pattern adapter est à la fois plus simple et respecteux du code existant quand il s’agit d’ajouter de nouvelles fonctionnalités à un niveau global. Toutefois, cette simplicité est relative : le code est explicite mais il peut très vite devenir verbeux. La plupart des cas d’usages recouvre la mise en place rapide d’une solution en minimisant les risques mais en contrepartie du maintien d’un niveau de dette technique, voire son augmentation. Reprenons un exemple similaire au précédent adapté aux besoins de la démonstration.
class Charcuterie:
def griller(self):
return "🍖"
class PommeDeTerre:
def bouillir(self):
return "🥔"
def frire(self):
return "🍟"
class Fromage:
def fondre(self):
return "🫕"
Une version naïve de la préparation de n’importe quel ingrédient pourrait être :
def cuisiner(aliment: Any, gras: bool = False):
if isinstance(aliment, Charcuterie):
return aliment.griller()
elif isinstance(aliment, PommeDeTerre):
if gras:
return aliment.frire()
else:
return aliment.bouillir()
elif isinstance(aliment, Fromage):
return aliment.fondre()
Ce qui n’est ni très compréhensible (malgré le faible nombre de classes) ni facile à maintenir.
Nous allons créer un adaptateur qui va rendre générique la notion de cuisson sans référencer spécifiquement chaque classe.
class Cuisiner:
def __init__(self, aliment: Any, **methodes):
self.aliment = aliment
self.__dict__.update(methodes) # La structure de donnée interne va stocker les associations
def __getattr__(self, item): # Cette méthode va permettre de faire l'association en elle-même
return getattr(self.aliment, item)
Nous ne nous occupons que des méthodes mais il est bien sûr possible de prendre en compte des propriétés ou un choix des méthodes plus paramétré. L’utilisation se comprend assez naturellement :
charcuterie = Charcuterie()
pdt = PommeDeTerre()
fromage = Fromage()
aliments = [
Cuisiner(charcuterie, cuire=charcuterie.griller),
Cuisiner(pdt, cuire=pdt.bouillir, cuire_autrement=pdt.frire),
Cuisiner(fromage, cuire=fromage.fondre),
]
for aliment in aliments:
aliment.cuire()
Le composite
Comment représenter une hiérarchie si l’on s’intéresse principalement aux regroupements ?
Imaginons que l’on souhaite représenter différents emballages de produits en vue de les conditionner. Chaque parent pourrait être défini par rapport au précédent (un conteneur contient des palettes, qui contiennent des cartons, qui contiennent des produits). Cependant, si l’on ne s’intéresse qu’à des notions génériques (volume, référence…), deux classes peuvent suffire : l’objet primitif et l’objet complexe (ou agrégeant).
Ce pattern est souvent associé à une représentation en arbre car seuls deux éléments servent à définir un ensemble de complexité croissante.
Voyons ce qu’une telle représentation peut donner :
class Contenu: # Appelé "feuille" par analogie avec la représentation en arbre
def __init__(self, symbole: str, volume: int):
self.volume = volume
self.representation = volume * symbole
def voir(self):
print(self.representation)
class Contenant: # Appelé composite, représente tout élément pouvant contenir une feuille ou un autre composite
def __init__(self, reference: str, volume: int):
self.volume = volume
self.reference = reference
self.representation = []
def ajouter(self, enfant: Any):
self.volume -= enfant.volume
if self.volume < 0:
raise Plein
self.representation.append(enfant)
def voir(self):
print(f"{self.reference} :\n[")
for enfant in self.representation:
enfant.voir()
print("]")
print(f"Place restante : {self.volume} unités de produit")
A première vue, peu de choses diffèrent entre les deux classes hormis cette notion d’agrégation. Les propriétés et méthodes se recoupent car on cherche avant tout à illustrer une hiérarchie homogène. Le code client se résume à des déclarations types :
telephone, tablette, portable = Contenu("X", 1), Contenu("O", 2), Contenu("U", 4)
carton, autre_carton, palette = Contenant("Carton", 20), Contenant("Carton", 20), Contenant("Palette", 600)
carton.ajouter(telephone)
carton.ajouter(telephone)
carton.ajouter(telephone)
carton.ajouter(telephone)
autre_carton.ajouter(telephone)
autre_carton.ajouter(tablette)
autre_carton.ajouter(tablette)
autre_carton.ajouter(portable)
palette.ajouter(carton)
palette.ajouter(autre_carton)
Le résultat de la fonction d’affichage de la “racine” de l’arbre correspond à ce qui est attendu :
Palette :
[
Carton : [ X X X X ]
Place restante : 16 unités de produit
Carton : [ X OO OO UUUU ]
Place restante : 11 unités de produit
]
Place restante : 573 unités de produit
Ce pattern possède des avantages en termes de performance (moins d’objets à stocker en mémoire) et de flexibilité (moins de classes à gérer). Toutefois, les contreparties ne sont pas négligeables puisqu’il est impossible de spécialiser le composite à travers une logique spécifique ou de le typer. Il faut donc en principe le réserver à un usage limité tout en sachant qu’on ne pourra l’étendre que dans la mesure où la nouvelle notion est compatible avec chaque niveau hiérarchique possible.
La factory
Comment découpler du code quand l’instanciation repose sur une logique conditionnelle ?
Imaginons qu’une agence de voyage souhaite proposer différentes expériences de voyage selon plusieurs critères. Prenons un exemple de code client (volontairement schématique et non exhaustif) sans se préoccuper pour le moment des classes.
if client.age < 20:
if client.voyage_seul:
return Survie(client)
elif client.sportif:
return Escalade(client)
elif client.age < 40:
if client.voyage_en_groupe:
return Festival(client)
else:
return Farniente(client)
Il est assez aisé de voir en quoi la maîtrise de ce code est impossible à moyen terme.
Commençons par définir quelques éléments structurants :
class Client:
physique = 0 # 0 (calme) <==================> 1 (extrême)
social = 0 # 0 (sentiers battus) <========> 1 (club)
confort = 0 # 0 (aucun) <==================> 1 (palace)
def __init__(self, physique: float, social: float, confort: float, budget_max: int):
self.physique, self.social, self.confort, self.budget_max = physique, social, confort, budget_max
class Item: # Simplification de trois classes Transport, Hebergement et Activite
def __init__(self, name: str, prix_de_base: int, coefficient_prix: float):
self.name = name
self.prix_de_base = prix_de_base
self.coefficient_prix = coefficient_prix
def prix(self) -> float:
return (1 + self.coefficient_prix) * self.prix_de_base
La classe Client
va abstraire les conditions d’instanciation et Item
les différents éléments constitutifs d’une
expérience. Décrivons maintenant la factory :
class ExperienceFactory: # Classe abstraite
def __init__(self, client: Client):
self.client = client
def choisir_transport(self, client: Client) -> Item:
pass
def choisir_hebergement(self, client: Client) -> Item:
pass
def choisir_activite(self, client: Client) -> Item:
pass
Les trois méthodes vont servir à créer des objets concrets qui seront constitutifs de l’objet final. Regardons en premier le code qui va exploiter la factory:
class Voyage:
def __init__(self, factory: ExperienceFactory, client: Client):
self.factory = factory
self.client = client
self.transport = factory.choisir_transport(self.client)
self.hebergement = factory.choisir_hebergement(self.client)
self.activite = factory.choisir_activite(self.client)
def valider(self):
if self.transport.prix() + self.hebergement.prix() + self.activite.prix() > self.client.budget_max:
raise HorsBudget
Cette classe ne possède qu’une seule méthode mais il est tout à fait possible d’implémenter des méthodes de comparaison
d’objets (en redéfinissant __gt__
et __eq__
sur des critères de prix ou un scoring plus complexe) ou de la logique
métier.
L’intérêt se trouve maintenant dans la définition de la classe implémentant la factory :
class TrekAsiatique(ExperienceFactory):
def choisir_transport(self, client: Client) -> Item:
return Item("LongCourrier", 1_000, self.client.confort)
def choisir_hebergement(self, client: Client) -> Item:
if client.confort <= 0.25:
return Item("CampingSauvage", 500, self.client.confort)
else:
return Item("Yourte", 1_200, self.client.confort)
def choisir_activite(self, client: Client):
if client.physique > 0.5:
if client.social < 0.3:
return Item("DefiMontagne", 800, self.client.physique)
else:
return Item("VisiteDesGorges", 600, self.client.confort)
else:
return Item("DecouverteDesPlaines", 1_000, self.client.confort)
Il s’agit là de la seule partie du code qui sera variable à moins d’étendre le modèle, comme intégrer la notion d’âge de l’exemple initial. Même si le code est plus complexe et plus polyvalent, il a gagné en clarté. Intégrer une nouvelle expérience se limitera à une nouvelle classe qui implémentera sa propre logique dans le schéma existant.
La chain of responsibility
Comment découpler du code à partir d’une logique séquentielle ?
Le dernier pattern que nous évoquerons dans cet article part du même constat que le précédent (une série de conditions assez complexe) mais avec un paradigme différent dans le sens où nous souhaitons que notre objet passe par une succession d’étapes qui lui donneront sa forme finale. On peut visualiser cela comme une série d’entonnoirs ou de moules que l’on peut agencer en fonction des besoins.
Prenons comme exemple du code qui permette de générer des chapeaux festifs. La première brique (que l’on appelle handler car il va avoir une responsabilité, donc devoir gérer son domaine) s’occupera de la forme du chapeau, la seconde la couleur globale, la troisième les motifs. Commençons par définir l’objet puis l’abstraction du handler :
class Chapeau:
forme: str = ""
couleur: str = ""
motifs: str = ""
class Modificateur: # Handler abstrait
def modificateur_suivant(self, modificateur):
pass
def modifie_chapeau(self, chapeau: Chapeau):
pass
Le chapeau est défini par ses trois propriétés principales et le modificateur (il s’agira du handler ici) par deux méthodes, l’une qui va gérer la séquence des modificateurs (leur ordre dans une logique séquentielle) et l’autre la logique métier de modification de l’objet. Ce modificateur va être décrit formellement ainsi :
class ModificateurChapeau(Modificateur):
_modificateur_suivant: Modificateur = None
def modificateur_suivant(self, modificateur: Modificateur) -> Modificateur:
self._modificateur_suivant = modificateur
return modificateur
def modifie_chapeau(self, chapeau: Chapeau):
if self._modificateur_suivant:
return self._modificateur_suivant.modifie_chapeau(chapeau)
return chapeau
Les deux méthodes ne font rien de complexe :
- affecter le modificateur suivant à la classe initiale (et ainsi de suite à travers les appels successifs)
- appeler la fonction de modification du handler suivant pour constituer une chaîne
La logique métier sera circonscrite à la définition des classes implémentant le modificateur de base :
class FormeChapeau(ModificateurChapeau):
def modifie_chapeau(self, chapeau: Chapeau):
chapeau.forme = random.choices(("cône", "couronne", "marottte", "mortarboard"))[0]
return super().modifie_chapeau(chapeau)
class CouleurChapeau(ModificateurChapeau):
def modifie_chapeau(self, chapeau: Chapeau):
chapeau.couleur = {
"cône": random.choices(("rouge", "bleu", "vert"))[0],
"couronne": random.choices(("or", "argent"))[0],
"marottte": random.choices(("rouge", "jaune"))[0],
"mortarboard": "noir",
}[chapeau.forme]
return super().modifie_chapeau(chapeau)
class MotifsChapeau(ModificateurChapeau):
def modifie_chapeau(self, chapeau: Chapeau):
chapeau.motifs = "aucun"
if chapeau.couleur in ("rouge", "jaune"):
chapeau.motifs = "pois"
if chapeau.couleur in ("or", "argent"):
chapeau.motifs = "paillettes"
if chapeau.couleur in ("bleu", "vert"):
chapeau.motifs = "animaux"
return super().modifie_chapeau(chapeau)
On remarque que l’ajout d’un nouvel élement se limite à la création d’une classe qui surcharge la seule méthode de la couche métier, ce qui est confirmé dans son usage par le code client :
forme = FormeChapeau()
couleur = CouleurChapeau()
motifs = MotifsChapeau()
forme.modificateur_suivant(couleur).modificateur_suivant(motifs)
for iteration in range(5):
chapeau = forme.modifie_chapeau(Chapeau())
print(f"Création d'un chapeau type '{chapeau.forme}' de couleur '{chapeau.couleur}' "
f"avec comme motifs '{chapeau.motifs}'")
Le résultat reflète bien la description formelle de la séquence des opérations métier :
Création d'un chapeau type 'cône' de couleur 'rouge' avec comme motifs 'pois'
Création d'un chapeau type 'marottte' de couleur 'rouge' avec comme motifs 'pois'
Création d'un chapeau type 'mortarboard' de couleur 'noir' avec comme motifs 'aucun'
Création d'un chapeau type 'cône' de couleur 'bleu' avec comme motifs 'animaux'
Création d'un chapeau type 'couronne' de couleur 'or' avec comme motifs 'paillettes'
En conclusion
Il existe bien d’autres patterns et même bien d’autres façons différentes de présenter ceux qui ont été évoqués dans cet article. Le but n’est pas d’être objectif ou exhaustif, mais de prendre du recul sur sa propre pratique du code quitte, parfois, à faire table rase du passé pour appréhender une problématique sous un autre angle.
Certains patterns vont être plus adaptés à la factorisation que d’autres, d’autres vont être plus polyvalents, d’autres encore plus simples à étendre quand les contours d’une architecture sont encore flous.
Plus que la connaissance, c’est surtout la curiosité et la capacité à garder l’esprit ouvert qui rendent l’analyse d’un design pattern et son utilisation si intéressante et ce, quel que soit le niveau d’expérience.
Cette entrée a été publiée dans programmation avec comme mot(s)-clef(s) design patterns, python, abstraction
Les articles suivant pourraient également vous intéresser :
- La bonne et la mauvaise review par Sébastien Bizet
- Dark mode vs Light mode : accessibilité et éco-conception par Jean-Baptiste Bergy
- Principes SOLID et comment les appliquer en Python par Mariana ROLDAN VELEZ
- Pydantic, la révolution de python ? par Pablo Abril
- Comment utiliser les fixtures pytest en autouse tout en maîtrisant ses effets de bord ? par Amaury Boin
Postez votre commentaire :
Votre commentaire a bien été envoyé ! Il sera affiché une fois que nous l'aurons validé.
Vous devez activer le javascript ou avoir un navigateur récent pour pouvoir commenter sur ce site.