Design Patterns
Les design patterns, patron de conception ou plus prosaïquement "façons de faire", sont pour ainsi dire de l'algorithmie objet : ils permettent de résoudre nombre de problèmes courants en développement et d'éviter les erreurs classiques.
Le terme de design pattern a été initialement donné dans le livre Design Patterns: Elements of Reusable Object-Oriented Software pour le langage C++.
Ils permettent de résoudre de façon efficace des problèmes courants en programmation. Il est utile de connaître une liste actuelle de design patterns (certains de la liste originelle, comme singleton ne sont plus utilisés et d'autres, comme MVP sont apparus), car ils donnent souvent de bonnes idées pour résoudre les problèmes que l'on se pose.
À retenir
Les design pattern sont très utilisés par les bibliothèques (par exemple les design pattern de react), les reconnaître vous permettra 'aller vite dans la compréhension de celles-ci.
Le site suivant contient une liste actualisée de design patterns :
Design et refactoring pattern
Il existe aussi leurs doubles maléfiques, les anti-patterns, qui sont des solutions évidentes -- et mauvaises -- à des problèmes courants. Là aussi, il est bon de connaître une liste actualisée d'anti-pattern, comme par exemple :
Nous allons utiliser la classe Dé qui nous a accompagné tout au long du cours de programmation objet et lui ajouter quelques design pattern pour encore une fois l'améliorer.
code
code
Fichier dés.py
from random import randrange
import random
class Stat:
def __init__(self):
self.historique = []
def sauve(self):
self.historique.append(self.valeur)
def moyenne(self):
return sum(self.historique) / max(1, len(self.historique))
class DéGénérique(Stat):
MIN_VALEUR = 1
def __init__(self, max, valeur=1):
super().__init__()
self.MAX_VALEUR = max
self._valeur = valeur
valeur = property(lambda self: self._valeur)
def lancer(self):
self._valeur = random.randrange(self.MIN_VALEUR, self.MAX_VALEUR + 1)
self.sauve()
class D6(DéGénérique):
def __init__(self, valeur=1):
super().__init__(6, valeur)
class D20(DéGénérique):
def __init__(self, valeur=1):
super().__init__(20, valeur)
Fichier main.py :
from dés import D6, D20
d_6 = D6()
d_20 = D20()
print(d_6.valeur, d_20.valeur)
d_6.lancer()
d_20.lancer()
print(d_6.valeur, d_20.valeur)
for _ in range(1000):
d_6.lancer()
d_20.lancer()
print('1000 lancers :', d_6.moyenne(), d_20.moyenne())
Créer des objets grâce à une factory
Le pattern factory est un design pattern faisant parti des pattern de création d'objet. Il stipule que l'on doit créer des objets via des fonctions avec le moins de paramètres possible et avec un nom adapté plutôt qu'avec un constructeur possédant des milliers de paramètres.
Dans notre cas ce pattern permet également de faire disparaître les deux classes qui n'existent que via leur constructeur :
from random import randrange
import random
class Stat:
def __init__(self):
self.valeur = 1
self.historique = []
def sauve(self):
self.historique.append(self.valeur)
def moyenne(self):
return sum(self.historique) / max(1, len(self.historique))
class DéGénérique(Stat):
MIN_VALEUR = 1
def __init__(self, max, valeur=1):
super().__init__()
self.MAX_VALEUR = max
self.valeur = valeur
def lancer(self):
self.valeur = random.randrange(self.MIN_VALEUR, self.MAX_VALEUR + 1)
self.sauve()
def d6(valeur=1):
return DéGénérique(6, valeur)
def d20(valeur=1):
return DéGénérique(20, valeur)
On peut alors utiliser directement les fonctions de création dans le fichier main.py :
from dés import d6, d20
d_6 = d6()
d_20 = d20()
print(d_6.valeur, d_20.valeur)
d_6.lancer()
d_20.lancer()
print(d_6)
for _ in range(1000):
d_6.lancer()
d_20.lancer()
print('1000 lancers :', d_6.moyenne(), d_20.moyenne())
Fluent interface
Ce n'est pas un design pattern stricto-sensu, plutôt une règle de programmation : on essaie de rendre l'utilisation des méthodes fluide, chaînables sans avoir besoin de créer des variables intermédiaires.
Dans notre cas, il pourrait être intéressant de connaître la valeur du dé directement après un lancer pour pourvoir par exemple remplacer les lignes suivantes dans le fichier main.py :
d_6.lancer()
d_20.lancer()
print(d_6.valeur, d_20.valeur)
par :
print(d_6.lancer().valeur, d_20.lancer().valeur)
Ok, ce n'est pas très impressionnant ici, mais si vous utilisez la bibliothèque pandas pour l'analyse ds données par exemple. vous verrez la puissance de ce genre d'écriture.
Pour ceci il suffit de changer la méthode Dé.lancer :
class DéGénérique(Stat):
# ...
def lancer(self):
self.valeur = random.randrange(self.MIN_VALEUR, self.MAX_VALEUR + 1)
self.sauve()
return self
# ...
Pattern observer
Le pattern observer est à la base de la programmation évènementielle utilisée pour développer des interfaces graphique.
Dans notre cas, il va être utile d'utiliser ce pattern pour supprimer l'héritage.
Commençons par préparer le pattern en ajoutant la méthode permettant d'ajouter un observateur et la notification :
class DéGénérique(Stat):
# ...
def __init__(self, max, valeur=1):
# ...
self._observateurs = []
# ...
def add(self, observateur):
self._observateurs.append(observateur)
def remove(self, observateur):
self._observateurs.remove(observateur)
def notify(self):
for o in self._observateurs:
o.update(self)
Et on peut supprimer la l'héritage pour placer la notification après lancé :
class DéGénérique:
# ...
def lancer(self):
self._valeur = random.randrange(self.MIN_VALEUR, self.MAX_VALEUR + 1)
self.notify()
return self
On peut maintenant créer l'observateur qui va sauver nos jet et en calculer la moyenne :
class Stat:
def __init__(self):
self.historique = []
def update(self, dé):
self.historique.append(dé.valeur)
def moyenne(self):
return sum(self.historique) / max(1, len(self.historique))
Et le main.py devient :
from dés import d6, d20, Stat
d_6 = d6()
d_20 = d20()
stat6 = Stat()
d_6.add(stat6)
stat20 = Stat()
d_20.add(stat20)
print(d_6.valeur, d_20.valeur)
print(d_6.lancer().valeur, d_20.lancer().valeur)
for _ in range(1000):
d_6.lancer()
d_20.lancer()
print('1000 lancers :', stat6.moyenne(), stat20.moyenne())
L'observateur découple l'objet qui fait et l'objet qui observe, ce qui est une bonne chose. De plus il permet d'ajouter d'autre types d'observateurs sans effort comme vous allez le faire plus tard.
Pattern composite
Le pattern composite permet de composer deux dés de façon à pouvoir :
- les lancer simultanément
- obtenir la somme de leurs valeurs respectives
Ceci doit être possible en utilisant une classe Somme :
# ...
class Somme:
def __init__(self, gauche, droite):
self.gauche = gauche
self.droite = droite
valeur = property(lambda self: self.gauche.valeur + self.droite.valeur)
def lancer(self):
self.gauche.lancer()
self.droite.lancer()
return self
# ...
On peut l'utiliser ainsi, dans le fichier main.py :
from dés import Somme
# ...
somme = Somme(d6(), d20())
print(somme.valeur)
print(somme.lancer().valeur)
Il ne nous reste plus qu'à rendre cette classe transparente pour l'utilisateur en créant la somme de deux dés directement dans la classe DéGénérique :
# ...
class DéGénérique:
# ...
def __add__(self, other):
return Somme(self, other)
# ...
# ...
Modifions notre main.py en conséquence :
# ...
somme = d6() + d20()
# ...
Terminons le travail en permettant d'écrire s = d6() + d6() + d20(). Le pattern composite rend ça trivial, il suffit de déclarer la somme dans la classe Somme :
# ...
class Somme:
# ...
def __add__(self, other):
return Somme(self, other)
# ...
Et le main.py mis à jour :
# ...
somme = d6() + d6() + d20()
# ...
Continuons sur notre lancée. Un marteau enchanté fait : d6 + 4 dégâts. Il est pour l'instant impossible de gérer ceci avec nos objets, car un entier n'a pas d'attribut valeur ni lancer().
Une solution simple pour résoudre ce problème est de vérifier si un objet possède l'attribut demandé avant de l'utiliser. Pour cela on utilise la fonction hasattr qui possède 2 paramètres :
- le premier est l'objet pour lequel on veut faire la vérification
- le second est le nom de l'attribut/méthode (donc une chaîne de caractères) dont on veut tester l'existence.
Dans la somme cela donne :
class Somme:
# ...
def lancer(self):
if hasattr(self.gauche, "lancer"):
self.gauche.lancer()
if hasattr(self.droite, "lancer"):
self.droite.lancer()
return self
On pourrait faire pareil pour l'attribut valeur, mais ici on va plutôt utiliser la fonction getattr de python qui rend une valeur par dévaut si l'attribut n;est pas trouvé. Dans notre cas on rend directement l'objet (qui doit être un entier) :
- le premier est l'objet pour lequel on veut faire la vérification
- le second est le nom de l'attribut/méthode à récupérer
- le troisième est le retour par défaut si l'attribut/méthode n'est pas trouvée.
Dans notre cas pour la property valeur des Somme cela donne :
class Somme:
# ...
valeur = property(
lambda self: getattr(self.gauche, "valeur", self.gauche)
+ getattr(self.droite, "valeur", self.droite)
)
On peut maintenant modifier notre main.py :
somme = d6() + 4
print(somme.valeur)
print(somme.lancer().valeur)
À vous
Le problème de la boule de feu
Terminons la mise à jour de la classe Dé en lui permettant de résoudre le problème de la boule de feu qui fait 2d6 + 1 dégâts. On supposera que l'on ne peut multiplier que un entier à un dé ou une Somme (3d6 ou 4(d6 + 2d20)). On ne pourra jamais multiplier 2 dés ensemble (d6 * d20 est interdit).
En implémentant une classe Multiplication et en utilisant la méthode spéciale rmul dans les dés.
pour calculer a * b python fait plusieurs essais :
- il essaye
A.__mul__(B) - si l'expression précédente rend une erreur de type
TypeErroralors python tenteB.__rmul__(A)
Undo
On a déjà utilisé le pattern memento lorsque l'on a fait de la compositions et de l'agrégation de dés, utilisez cette partie pour créer une liste de undo :
En utilisant le design pattern memento et notre observer, créer une classe permettant de gérer les undo des dés
Vous ne stockerez dans vos undos que les changements de valeurs (si un lancer garde la même valeur de dé ce n'est pas la peine de l'ajouter aux undos).
Redo
Ajoutez une classe permettant de gérer les redo.
Builder
Lorsqu'on joue à un jeu de dés il y a toujours plein de règles différentes pour compter les points :
Utilisez le pattern Builder pour créer une façon de compter les points de jets de 5 dés où l'on veut être capable de reconnaître :
- la somme des valeurs > 15
- 5 valeurs identiques
- les suites
Et de créer des objets qui reconnaissent l'une, l'autre ou toute combinaison de ces 3 règles pour une liste de 5 dés.