Architecture générale du pattern Decorator
Un beau dessin valant parfois mieux qu'un long discours, examinons ensemble le schéma du Decorator tel qu'il est défini dans
le livre du « Gang of Four ».
Ce schéma paraît bien compliqué à première vue. Si vous avez du mal à comprendre le fonctionnement qu'il décrit, ne vous inquiétez pas, nous allons voir tout cela ensemble.
Dans ce schéma, nous avons une classe principale, le
Composant abstrait, qui définit l'interface publique de plusieurs
ComposantConcrets. Dans notre cas, le
Composant se traduirait par une classe
Sandwich, dont dérivent en particulier le
Kebab, ainsi que d'autres sandwiches "de base", mettons le
Cheeseburger, par exemple. En clair, le comportement de base des sandwiches est défini dans une classe de plus haut niveau que le
Kebab, et chacune des implémentations concrètes précise le comportement du
Sandwich. Jusqu'ici, c'est l'expression la plus pure de l'héritage.
Là où le Decorator se démarque, c'est au niveau de la classe
Decorateur. Dans le schéma générique, on définit une interface
Decorateur d'où vont dériver de multiples implémentations différentes. Ce qui caractérise ces objets, c'est que non seulement, ils héritent (directement ou non) de l'interface publique de
Composant, mais qu'en plus, ils
encapsulent un
Composant (ou une classe concrète dérivée) afin d'en modifier le comportement ou l'état. Sachant qu'un
Decorateur EST UN
Composant, cela veut dire qu'un
Decorateur peut envelopper indifféremment un
ComposantConcret ou un autre
Decorateur. Vous en comprenez l'intérêt ?
Dans notre cas, les implémentations concrètes de
DecorateurSandwich pourront être
Supplement (pour ajouter un ingrédient),
ModPain (pour utiliser du pain pita au lieu du pain normal, par exemple),
ModViande (pour remplacer le mouton par du poulet), ou
RetraitIngredient (pour avoir un kebab « sans oignon »). Étant donné que les décorateurs peuvent s'envelopper les uns les autres, cela va nous permettre de créer toutes les combinaisons de modifications possibles et imaginables, avec très peu d'objets.
Une remarque importante, c'est que les décorateurs ne sont pas vraiment obligés de tous hériter d'une classe
Decorateur. En effet : dans le cas où nos objets sont simples (comme ici), on peut sans crainte créer des décorateurs qui héritent directement de
Composant. Cependant, pour cette fois, nous allons respecter scrupuleusement le schéma.
Application du Decorator à notre problème
Bien ! Les explications que je vous ai données au paragraphe précédent peuvent être résumées dans le schéma suivant :
Voyons comment ceci peut se traduire en code.
D'abord, la classe
Sandwich de base :
Code : Python 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22 | from collections import namedtuple
Ingredient = namedtuple('Ingredient', 'prix qte')
class Sandwich:
def __init__(self, sauce):
self.sauce = sauce
self._ing = dict()
@property
def prix(self):
return sum(i.prix * i.qte for i in self._ing.values())
def __repr__(self):
return "Sandwich sauce {0}".format(self.sauce)
def __str__(self):
s = repr(self)
for key, it in self._ing.items():
s += "\n {1.qte:>2}x {0:<15}{2:>5.2f} €".format(key, it, it.prix * it.qte)
s += "\nTotal {:>5.2f} €".format(self.prix)
return s
|
Un sandwich est caractérisé par sa sauce, ainsi que la liste de ses ingrédients. La somme des
prix × quantite des ingrédients constitue le prix du sandwich. Enfin, deux méthodes utilitaires sont créées, l'une pour décrire le sandwich, et l'autre pour donner le détail du prix.
Voyons maintenant le
Kebab :
Code : Python | class Kebab(Sandwich):
def __init__(self, sauce):
super().__init__(sauce)
self._ing['base'] = Ingredient(PRIX_BASE, 1)
self._ing['salade'] = Ingredient(PRIX_SALADE, 1)
self._ing['tomates'] = Ingredient(PRIX_TOMATES, 1)
self._ing['oignons'] = Ingredient(PRIX_OIGNONS, 1)
self._ing['frites'] = Ingredient(PRIX_FRITES, 1)
def __repr__(self):
return "Kebab sauce {0}".format(self.sauce)
|
Comme vous le voyez, on se contente de dériver la classe
Sandwich et de préciser ses ingrédients (la 'base' représente le pain normal et le mouton).
Commençons par tester cette partie du code :
Code : Python Console | >>> print(Kebab("harissa"))
Kebab sauce harissa
1x tomates 0.20 €
1x salade 0.20 €
1x base 3.00 €
1x oignons 0.30 €
1x frites 0.50 €
Total 4.20 €
|
Bien. Tout fonctionne.
Créons maintenant notre classe
DecorateurSandwich. Attention les yeux, cette classe est très très compliquée !
Code : Python | class DecorateurSandwich(Sandwich):
def __init__(self, sandwich):
super().__init__(sandwich.sauce)
self.sandwich = sandwich
self._ing = sandwich._ing
|
Ça fait peur, hein ?
Cette classe remplit le rôle minimal du décorateur
- elle encapsule un Sandwich dont le comportement est destiné à être modifié.
- elle se fait passer pour le Sandwich décoré, de telle manière que le code qui utilisera ce décorateur aura l'impression de traiter directement le Sandwich et n'y verra que du feu.
C'est pour gérer le second aspect que l'on récupère une référence du membre
_ing (le dictionnaire d'ingrédients) du sandwich décoré dans le membre
_ing du décorateur : en modifiant les ingrédients du
DecorateurSandwich on modifiera directement les ingrédients du
Sandwich décoré.
Essayons maintenant de modéliser un premier décorateur : le retrait d'ingrédients.
Code : Python 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16 | class RetraitIngredient(DecorateurSandwich):
def __init__(self, sandwich, ingredient):
super().__init__(sandwich)
# suppression de l'ingrédient voulu
self.retrait = None
if ingredient in self._ing:
del self._ing[ingredient]
self.retrait = ingredient
def __repr__(self):
r = repr(self.sandwich)
# Ajout de la mention "sans X" si un ingrédient
# a bien été retiré
if self.retrait is not None:
r += ", sans {0}".format(self.retrait)
return r
|
Comme vous le voyez, c'est extrêmement simple. Une fois que le constructeur de la classe mère
DecorateurSandwich a été appelé, on n'a plus qu'à travailler sur les membres de notre classe exactement comme si l'on modifiait le sandwich décoré de l'intérieur.
Regardez le résultat.
Code : Python Console 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32 | >>> a = Kebab("harissa")
>>> print(a)
Kebab sauce harissa
1x tomates 0.20 €
1x salade 0.20 €
1x base 3.00 €
1x oignons 0.30 €
1x frites 0.50 €
Total 4.20 €
>>> a = RetraitIngredient(a, "oignons") # retrait d'un ingrédient
>>> print(a)
Kebab sauce harissa, sans oignons
1x tomates 0.20 €
1x salade 0.20 €
1x base 3.00 €
1x frites 0.50 €
Total 3.90 €
>>> a = RetraitIngredient(a, "champignons") # retrait d'un ingrédient inexistant
>>> print(a) # aucun effet
Kebab sauce harissa, sans oignons
1x tomates 0.20 €
1x salade 0.20 €
1x base 3.00 €
1x frites 0.50 €
Total 3.90 €
>>> a = RetraitIngredient(a, "frites") # retrait d'un nouvel ingrédient
>>> print(a)
Kebab sauce harissa, sans oignons, sans frites
1x tomates 0.20 €
1x salade 0.20 €
1x base 3.00 €
Total 3.40 €
|
Magique, non ?
On peut d'ailleurs créer quelques fonctions utilitaires pour alléger la syntaxe :
Code : Python | def sans_oignon(sandwich):
return RetraitIngredient(sandwich, 'oignons')
def sans_frite(sandwich):
return RetraitIngredient(sandwich, 'frites')
def sans_salade(sandwich):
return RetraitIngredient(sandwich, 'salade')
def sans_tomate(sandwich):
return RetraitIngredient(sandwich, 'tomates')
|
Ce qui peut nous permettre de faire quelque chose comme :
Code : Python Console | >>> a = sans_frite(sans_oignon(sans_tomate(Kebab("ketchup"))))
>>> print(a)
Kebab sauce ketchup, sans tomates, sans oignons, sans frites
1x salade 0.20 €
1x base 3.00 €
Total 3.20 €
|
Vous devriez commencer à sentir la puissance des décorateurs, maintenant.
Poursuivons et essayons d'implémenter le
Supplement.
Code : Python 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20 | class Supplement(DecorateurSandwich):
def __init__(self, sandwich, ingredient, prix):
super().__init__(sandwich)
# ajout de l'ingrédient voulu
prix, qte = self._ing.get(ingredient, Ingredient(prix, 0))
self.ajout = ingredient
self._ing[ingredient] = Ingredient(prix, qte+1)
def __repr__(self):
r = repr(self.sandwich)
s = ", supplément {0}".format(self.ajout)
if s not in r:
r += s
return r
def supp_fromage(sandwich):
return Supplement(sandwich, 'fromage', PRIX_FROMAGE)
def supp_oignon(sandwich):
return Supplement(sandwich, 'oignons', PRIX_OIGNONS)
|
Aucune réelle difficulté, si ce n'est qu'il faut faire attention à ne pas se répéter dans le libellé lorsque l'on applique plusieurs suppléments identiques. Remarquez l'utilisation de la méthode
get du dictionnaire
_ing qui permet de récupérer une valeur par défaut si aucun élément du dictionnaire ne correspond à la clé donnée.
Voyons le résultat :
Code : Python Console | >>> a = supp_fromage(supp_oignon(supp_fromage(Kebab("ketchup"))))
>>> print(a)
Kebab sauce ketchup, supplément fromage, supplément oignons
1x salade 0.20 €
2x oignons 0.60 €
1x frites 0.50 €
2x fromage 1.00 €
1x tomates 0.20 €
1x base 3.00 €
Total 5.50 €
|
Comme vous le constatez, on applique un double supplément fromage et un simple supplément oignon à notre Kebab. Le libellé reste lisible, et toutes les modifications sont prises en compte dans le prix.
Il ne reste plus que deux décorateurs à créer : le modificateur de viande, et le modificateur de pain. Ces deux classes sont tellement semblables que l'on pourrait même les réunir en une seule classe. Cependant, utiliser deux classes séparées permet de s'assurer plus simplement que l'on n'effectue pas plusieurs fois une même modification (si le client décide de changer la viande pour "poulet", "oh puis non en fait dinde", il n'a pas à payer deux fois la modification).
Code : Python 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50 | class ModViande(DecorateurSandwich):
def __init__(self, sandwich, viande):
super().__init__(sandwich)
self.viande = viande
self.deja_fait = "mod viande" in self._ing
if not self.deja_fait:
self._ing["mod viande"] = Ingredient(PRIX_MOD_VIANDE, 1)
def __repr__(self):
s = " mod viande {0}".format(self.viande)
r = repr(self.sandwich).split(',')
if not self.deja_fait:
r.append(s)
else:
for idx, elt in enumerate(r):
if "mod viande" in elt:
r[idx] = s
break
return ','.join(r)
class ModPain(DecorateurSandwich):
def __init__(self, sandwich, pain):
super().__init__(sandwich)
self.pain = pain
self.deja_fait = "mod pain" in self._ing
if not self.deja_fait:
self._ing["mod pain"] = Ingredient(PRIX_MOD_PAIN, 1)
def __repr__(self):
s = " pain {0}".format(self.pain)
r = repr(self.sandwich).split(',')
if not self.deja_fait:
r.append(s)
else:
for idx, elt in enumerate(r):
if "pain" in elt:
r[idx] = s
break
return ','.join(r)
def mod_poulet(sandwich):
return ModViande(sandwich, 'poulet')
def mod_dinde(sandwich):
return ModViande(sandwich, 'dinde')
def mod_pita(sandwich):
return ModPain(sandwich, 'pita')
|
Et voilà le résultat :
Code : Python Console 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22 | >>> a = mod_dinde(mod_pita(Kebab("ketchup")))
>>> print(a)
Kebab sauce ketchup, pain pita, mod viande dinde
1x mod viande 0.20 €
1x salade 0.20 €
1x oignons 0.30 €
1x frites 0.50 €
1x tomates 0.20 €
1x base 3.00 €
1x mod pain 0.20 €
Total 4.60 €
>>> a = mod_poulet(a)
>>> print(a)
Kebab sauce ketchup, pain pita, mod viande poulet
1x mod viande 0.20 €
1x salade 0.20 €
1x oignons 0.30 €
1x frites 0.50 €
1x tomates 0.20 €
1x base 3.00 €
1x mod pain 0.20 €
Total 4.60 €
|
Comme vous pouvez le voir, le libellé du sandwich a bien mémorisé la deuxième modification pour la viande, mais le prix n'a heureusement pas été affecté.
Voilà, nous avons terminé d'implémenter les personnalisations prévues par le cahier des charges initial. Testons un peu la bestiole et commandons un « kebab mayo/harissa, avec pain pita, au poulet, avec supplément fromage et sans oignon » :
Code : Python Console | >>> a = sans_oignon(supp_fromage(mod_poulet(mod_pita(Kebab("mayo/harissa")))))
>>> print(a)
Kebab sauce mayo/harissa, pain pita, mod viande poulet, supplément fromage, sans oignons
1x mod viande 0.20 €
1x salade 0.20 €
1x frites 0.50 €
1x fromage 0.50 €
1x tomates 0.20 €
1x base 3.00 €
1x mod pain 0.20 €
Total 4.80 €
|
Bon appétit !
Prenons un peu de recul :
- Toutes les modifications que nous avons prévues sont modélisées par une classe.
- Nous n'avons pas eu besoin de créer trop de classes (une par type de modification).
- Nos classes Sandwich et Kebab n'ont été définies qu'une fois et une seule, et nous n'avons pas eu besoin d'y retoucher.
- La syntaxe des modifications est ridiculement simple à utiliser.
On est bien loin de notre explosion combinatoire du premier jet, et de la classe "qui-fait-tout, qui-sait tout" du second essai.
Avouez qu'il s'agit là d'une solution puissante et élégante !
Mais le meilleur reste à venir,
vous n'avez encore rien vu…
Quand le patron a (encore) une nouvelle idée…
Pour bien mesurer la puissance réelle du Decorator, faites-moi le plaisir de réfléchir aux questions suivantes. Vous devriez vous émerveiller devant la simplicité des réponses.
- Imaginons que le patron décide d'ajouter un nouvel ingrédient possible en supplément (les épices du chef), comment feriez-vous pour gérer cela dans le code ?
- Comment vous y prendriez-vous pour rendre la sauce personnalisable de manière dynamique (c'est-à-dire APRÈS la création du Sandwich) ?
- Nous n'avons pas encore défini le sandwich Cheeseburger. Comment l'implémenteriez-vous ? Que remarquez-vous, par rapport aux décorateurs que nous avons déjà définis ?
Bien, prenons ces questions dans l'ordre.
Si le patron voulait ajouter un nouvel ingrédient possible en supplément, nous n'aurions pas besoin de modifier une classe : il nous suffirait de connaître son prix, et d'utiliser le décorateur
Supplement pour l'ajouter à un sandwich. Pour faire simple, il suffirait en fait de créer une petite fonction :
Code : Python | PRIX_EPICES = 0.1
def supp_epices(sandwich):
return Supplement(sandwich, 'épices du chef', PRIX_EPICES)
|
Vérifions :
Code : Python Console | >>> a = supp_epices(Kebab("harissa"))
>>> print(a)
Kebab sauce harissa, supplément épices du chef
1x épices du chef 0.10 €
1x salade 0.20 €
1x oignons 0.30 €
1x frites 0.50 €
1x tomates 0.20 €
1x base 3.00 €
Total 4.30 €
|
Sans problème !
Aucun code à modifier, seulement une petite fonction rajoutée.
Maintenant, si l'on voulait modifier dynamiquement la sauce d'un Kebab, qu'aurions-nous besoin de faire ?
Un nouveau décorateur, vous êtes sûr ?
Code : Python Console 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18 | >>> a = Kebab('harissa')
>>> print(a)
Kebab sauce harissa
1x tomates 0.20 €
1x salade 0.20 €
1x base 3.00 €
1x oignons 0.30 €
1x frites 0.50 €
Total 4.20 €
>>> a.sauce = 'ketchup'
>>> print(a)
Kebab sauce ketchup
1x tomates 0.20 €
1x salade 0.20 €
1x base 3.00 €
1x oignons 0.30 €
1x frites 0.50 €
Total 4.20 €
|
C'était une question piège.
Le décorateur n'est pas la réponse à toutes les modifications dynamiques d'un objet ! Si la classe
Sandwich a un membre
sauce modifiable sans répercussion sur le prix, il est inutile de créer une nouvelle classe : le comportement est déjà implémenté. Souvenez-vous : le décorateur sert à
modifier le comportement d'une classe. Autrement dit, il sert à rendre une classe capable de faire quelque chose de légèrement différent que ce que ses méthodes actuelles lui permettent de faire.
Bien ! Créons un
Cheeseburger, maintenant. Ce n'est pas tellement différent d'un
Kebab, en fait. Regardez, seule la composition change :
Code : Python 1
2
3
4
5
6
7
8
9
10
11
12
13 | class Cheeseburger(Sandwich):
def __init__(self, sauce):
super().__init__(sauce)
self._ing['base'] = Ingredient(PRIX_BASE_CHEESE, 1)
self._ing['steak'] = Ingredient(PRIX_STEAK, 1)
self._ing['salade'] = Ingredient(PRIX_SALADE, 1)
self._ing['tomates'] = Ingredient(PRIX_TOMATES, 1)
self._ing['oignons'] = Ingredient(PRIX_OIGNONS, 1)
self._ing['fromage'] = Ingredient(PRIX_FROMAGE, 2)
self._ing['cornichons'] = Ingredient(PRIX_CORNICHONS, 1)
def __repr__(self):
return "Cheeseburger sauce {0}".format(self.sauce)
|
Et puis, pour la peine, essayons directement de faire un cheeseburger sans oignon.
Code : Python Console | >>> a = sans_oignon(Cheeseburger('ketchup/mayo'))
>>> print(a)
Cheeseburger sauce ketchup/mayo, sans oignons
1x cornichons 0.10 €
1x salade 0.20 €
2x fromage 1.00 €
1x tomates 0.20 €
1x base 2.00 €
1x steak 0.50 €
Total 4.00 €
|
Parfait ! Non seulement c'est très simple, mais en plus, les décorateurs que l'on avait créés pour le
Kebab fonctionnent AUSSI avec le
Cheeseburger (dans la limite du bon sens, bien sûr : je n'ai jamais vu de « Cheeseburger pain pita » personnellement :D).
Cela signifie que notre application peut être étendue non seulement en créant de nouveaux décorateurs, mais aussi en créant de nouvelles sous-classes de
Sandwich qui seront automatiquement compatibles avec les décorateurs existants. Et tout ça, sans jamais modifier ne serait-ce qu'un caractère des classes et fonctions que nous avons déjà écrites.
La grande classe !