Design patterns de structure
Intéressons-nous maintenant à une nouvelle catégorie de design patterns, les design patterns de structure. Ces design patterns facilitent la conception d'une application en offrant une méthode simple pour établir des liens entre des entités.
Dans ce chapitre, nous allons étudier trois de ces design patterns :
Adaptateur
Composite
Décorateur
Adaptateur (Adaptor)
Ce pattern est très simple à comprendre lorsqu'on fait l'analogie avec un adaptateur physique. Pensez aux adaptateurs électriques que vous avez chez vous. Votre smartphone dispose d'un adaptateur dont une extrémité est dotée d'une entrée USB et l'autre d'une fiche pour prise de courant murale. L'adaptateur a pour objectif de rendre ces deux formats compatibles.
L’adaptateur permet à des classes incompatibles d'interagir en convertissant l'interface d'une classe en une interface attendue par le client.
Pour utiliser un adaptateur, vous avez besoin des éléments suivants :
Une interface
cible
Une classe
adaptée
Une classe d'
adaptation
Une application
cliente
Prenons l'exemple d'une entreprise qui gère les dossiers RH pour différentes sociétés tierces. Chacune d'elle dispose de son propre système de gestion. Nous devons donc écrire un adaptateur permettant à l'entreprise de récupérer les dossiers des employés depuis tous ces systèmes.
Tout d'abord, créons une interface cible ICible
:
public interface ICible
{
List<string> RecupererEmployes();
}
Passons ensuite à la classe adaptée
:
public class EmployeTiers
{
public List<string> RecupererListeEmployes()
{
List<string> ListeEmployes = new List<string>();
ListeEmployes.Add("Jean");
ListeEmployes.Add("Pierre");
return ListeEmployes;
}
}
Créons maintenant la classe d' adaptation
:
public class AdaptateurEmployes : EmployeTiers, ICible
{
public List<string> RecupererEmployes()
{
return RecupererListeEmployes();
}
}
Enfin, codons l'application cliente
pour l'implémentation :
public class Client
{
static void Main(string[] args)
{
ICible adaptateur = new AdaptateurEmployes();
foreach(string employe in adaptateur.RecupererEmployes())
{
Console.WriteLine(employe);
}
}
}
La sortie sera :
Jean
Pierre
Comme vous pouvez le voir dans l'application cliente
, l'interface ICible
appelle la fonctionnalité de la classe adaptée
.
Quels sont les avantages de cette stratégie ?
Vous bénéficiez d'une meilleure réutilisabilité et d'une meilleure flexibilité.
La classe cliente n'est pas compliquée par le recours forcé à une interface différente.
Composite
Intéressons-nous maintenant à un autre design pattern de structure, le composite. Il décrit un groupe d'objets considéré comme formant une instance unique d'un même type d'objet. L'objectif est de placer les objets dans une structure arborescente afin de représenter des hiérarchies partielles et globales.
En d'autres termes, vous pouvez représenter tout ou partie d'une hiérarchie en la réduisant à ses composants communs.
Pour établir un composite, vous aurez besoin des éléments suivants :
Composant : déclare l'interface pour les objets et leur comportement commun.
Feuilles : cet élément représente le comportement des feuilles.
Composite : il définit le comportement des composants ayant des enfants (inverse des feuilles).
Client : application principale.
Prenons l'exemple d'une cafetière haut de gamme.
Ce qui rend cette cafetière unique, c'est qu'elle vous invite à composer votre boisson en sélectionnant d'abord un type (expresso, latte, cappuccino...), puis une saveur.
Il s'agit de la hiérarchie (arborescence) dont nous avons parlé un peu plus haut. Modélisons cette arborescence et déterminons le nombre de calories associé à chaque saveur.
public class Cafe
{
public int Calories { get; set; }
public List<Cafe> Saveurs { get; set; }
public Cafe(int calories)
{
Calories = calories;
Saveurs = new List<Cafe>();
}
public void AfficherCalories()
{
Console.WriteLine(this.GetType().Name + ": " + this.Calories.ToString() + " calories.");
foreach(var boisson in this.Saveurs)
{
boisson.AfficherCalories();
}
}
}
Avez-vous remarqué la méthode AfficherCalories()
? Il s'agit d'une méthode récursive permettant d'afficher les calories de l'ensemble des saveurs.
Maintenant, implémentons les feuilles des saveurs de café :
public class LatteVanille : Cafe
{
public LatteVanille(int calories)
: base(calories)
{ }
}
public class LatteCannelle : Cafe
{
public LatteCannelle(int calories)
: base(calories)
{ }
}
public class CappuccinoMenthePoivree : Cafe
{
public CappuccinoMenthePoivree(int calories)
: base(calories)
{ }
}
public class CappuccinoCaramel : Cafe
{
public CappuccinoCaramel(int calories)
: base(calories)
{ }
}
Ensuite, implémentons le composite, qui représente les objets de l'arborescence disposant d'enfants :
public class Expresso : Cafe
{
public Expresso(int calories)
: base(calories)
{ }
}
public class Cappuccino : Cafe
{
public Cappuccino(int calories)
: base(calories)
{ }
}
public class Latte : Cafe
{
public Latte(int calories)
: base(calories)
{ }
}
Enfin, nous avons besoin d'une classe composite supplémentaire pour le nœud racine de l'arborescence :
public class CafeChaud : Cafe
{
public CafeChaud(int calories)
: base(calories)
{ }
}
Les classes composites sont parfaitement identiques aux classes feuilles, et c'est tout à fait normal.
Désormais, nous pouvons initialiser dans la méthode Main
de notre application une arborescence Cafe
avec plusieurs saveurs, puis afficher les calories pour chacune de ces saveurs.
static void Main(string[] args)
{
Latte latte = new Latte(350);
latte.Saveurs.Add(new LatteCannelle(400));
latte.Saveurs.Add(new LatteVanille(300));
Expresso expresso = new Expresso(10);
Cappuccino cappuccino = new Cappuccino(450);
cappuccino.Saveurs.Add(new CappuccinoCaramel(500));
cappuccino.Saveurs.Add(new CappuccinoMenthePoivree(425));
Cafe cafe = new Cafe(100);
cafe.Saveurs.Add(latte);
cafe.Saveurs.Add(expresso);
cafe.Saveurs.Add(cappuccino);
cafe.AfficherCalories();
}
La sortie de ce code serait :
Cafe : 100 calories.
Latte : 350 calories.
LatteCannelle : 400 calories.
LatteVanille : 300 calories.
Expresso : 10 calories.
Cappuccino : 450 calories.
CappuccinoCaramel : 500 calories.
CappuccinoMenthePoivree : 425 calories.
Quels sont les avantages de cette stratégie ?
Les clients peuvent ainsi appliquer un traitement identique à différentes parties de la hiérarchie des objets :
Si on appelle une feuille, la requête est gérée directement.
Si on appelle un composite, celui-ci transfère la requête à ses composants enfants.
L'ajout de nouveaux types de composants est simplifié.
La structure est flexible grâce à une classe ou une interface gérable.
Décorateur (Decorator)
Le dernier design pattern de notre liste est le décorateur. Il vous permet d'ajouter un comportement à un objet sans modifier celui des autres objets de la même classe. Un décorateur peut être considéré comme une alternative à un héritage.
Pour illustrer, imaginez que vous commandez un sundae. Ce dessert est principalement composé d'une glace. Vous pouvez le savourer avec une cuillère. Vous pouvez lui ajouter des éclats croustillants et des nappages. En d'autres termes, le décorer. Mais même décoré, vous continuez à le savourer avec une cuillère. L'interface reste identique, même si le sundae est devenu plus complexe.
Pour créer un pattern décorateur, on utilise des éléments suivants :
Composant : il déclare l'interface des objets auxquels il est possible d'ajouter dynamiquement des responsabilités.
ComposantConcret : objet auquel des responsabilités supplémentaires seront ajoutées.
Décorateur : pour garder une référence à l'objet composant et définir une interface qui se conforme à celle du composant.
DécorateurConcret : permet d'ajouter des responsabilités au composant.
Prenons l'exemple d'une pizza que nous allons décorer avec différentes garnitures.
Commençons par la classe du composant, nommée Pizza
:
public abstract class Pizza
{
protected string description = "Pizza simple";
public string RecupererDescription()
{
return description;
}
public abstract int RecupererCout();
}
Créons ensuite les classes concrètes de Pizza
, qui correspondent aux types de pizzas :
public class Pepperoni : Pizza
{
public Pepperoni()
{
description = "Pepperoni";
}
public override int RecupererCout()
{
return 10;
}
}
public class Saucisse : Pizza
{
public Saucisse()
{
description = "Saucisse";
}
public override int RecupererCout()
{
return 14;
}
}
Ajoutons maintenant la classe Decorateur
, qui étend la classe Pizza
:
public abstract class DecorateurGarnitures : Pizza
{
public abstract string RecupererDescription();
}
Ajoutons ensuite les décorateurs concrets, à savoir les garnitures des pizzas :
public class Fromage : DecorateurGarnitures
{
Pizza pizza;
public Fromage(Pizza pizza)
{
this.pizza = pizza;
}
public override string RecupererDescription()
{
return pizza.RecupererDescription() + ", Fromage";
}
public override int RecupererCout()
{
return 2 + pizza.RecupererCout();
}
}
public class Oignon : DecorateurGarnitures
{
Pizza pizza;
public Oignon(Pizza pizza)
{
this.pizza = pizza;
}
public override string RecupererDescription()
{
return pizza.RecupererDescription() + ", Oignon";
}
public override int RecupererCout()
{
return 3 + pizza.RecupererCout();
}
}
Pour finir, rassemblons tout ce code dans l'application cliente :
public static void Main(string[] args)
{
//création d'une nouvelle pizza aux pepperonis
Pizza pizzaPepperoni = new Pepperoni();
Console.WriteLine(pizzaPepperoni.RecupererDescription() + " Coût : " + pizzaPepperoni.RecupererCout());
//création d'une nouvelle pizza à la saucisse
Pizza pizzaSaucisse = new Saucisse();
//décoration avec une garniture au fromage
pizzaSaucisse = new Fromage(pizzaSaucisse);
//décoration avec une garniture aux oignons
pizzaSaucisse = new Oignon(pizzaSaucisse);
Console.WriteLine(pizzaSaucisse.RecupererDescription() + " Coût :" + pizzaSaucisse.RecupererCout());
}
Voici la sortie de ce code :
Pepperoni Coût : 10 €.
Saucisse, Fromage, Oignon Coût : 19 €.
Comme vous le constatez, vous pouvez ajouter et supprimer des pizzas et des garnitures sans modifier le reste de votre code.
Quels sont les avantages de cette stratégie ?
Elle offre une option plus simple en cas d'héritage complexe (sur trop de niveaux).
Elle est utile lorsque vous devez ajouter des comportements supplémentaires à vos objets au moment de l'exécution.
Elle est utile lorsque différentes instances d'un même objet peuvent se comporter différemment.
En résumé
L’adaptateur permet à des classes incompatibles d'interagir en convertissant l'interface d'une classe en une interface attendue par le client.
Le composite décrit un groupe d'objets considérés comme formant une instance unique d'un même type d'objet.
Le décorateur vous permet d'ajouter un comportement à un objet sans modifier celui des autres objets de la même classe.
Maintenant que nous avons vu quelques design patterns de structure, étudions un dernier groupe de patterns : les patterns de comportement.