Design patterns de création
Dans le dernier chapitre, nous avons vu ce que sont les design patterns et quelle est leur utilité. Nous allons maintenant examiner en détail certains d'entre eux pour chaque catégorie (création, structure et comportement).
Les design patterns de création permettent de créer des objets d'une manière adaptée à la situation.
Commençons par quatre design patterns de création :
Fabrique
Monteur
Prototype
Singleton
Fabrique (Factory)
Comme n'importe quelle usine ou fabrique, le pattern Fabrique crée et produit des éléments.
Dans une fabrique automobile, chaque voiture se compose de nombreux éléments (freins, sièges, moteurs, pneus). Un opérateur sur la ligne d'assemblage d'un moteur sait qu'il contribue à la construction d'une voiture, sans forcément en connaître le modèle.
De la même manière, vous pouvez utiliser le pattern Fabrique dans les situations où vous disposez d'un ensemble de classes de composants, sans savoir laquelle utiliser au moment de l'exécution. Il vous permet de créer un objet sans exposer la logique de création. Ainsi, vous utilisez une interface pour créer l'objet, mais vous laissez une sous-classe choisir la classe à instancier.
Comment savoir si je dois utiliser un pattern Fabrique ?
Voici quelques indices :
Vos constructeurs comportent trop de code. Vous avez l'impression de ne pas vous contenter d'affecter quelques variables ?
Un constructeur au sein d'une classe dispose d'un argument. Vous devez par la suite modifier votre code, et votre constructeur se retrouve à devoir accepter davantage d'arguments. Il vous faut alors modifier votre application partout où cette classe a été créée.
Voici un exemple :
LampeTable lampe = new LampeTable(Interrupteur interrupteur, TypeAmpoule typeAmpoule);
Lorsque l'objet est créé, le client connaît les classes et le processus de l'objet. Vous devriez éviter d'exposer ce type de détails de la création d'objet à l'application cliente.
Voyons le diagramme de classe UML ci-dessous. Il présente la structure d'une fabrique :
Structure d'une fabrique
IProduit
: interface de création de l'objet.ProduitConcret
: classe implémentant l'interface du produit.Createur
: classe abstraite qui déclare la méthode Fabrique et renvoie un objet de typeProduit
.CreateurConcret
: classe qui implémente la classe Createur et remplace la méthode Fabrique pour renvoyer une instance deProduitConcret
.
Qu'est-ce que cela donne en code ?
Codons le diagramme UML ci-dessous en C# :
public interface IProduit
{
}
public class ProduitConcretA : IProduit
{
}
public class ProduitConcretB : IProduit
{
}
public abstract class Createur
{
public abstract IProduit Fabrique(string type);
}
public class CreateurConcret : Createur
{
public override Produit Fabrique(string type)
{
switch(type)
{
case "A":
return new ProduitConcretA();
break;
case "B":
return new ProduitConcretB();
break;
default:
throw new ArgumentException("Type non valide", type);
}
}
}
Prenons maintenant la structure ci-dessus pour créer une fabrique de produit.
Commençons par créer une interface IProduit
:
public interface IProduit
{
string RecupererNom();
string FixerPrix(double prix);
}
En utilisant une interface, nous pouvons confier aux sous-classes le choix de la classe à instancier. Par exemple :
public class Telephone : IProduit {}
public class Tablette : IProduit {}
Créons la classe Telephone :
public class Telephone : IProduit
{
private double _prix;
public string RecupererNom()
{
return "IPad";
}
public string FixerPrix(double prix)
{
this._prix = prix;
return "Opération réussie";
}
}
Créons une classe abstraite devant jouer le rôle de fabrique :
public abstract class FabriqueProduit
{
public abstract IProduit CreerProduit();
public IProduit RecupererProduit()
{
return this.CreerProduit();
}
}
Avec la fabrique Produit, vous pouvez désormais créer les fabriques de type produit qui en héritent.
public class FabriqueTelephone : FabriqueProduit
{
public override IProduit CreerProduit()
{
IProduit produit = new Telephone();
produit.FixerPrix(200.50);
return produit;
}
}
Ainsi, lorsque vous souhaitez créer un objet Telephone :
FabriqueProduit fabriqueTelephone = new FabriqueTelephone();
IProduit telephone = fabriqueTelephone.CreerProduit();
Mais comment faire pour créer une tablette ? C'est simple :
FabriqueProduit fabriqueTablette = new FabriqueTablette();
IProduit tablette = fabriqueTablette.CreerProduit();
Quels sont les avantages de cette stratégie ?
L'exemple ci-dessus vous a-t-il rappelé quelque chose ? Si vous vous souvenez encore des principes SOLID, vous avez dû remarquer que le pattern de fabrique suit le principe ouvert/fermé. Lorsqu'un nouveau besoin a fait son apparition, la création d'une tablette, nous n'avons pas eu besoin de modifier le code existant. Nous avons simplement créé une fabrique supplémentaire.
Pour prendre en charge d'autres produits, ne modifiez pas le code existant, mais ajoutez une nouvelle classe de fabrique.
L'application cliente appelle
CreerProduit
sans savoir ce qui a été créé ni comment.
Monteur (Builder)
Le prochain design pattern sur notre liste est le monteur. Sa définition est la suivante :
Séparer la construction d'un objet complexe de sa représentation pour qu'un même processus puisse créer différentes représentations.
Dans quels cas utiliser ce design pattern ?
Dès que vous aurez besoin de créer un élément à partir d'éléments plus petits.
Représentez-vous une ligne d'assemblage d'un véhicule, qui n'est rien d'autre qu'un processus constitué de multiples étapes consistant à ajouter de petites pièces pour aboutir à une voiture finie.
Dans le cadre d'une application, ce pattern est utile lorsque vous devez créer plusieurs objets complexes dont la procédure de construction est similaire.
Un pattern fabrique semble bien convenir. Pourquoi ne pas l'utiliser donc ?
C'est une très bonne question ! La différence réside dans la complexité. Utilisez le monteur lorsque vous devez suivre un grand nombre d'étapes pour créer un objet. La fabrique est plus appropriée lorsque vous devez créer des objets appartenant à une même famille. Le monteur renvoie le produit à la dernière étape d'une série, tandis que la fabrique renvoie un produit fini en une seule étape.
Voyons le diagramme de classe UML ci-dessous. Il présente la structure d'un monteur :
Directeur : cette classe ne procède pas directement à la création et à l'assemblage de l'objet. Elle fait référence à l'interface Monteur pour créer les pièces d'un objet complexe.
Monteur : interface abstraite de création d'objets (produit).
MonteurConcret : cette classe implémente le monteur, ce qui la rend capable de créer d'autres objets.
Produit : classe qui définit le type de l'objet complexe à générer.
Reprenons notre exemple de voiture construite étape par étape.
Pour commencer, nous créons une classe qui représente l'objet produit :
public class Voiture
{
public string Marque { get; set; }
public string Modele { get; set; }
public int NombrePortes { get; set; }
public string Couleur { get; set; }
public Voiture(string marque, string modele, string couleur, int nombrePortes)
{
Marque = marque;
Modele = modele;
Couleur = couleur;
NombrePortes = nombrePortes;
}
}
Créons ensuite une interface Monteur :
public interface IMonteurVoiture
{
string Couleur { get; set; }
int NombrePortes { get; set; }
Voiture Creer();
}
Implémentons maintenant un monteur concret :
public class MonteurHonda : IMonteurVoiture
{
public string Couleur { get; set; }
public int NombrePortes { get; set; }
public Voiture Creer()
{
return NombrePortes == 2 ? new Voiture("Honda", "Civic", Couleur, NombrePortes) : null;
}
}
Vient ensuite la classe Directeur, qui contient le déroulement ou l'algorithme de construction de la voiture à l'aide du monteur.
public class DirecteurMonteurVoiture
{
private IMonteurVoiture _monteur;
public DirecteurMonteurVoiture(IMonteurVoiture monteur)
{
_monteur = monteur;
}
public void Construction()
{
_monteur.Couleur = "Bleu";
_monteur.NombrePortes = 2;
}
}
Enfin, créons une Honda dans l'application cliente :
public class Client
{
public void ConstructionDeVoitures()
{
MonteurHonda monteur = new MonteurHonda();
DirecteurMonteurVoiture directeur = new DirecteurMonteurVoiture(monteur);
directeur.Construction();
Voiture monHonda = monteur.Creer();
}
}
Le directeur assemble une instance de voiture et délègue la construction à un objet monteur distinct qui lui a été confié par le client.
Quels sont les avantages de cette stratégie ?
MonteurConcret réunit tous les composants nécessaires dans la même portée. Le client du monteur ne voit rien à propos de la voiture (produit).
Le principe d'inversion des dépendances est respecté. L'interface de monteur joue un rôle clé dans ce pattern, car le directeur et le MonteurConcret suivent les mêmes règles de construction d'un véhicule.
Un processus de construction peut donner naissance à plusieurs types de voitures différents.
Les paramètres transmis au constructeur sont limités.
L'objet est toujours instancié dans un état complet (c'est-à-dire
monteur.Creer();
) .
Prototype
Passons au pattern prototype. Sa définition est la suivante :
Spécifiez les types d'objets à créer à l'aide d'une instance de prototype, puis créez de nouveaux objets en copiant ce prototype.
Pour mieux comprendre, faisons une analogie : pour se multiplier, les cellules de votre corps se divisent en cellules identiques.
En termes de développement logiciel, vous pouvez cloner un objet pour en créer un nouveau instantanément sans partir de zéro. Dans les chapitres précédents, nous avons vu comment créer une instance de classe à partir d'un monteur ou d'une fabrique. Désormais, vous pouvez cloner ce monteur ou cette fabrique.
Le clone (copie) d'un objet est totalement indépendant de l'objet original et peut être utilisé librement sans affecter l'original. Le clonage n'a par ailleurs aucune limite : n'importe quel objet peut être cloné.
En C#, pour rendre une classe clonable, on utilise l'interface ICloneable
.
Il existe deux types de clonages : la copie superficielle et la copie complète.
La copie superficielle permet de créer un nouvel objet à partir d'un objet existant en reprenant les champs de type valeur de l'objet initial. Dans le cas des champs de type référence, elle ne copie que la référence. Par conséquent, l'original et son clone font référence au même objet dans ce cas de figure.
Dans l'exemple suivant, nous allons cloner une classe Employe
basique.
public class Employe : ICloneable
{
public string Prenom { get; set; }
public string Nom { get; set; }
public string Service { get; set; }
public Adresse AdresseEmp { get; set; }
public object Clone()
{
return this.MemberwiseClone();
}
}
public class Adresse
{
public string Rue { get; set; }
}
Voyons comment cette classe est utilisée dans l'application cliente console :
public static void Main(string[] args)
{
Employe emp1 = new Employe();
emp1.Prenom = "Jean";
emp1.Nom = "Martin";
emp1.Service = "Informatique";
emp1.AdresseEmp = new Adresse() { Rue = "4 rue de la libération" };
Employe emp2 = (Employe)emp1.Clone();
emp2.Prenom = "Pierre";
emp2.AdresseEmp.Rue = "30 rue des lilas";
}
Vous remarquez que la méthode Clone()
inclut la méthode .NET MemberwiseClone()
. C'est ainsi qu'on effectue la copie superficielle d'un objet.
Dans l'exemple précédent :
J'ai créé un objet
emp1
et j'ai défini quelques valeurs.J'ai ensuite créé un deuxième objet,
emp2
, en utilisant la méthodeClone()
.L'ajout de
MemberwiseClone()
m'a permis de créer une copie superficielle reprenant les champs de type valeur suivants :Prenom
,Nom
etService
.AdresseEmp
est un type référence. Par conséquent, si je modifie la valeurRue
dansemp1
, elle sera aussi modifiée dansemp2
.
Dans notre exemple, il vaut sans doute mieux que les valeurs AdresseEmp
soient indépendantes dans tous les objets clonés. Pour contourner ce problème, nous devons faire une copie complète, qui créera un nouvel objet à partir d'un objet existant. Les champs de type valeur et de type référence seront copiés.
Apportons une modification à notre exemple pour réaliser une copie complète. Modifions la classe Adresse
:
public class Adresse : ICloneable
{
public string Rue { get; set; }
public object Clone()
{
return this.MemberwiseClone();
}
}
Apportons ensuite une petite modification à la méthode Clone()
incluse dans la classe Employe
:
public object Clone()
{
this.AdresseEmp = (Adresse)AdresseEmp.Clone();
return this.MemberwiseClone();
}
Maintenant, si vous modifiez la valeur Rue
dans emp1.AdresseEmp
, la valeur Rue
de emp2.AdresseEmp
ne changera pas.
Quels sont les avantages de cette stratégie ?
Elle vous évite de mobiliser des ressources parfois importantes pour initialiser un objet.
Elle permet de simplifier et d'optimiser les situations dans lesquelles plusieurs objets d'un même type ont beaucoup de données en commun.
Elle permet d'ajouter et de supprimer des objets au moment de l'exécution.
Elle permet de configurer les classes d'une application de manière dynamique.
Singleton
Le dernier design pattern que nous allons étudier est le singleton. C'est aussi le plus simple. Le singleton limite l'instanciation d'une classe à une instance unique.
L'implémentation d'une classe singleton doit suivre les règles ci-dessous :
L'instance doit être unique.
L'instance doit être disponible de manière globale.
Ce pattern est le plus controversé, car il est aussi considéré comme un anti-pattern.
Pourquoi ?
Tout simplement parce qu'il est fréquemment utilisé à mauvais escient.
Il ne respecte pas le principe SOLID de responsabilité unique. Le rôle d'une classe
Singleton
est de créer une seule instance d'elle-même, mais cet objectif peut être atteint par d'autres moyens.On ne peut pas en faire une sous-classe.
Une classe
Singleton
peut masquer des dépendances.Elle est difficile à valider par le biais d'un test unitaire.
Si elle présente autant d'inconvénients, pourquoi l'utiliser ?
C'est un pattern utile pour la journalisation, la communication, la configuration, la mise en cache et d'autres actions nécessitant un point d'accès global.
Prenons un exemple simple :
public sealed class MonSingleton
{
private static MonSingleton _instance;
private MonSingleton()
{
}
public static MonSingleton Instance()
{
if(_instance == null)
{
_instance = new MonSingleton();
}
return _instance;
}
}
Analysons cette classe.
Tout d'abord, vous remarquez que j'ai
sealed
(scellé) la classe. Je me suis ainsi assuré qu'elle ne puisse pas être héritée et que son instanciation soit proscrite dans la classe dérivée.Ensuite, le constructeur privé
MonSingleton()
garantit que la classe ne sera jamais instanciée de manière externe.Enfin, la propriété publique
Instance
permet de renvoyer une seule instance de la classe en vérifiant la valeur de l'instance de la variable privée.
Penchons-nous maintenant sur l'application cliente qui utilisera cette classe singleton.
static void Main(string[] args)
{
MonSingleton single1 = MonSingleton.Instance();
MonSingleton single2 = MonSingleton.Instance();
if(single1 == single2)
{
Console.WriteLine("Ces deux objets sont identiques.");
}
}
Sortie :
Ces deux objets sont identiques.
Comme vous pouvez le voir, un singleton garantit qu'une seule instance de la classe puisse être créée.
En résumé
Les design patterns de création sont liés à la création d'objets.
Le pattern fabrique offre une grande flexibilité pour créer des objets différents.
Le pattern monteur permet de créer des objets complexes étape par étape.
Le pattern prototype permet de créer des copies d'objets existants d'une même classe.
Le pattern singleton permet de s'assurer qu'une seule instance de la classe puisse être créée.
Maintenant que nous avons vu quelques design patterns de création, étudions un autre groupe de patterns, les patterns de structure.