IdentifiantMot de passe
Loading...
Mot de passe oublié ?Je m'inscris ! (gratuit)
Développons en Java v 2.20   Copyright (C) 1999-2021 Jean-Michel DOUDOUX.   
[ Précédent ] [ Sommaire ] [ Suivant ] [ Télécharger ]      [ Accueil ] [ Commentez ]


 

97. Les motifs de conception (design patterns)

 

chapitre 9 7

 

Niveau : niveau 3 Intermédiaire 

 

Le nombre d'applications développées avec des technologies orientées objets augmentant, l'idée de réutiliser des techniques pour solutionner des problèmes courants a abouti aux recensements d'un certain nombre de modèles connus sous le nom de motifs de conception (design patterns).

Ces modèles sont définis pour pouvoir être utilisés avec un maximum de langages orientés objets.

Le nombre de ces modèles est en constante augmentation. Le but de ce chapitre n'est pas de tous les recenser mais de présenter les plus utilisés et de fournir un ou des exemples de leur mise en oeuvre avec Java.

Il est habituel de regrouper ces modèles communs dans trois grandes catégories :

  • les modèles de création (creational patterns)
  • les modèles de structuration (structural patterns)
  • les modèles de comportement (behavioral patterns)

Le motif de conception le plus connu est sûrement le modèle MVC (Model View Controller) mis en oeuvre en premier avec SmallTalk.

Ce chapitre contient plusieurs sections :

 

97.1. Les modèles de création

Dans cette catégorie, il existe 5 modèles principaux :

Nom Rôle
Fabrique (Factory) Créer un objet dont le type dépend du contexte
Fabrique abstraite (abstract Factory) Fournir une interface unique pour instancier des objets d'une même famille sans avoir à connaître les classes à instancier
Monteur (Builder)  
Prototype (Prototype) Création d'objet à partir d'un prototype
Singleton (Singleton) Classe qui ne pourra avoir qu'une seule instance

 

97.1.1. Fabrique (Factory)

La fabrique permet de créer un objet dont le type dépend du contexte : cet objet fait partie d'un ensemble de sous-classes. L'objet retourné par la fabrique est donc toujours du type de la classe mère mais grâce au polymorphisme les traitements exécutés sont ceux de l'instance créée.

Ce motif de conception est utilisé lorsqu'à l'exécution il est nécessaire de déterminer dynamiquement quel objet d'un ensemble de sous-classes doit être instancié.

Il est utilisable lorsque :

  • Le client ne peut déterminer le type d'objet à créer qu'à l'exécution
  • Il y a une volonté de centraliser la création des objets

L'utilisation d'une fabrique permet de rendre l'instanciation d'objets plus flexible que l'utilisation de l'opérateur d'instanciation new.

Ce design pattern peut être implémenté sous plusieurs formes dont les deux principales sont :

  • Déclarer la fabrique abstraite et laisser une de ses sous-classes créer l'objet
  • Déclarer une fabrique dont la méthode de création de l'objet attend les données nécessaires pour déterminer le type de l'objet à instancier

Il est possible d'implémenter la fabrique sous la forme d'une classe abstraite et de définir des sous-classes chargées de réaliser les différentes instanciations.

La classe ProduitFactory propose la méthode getProduitA() qui se charge de retourner l'instance créée par la méthode createProduitA().

Les classes ProduitFactory1 et ProduitFactory2 sont les implémentations concrètes de la fabrique. Elles redéfinissent la méthode createProduitA() pour qu'elle renvoie l'instance du produit.

La classe ProduitA est la classe abstraite mère de tous les produits.

Les classes ProduitA1 et ProduitA2 sont des implémentations concrètes de produits.

Exemple : le code sources des différentes classes
package com.jmd.test.dej.factory1;

public class Client {

  public static void main(String[] args) {
    ProduitFactory produitFactory1 = new ProduitFactory1();
    ProduitFactory produitFactory2 = new ProduitFactory2();

    ProduitA produitA = null;

    System.out.println("Utilisation de la premiere fabrique");
    produitA = produitFactory1.getProduitA();
    produitA.methodeA();

    System.out.println("Utilisation de la seconde fabrique");
    produitA = produitFactory2.getProduitA();
    produitA.methodeA();

  }
}
package com.jmd.test.dej.factory1;

public abstract class ProduitFactory {

  public ProduitA getProduitA() {
    return createProduitA();
  }

  protected abstract ProduitA createProduitA();
}

package com.jmd.test.dej.factory1;

public class ProduitFactory1 extends ProduitFactory {

  protected ProduitA createProduitA() {
    return new ProduitA1();
  }
}

package com.jmd.test.dej.factory1;

public class ProduitFactory2 extends ProduitFactory {

  protected ProduitA createProduitA() {
    return new ProduitA2();
  }
}

package com.jmd.test.dej.factory1;

public abstract class ProduitA {

  public abstract void methodeA();
}

package com.jmd.test.dej.factory1;

public class ProduitA1 extends ProduitA {

  public void methodeA() {
    System.out.println("ProduitA1.methodeA()");
  }
}

package com.jmd.test.dej.factory1;

public class ProduitA2 extends ProduitA {

  public void methodeA() {
    System.out.println("ProduitA2.methodeA()");
  }
}

Résultat :
Utilisation de la premiere fabrique
ProduitA1.methodeA()
Utilisation de la seconde fabrique
ProduitA2.methodeA()

Il est possible d'implémenter la fabrique sous la forme d'une classe qui possède une méthode chargée de renvoyer l'instance voulue. La création de cette instance est alors réalisée en fonction de données du contexte (valeurs fournies en paramètres de la méthode, fichier de configuration, paramètres de l'application, ...).

Dans l'exemple ci-dessous, la méthode getProduitA() attend en paramètre une constante qui précise le type d'instance à créer.

Exemple : le code sources des différentes classes
package com.jmd.test.dej.factory2;

public class Client {

  public static void main(String[] args) {
    ProduitFactory produitFactory = new ProduitFactory();

    ProduitA produitA = null;

    produitA = produitFactory.getProduitA(ProduitFactory.TYPE_PRODUITA1);
    produitA.methodeA();

    produitA = produitFactory.getProduitA(ProduitFactory.TYPE_PRODUITA2);
    produitA.methodeA();

    produitA = produitFactory.getProduitA(3);
    produitA.methodeA();

  }
}

package com.jmd.test.dej.factory2;

public class ProduitFactory {

  public static final int TYPE_PRODUITA1 = 1;
  public static final int TYPE_PRODUITA2 = 2;

  public ProduitA getProduitA(int typeProduit) {
    ProduitA produitA = null;

    switch (typeProduit) {
      case TYPE_PRODUITA1:
        produitA = new ProduitA1();
        break;
      case TYPE_PRODUITA2:
        produitA = new ProduitA2();
        break;
      default:
        throw new IllegalArgumentException("Type de produit inconnu");
    }

    return produitA;
  }
}

package com.jmd.test.dej.factory2;

public abstract class ProduitA {

  public abstract void methodeA();
}

package com.jmd.test.dej.factory2;

public class ProduitA1 extends ProduitA {

  public void methodeA() {
    System.out.println("ProduitA1.methodeA()");
  }
}

package com.jmd.test.dej.factory2;

public class ProduitA2 extends ProduitA {

  public void methodeA() {
    System.out.println("ProduitA2.methodeA()");
  }
}

Résultat :
ProduitA1.methodeA()
ProduitA2.methodeA()
java.lang.IllegalArgumentException: Type de produit inconnu
	at com.jmd.test.dej.factory2.ProduitFactory.getProduitA(ProduitFactory.java:19)
	at com.jmd.test.dej.factory2.Client.main(Client.java:16)
Exception in thread "main"

Cette implémentation est plus légère à mettre en oeuvre.

Remarque : c'est une bonne pratique de toujours respecter la même convention de nommage dans le nom des fabriques et dans le nom de la méthode qui renvoie l'instance.

 

97.1.2. Fabrique abstraite (abstract Factory)

Le motif de conception Abstract Factory (fabrique abstraite) permet de fournir une interface unique pour instancier des objets d'une même famille sans avoir à connaître les classes à instancier.

L'utilisation de ce motif est pertinente lorsque :

  • Le système doit être indépendant de la création des objets qu'il utilise
  • Le système doit être capable de créer des objets d'une même famille

Le principal avantage de ce motif de conception est d'isoler la création des objets retournés par la fabrique. L'utilisation d'une fabrique abstraite permet de facilement remplacer une fabrique par une autre selon les besoins.

Le motif de conception fabrique abstraite peut être interprété et mis en oeuvre de différentes façons. Le diagramme UML ci-dessous propose une mise en oeuvre possible avec deux familles de deux produits.

Dans cet exemple, les classes suffixées par un chiffre correspondent aux classes relatives à une famille donnée.

Les classes misent en oeuvre sont :

  • IProduitFactory : interface pour les fabriques de création d'objets. Elle définit donc les méthodes nécessaires à la création des objets
  • ProduitFactory1 et ProduitFactory2 : fabriques qui réalisent la création des objets
  • ProduitA et ProduitB : interfaces des deux familles de produits (En Java, cela peut être une classe abstraite ou une interface)
  • ProduitA1, ProduitA2, ProduitB1 et ProduitB2 : implémentations des produits des deux familles
  • Client : classe qui utilise la fabrique pour obtenir des objets

C'est une des classes filles de la fabrique qui se charge de la création des objets d'une famille. Ainsi tous les objets créés doivent hériter d'une classe abstraite qui sert de modèle pour toutes les classes de la famille.

Le client utilise une implémentation concrète de la fabrique abstraite pour obtenir une instance d'un produit créé par la fabrique.

Cette instance est obligatoirement du type de la classe abstraite dont toutes les classes concrètes héritent. Ainsi des objets concrets sont retournés par la fabrique mais le client ne peut utiliser que leur interface abstraite.

Comme il n'y a pas de relation entre le client et la classe concrète retournée par la fabrique, celle-ci peut renvoyer n'importe quelle classe qui hérite de la classe abstraite.

Ceci permet facilement :

  • De remplacer une classe concrète par une autre.
  • D'ajouter de nouveaux types d'objets qui héritent de la classe abstraite sans modifier le code utilisé par la fabrique.

Pour prendre en compte une nouvelle famille de produit dans le code client, il suffit simplement d'utiliser la fabrique dédiée à cette famille. Le reste du code client ne change pas. Ceci est beaucoup plus simple que d'avoir à modifier dans le code client l'instanciation des classes concrètes concernées.

Exemple :
package com.jmd.test.dej.abstractfactory;

public class Client {

  public static void main(String[] args) {
    IProduitFactory produitFactory1 = new ProduitFactory1();
    IProduitFactory produitFactory2 = new ProduitFactory2();

    ProduitA produitA = null;
    ProduitB produitB = null;

    System.out.println("Utilisation de la premiere fabrique");
    produitA = produitFactory1.getProduitA();
    produitB = produitFactory1.getProduitB();
    produitA.methodeA();
    produitB.methodeB();

    System.out.println("Utilisation de la seconde fabrique");
    produitA = produitFactory2.getProduitA();
    produitB = produitFactory2.getProduitB();
    produitA.methodeA();
    produitB.methodeB();

  }
}

package com.jmd.test.dej.abstractfactory;

public interface IProduitFactory {

  public ProduitA getProduitA();
  public ProduitB getProduitB();
}


package com.jmd.test.dej.abstractfactory;

public class ProduitFactory1 implements IProduitFactory {

  public ProduitA getProduitA() {
    return new ProduitA1();
  }

  public ProduitB getProduitB() {
    return new ProduitB1();
  }
}

package com.jmd.test.dej.abstractfactory;

public class ProduitFactory2 implements IProduitFactory {

  public ProduitA getProduitA() {
    return new ProduitA2();
  }

  public ProduitB getProduitB() {
    return new ProduitB2();
  }
}

package com.jmd.test.dej.abstractfactory;

public abstract class ProduitA {

  public abstract void methodeA();
}

package com.jmd.test.dej.abstractfactory;

public class ProduitA1 extends ProduitA {

  public void methodeA() {
    System.out.println("ProduitA1.methodeA()");
  }
}

package com.jmd.test.dej.abstractfactory;

public class ProduitA2 extends ProduitA {

  public void methodeA() {
    System.out.println("ProduitA2.methodeA()");
  }
}

package com.jmd.test.dej.abstractfactory;

public abstract class ProduitB {

  public abstract void methodeB();
}

package com.jmd.test.dej.abstractfactory;

public class ProduitB1 extends ProduitB {

  public void methodeB() {
    System.out.println("ProduitB1.methodeB()");
  }
}

package com.jmd.test.dej.abstractfactory;

public class ProduitB2 extends ProduitB {

  public void methodeB() {
    System.out.println("ProduitB2.methodeB()");
  }
}

Résultat :
Utilisation de la premiere fabrique
ProduitA1.methodeA()
ProduitB1.methodeB()
Utilisation de la seconde fabrique
ProduitA2.methodeA()
ProduitB2.methodeB()

Une fabrique concrète est généralement un singleton.

 

97.1.3. Monteur (Builder)

 

 

en construction
Cette section sera développée dans une version future de ce document

 

97.1.4. Prototype (Prototype)

 

 

en construction
Cette section sera développée dans une version future de ce document

 

97.1.5. Singleton (Singleton)

Ce motif de conception propose de n'avoir qu'une seule et unique instance d'une classe dans une application.

Le Singleton est fréquemment utilisé dans les applications car il n'est pas rare de ne vouloir qu'une seule instance pour certaines fonctionnalités (pool, cache, ...). Ce modèle est aussi particulièrement utile pour le développement d'objets de type gestionnaire. En effet ce type d'objet doit être unique car il gère d'autres objets par exemple un gestionnaire de logs.

La mise en oeuvre du design pattern Singleton doit :

  • assurer qu'il n'existe qu'une seule instance de la classe
  • fournir un moyen d'obtenir cette instance unique

Un singleton peut maintenir un état (stateful) ou non (stateless).

La compréhension de ce motif de conception est facile mais son implémentation ne l'est pas toujours, notamment, à cause de quelques subtilités de Java et d'une attention particulière à apporter dans le cas d'une utilisation multithreads.

Ce design pattern peut avoir plusieurs implémentations en Java.

1) une implémentation classique avec initialisation tardive

  • le ou les contructeurs ont un attribut de visibilité private pour empêcher toute instanciation de l'extérieur de la classe : ne pas oublier de redéfinir le constructeur par défaut si aucun constructeur n'est explicitement défini
  • l'unique instance est une variable de classe
  • un getter static permet de renvoyer l'instance et de la créer au besoin
  • redefinir la méthode clone pour empêcher son utilisation
  • la classe est déclarée final pour empêcher la création d'une classe fille
Exemple :
public final class MonSingleton {

  private static MonSingleton instance;

  private MonSingleton() {
    // traitement du constructeur
  }

  public static MonSingleton getInstance() {
    if (instance == null) {
      instance = new MonSingleton();
    }
    return instance;
  }

  @Override
  public Object clone() throws CloneNotSupportedException {
    throw new CloneNotSupportedException();
  }
}

Cette implémentation est simple mais malheureusement, elle n'est pas threadsafe. Dans un contexte multithreads, il est possible que les deux premiers appels concomitants puissent créer deux instances. Chaque thread reçoit alors une instance distincte ce qui ne répond pas aux contraintes du design pattern.

 

2) une implémentation thread-safe classique avec initialisation tardive

Le plus simple et le plus sûr pour éviter ce problème est de sécuriser l'accès au getter avec le mot clé synchronized.

Exemple :
public final class MonSingleton {

  private static MonSingleton instance;

  private MonSingleton() {
    // traitement du constructeur
  }

  public static synchronized MonSingleton getlnstance() {
    if (instance == null) {
      instance = new MonSingleton();
    }
    return instance;
  }

  @Override
  public Object clone() throws CloneNotSupportedException {
    throw new CloneNotSupportedException();
  }
}

Cette solution est thread-safe mais elle induit un coût en terme de performance, lié à la synchronisation de la méthode, qui peut devenir génant si la méthode est appelée fréquemment de façon concomitante.

 

3) une implémentation classique non thread-safe avec initialisation tardive

La partie qui doit vraiment être thread safe est la création de l'instance ce qui correspond uniquement à la première invocation de la méthode. Il peut être alors tentant de ne synchroniser que la création de l'instance.

Exemple :
public final class MonSingleton {

  private static MonSingleton instance;

  private MonSingleton() {
    // traitement du constructeur
  }

  public static MonSingleton getInstance() {
    if (instance == null) {
      synchronized (MonSingleton.class) {
        instance = new MonSingleton();
      }
    }
    return instance;
  }

  @Override
  public Object clone() throws CloneNotSupportedException {
    throw new CloneNotSupportedException();
  }
}

Le but est d'éviter de poser un verrou sur le moniteur de la classe à chaque invocation de la méthode. Malheureusement, cette solution n'est pas thread-safe.

Le thread 1 entre dans le bloc securisé et avant l'assignation de la reference créé par le constructeur à la variable instance, le scheduler passe la main au thread 2 qui teste si l'instance est null et c'est le cas donc il va attendre la sortie du bloc sécurisé du thread 1 pour exécuter à son tour le bloc de code sécurisé. Les deux threads obtiennent chacun une instance distincte.

 

4) une implémentation classique avec initialisation tardive non thread-safe avec double-checked

Une autre implémentation utilisée est celle nommée double-checked : elle consiste à retester si l'instance est bien null après la pose du verrou au cas ou un autre thread aurait déjà passé le premier test.

Exemple :
public final class MonSingleton {

  private static MonSingleton instance;

  private MonSingleton() {
    // traitement du constructeur
  }

  public static MonSingleton getInstance() {
    if (instance == null) {
      synchronized (MonSingleton.class) {
        if (instance == null) {
          instance = new MonSingleton();
        }
      }
    }
    return instance;
  }

  @Override
  public Object clone() throws CloneNotSupportedException {
    throw new CloneNotSupportedException();
  }
}

Cette solution, elle aussi, peut ne pas fonctionner non plus correctement si le compilateur, par optimisation, assigne la référence alors que l'objet n'est pas encore initialisé (son constructeur n'est pas encore invoqué).

Ainsi le premier thread pourrait ne pas avoir une instance entièrement initialisée.

 

5) une implémentation threadsafe avec initialisation au chargement de la classe

Cette implémentation qui exploite une spécificité de Java est simple, rapide et sûre.

Exemple :
public final class MonSingleton {

  private static MonSingleton instance = new MonSingleton();

  public static MonSingleton getlnstance() {
    return instance;
  }

  private MonSingleton() {
  }
}

Cette implémentation est thread-safe car les spécifications du langage Java impose à la JVM d'avoir initialisée une variable static avant sa première utilisation.

 

6) une implémentation threadsafe avec initialisation tardive

L'utilisation d'une classe interne statique permet une initialisation tardive garantie par les spécifications de la JVM.

Exemple :
public class MonSingleton {
  private MonSingleton() {
  }

  private static class MonSingletonWrapper {
    private final static MonSingleton instance = new MonSingleton();
  }

  public static MonSingleton getInstance() {
    return MonSingletonWrapper.instance;
  }
}

 

Il existe plusieurs précautions à prendre lors de la mise en oeuvre du Singleton. Il est tentant d'utiliser des singletons mais ceux-ci peuvent être à l'origine de certaines difficultés dans des cas biens précis :

  • les tests unitaires : il n'est pas facile de créer des mocks de singletons
  • la distribution de l'application dans plusieurs JVM : l'utilisation du Singleton peut poser des problèmes car chaque JVM aura son propre Singleton
  • le singleton peut être récupéré par le ramasse-miettes dans des JVM antérieures à la version 2. La seule solution dans ce cas est d'empécher le ramassette-miette de récupérer la mémoire des classes chargées (-Xnoclassgc), Ce problème ne concerne pas les JVM 1.3 et supérieures.
  • si la classe est chargée par plusieurs classloaders alors plusieurs instances existeront (une pour chaque classloader). Ceci est dû au fait qu'une même classe chargée par deux classloaders sera présente deux fois dans la permgen.

 

97.2. Les modèles de structuration

 

 

97.2.1. Façade (Facade)

Une bonne pratique de conception est d'essayer de limiter le couplage existant entre des fonctionnalités proposées par différentes entités. Dans la pratique, il est préférable de développer un petit nombre de classes et de proposer une classe pour les utiliser. C'est ce que propose le motif de conception façade.

Le but est de proposer une interface facilitant la mise en oeuvre d'un ensemble de classes généralement regroupées dans un ou plusieurs sous-systèmes. Le motif Façade permet d'offrir un niveau d'abstraction entre l'ensemble de classes et celles qui souhaitent les utiliser en proposant une interface de plus haut niveau pour utiliser les classes du sous-système.

Exemple : un client qui utilise des classes d'un sous-système directement

Cet exemple volontairement simpliste va être modifié pour mettre en oeuvre le modèle de conception Façade.

Employer ce modèle aide à simplifier une grande partie de l'interface pour utiliser les classes du sous-système. Il facilite la mise en oeuvre de plusieurs classes en fournissant une couche d'abstraction supplémentaire entre ces dernières et les classes qui les utilisent. Le modèle Façade permet donc de faciliter la compréhension et l'utilisation d'un sous-système complexe que ce soit pour faciliter l'utilisation de tout ou partie du système ou pour forcer une utilisation particulière de celui-ci.

Les classes du sous-système encapsulent les traitements qui seront exécutés par des appels de méthodes de l'objet Façade. Ces classes ne doivent pas connaître ni, de surcroît, avoir de référence sur l'objet Façade.

La façade propose un ensemble de méthodes qui vont réaliser les appels nécessaires aux classes du sous-système pour offrir des fonctionnalités cohérentes. Elle propose une interface pour faciliter l'utilisation du sous-système en implémentant les traitements requis pour utiliser les classes de celui-ci.

La classe qui implémente le modèle Façade encapsule les appels aux différentes classes impliquées dans l'exécution d'un traitement cohérent. Elle fait donc office de point d'entrée pour utiliser le sous-système.

Ce modèle requiert plusieurs classes :

  • Le client qui va utiliser la façade
  • La façade
  • Les classes du sous système utilisées par la façade

Exemple :

Le code à utiliser dans la classe client est réduit ce qui va en faciliter la maintenance. La façade masque donc les complexités du sous-système utilisé et fournit une interface simple d'accès pour les clients qui l'utilisent.

Exemple :
public class ClientTestFacade {
  public static void main(String[] argv) {
    TestFacade facade = new TestFacade();

    facade.methode1();
    facade.methode2();
  }
}

public class TestFacade {

  ClasseA classeA;
  ClasseB classeB;
  ClasseC classeC;
  ClasseD classeD;

  public TestFacade() {
    classeA = new ClasseA();
    classeB = new ClasseB();
    classeC = new ClasseC();
    classeD = new ClasseD();
  }

  public void methode1() {
    System.out.println("Methode2 : ");
    classeA.methodeA();
    classeD.methodeD();
    classeC.methodeC();
  }

  public void methode2() {
    System.out.println("Methode1 : ");
    classeB.methodeB();
    classeC.methodeC();
  }
}

public class ClasseA {
  public void methodeA() {
    System.out.println(" - MethodeA ClasseA");
  }
}

public class ClasseB {
  public void methodeB() {
    System.out.println(" - MethodeB Classe B");
  }
}

public class ClasseC {
  public void methodeC() {
    System.out.println(" - MethodeC ClasseC");
  }
}

public class ClasseD {
  public void methodeD() {
    System.out.println(" - MethodeD ClasseD");
  }
}

Résultat :
Methode2 : 
 - MethodeA ClasseA
 - MethodeD ClasseD
 - MethodeC ClasseC
Methode1 : 
 - MethodeB Classe B
 - MethodeC ClasseC

Le modèle Façade peut être utilisé pour :

  • Faciliter l'utilisation partielle d'un sous-système complexe ou de plusieurs classes
  • Masquer l'existence d'un sous-système
  • Ajouter des fonctionnalités sans modifier le sous-système
  • Assurer un découplage entre le client et le sous-système (par exemple pour chaque couche d'une architecture logicielle N tiers)

L'utilisation d'une façade permet au client de limiter le nombre d'objets à utiliser puisqu'il se contente simplement d'appeler une ou plusieurs méthodes de la façade. Ce sont ces méthodes qui vont utiliser les classes du sous-système, masquant ainsi au client toute la complexité de leur mise en oeuvre.

Il peut être pratique de définir une façade sans état (les méthodes de la façade n'utilisent pas de membres statiques de la classe) car dans ce cas, une seule et unique instance de la façade peut être définie côté client en mettant en oeuvre le modèle de conception singleton prévu à cet effet.

Il est possible de proposer des fonctionnalités supplémentaires dans la façade qui enrichissent la mise en oeuvre du sous-système.

La façade peut aussi être utilisée pour masquer le sous-système. Elle peut encapsuler les classes du sous-système et ainsi cacher au client l'existence du sous-système. Cette mise en oeuvre facilite le remplacement du sous-système par un autre : il suffit simplement de modifier la façade pour que le client continue à fonctionner.

Il est possible que toutes les fonctionnalités proposées par les classes du sous-système ne soient pas accessibles par la façade : son but est de simplifier leurs utilisations mais pas de proposer toutes les fonctionnalités.

Ce motif de conception est largement utilisé.

 

97.2.2. Décorateur (Decorator)

Le motif de conception décorateur (decorator en anglais) permet d'ajouter des fonctionnalités à un objet en mettant en oeuvre une solution plus souple que l'héritage : il permet d'ajouter des fonctionnalités à une ou plusieurs méthodes existantes d'une classe dynamiquement.

La programmation orientée objet propose l'héritage pour ajouter des fonctionnalités à une classe, cependant l'héritage présente quelques contraintes et il n'est pas toujours possible de le mettre en oeuvre (par exemple si la classe est finale). L'héritage crée une nouvelle classe qui reprend les fonctionnalités de  la classe mère et les modifie ou les enrichie. Mais il présente quelques inconvénients :

  • Il n'est pas toujours possible (par exemple pour une classe déclarée finale)
  • Cela peut faire augmenter le nombre de classes pour définir tous les cas de figure requis
  • L'ajout des fonctionnalités est statique

Avec l'héritage, il serait nécessaire de définir autant de classes filles que de cas ce qui peut vite devenir ingérable. Avec l'utilisation d'un décorateur, il suffit de définir un décorateur pour chaque fonctionnalité et de les utiliser par combinaison en fonction des besoins. L'héritage ajoute des fonctionnalités de façon statique (à la compilation) alors que le décorateur ajoute des fonctionnalités de façon dynamique (à l'exécution).

Le modèle de conception décorateur apporte une solution à ces trois inconvénients et propose donc une alternative à l'héritage.

Le motif de conception décorateur permet de définir un ensemble de classes possédant une base commune mais proposant chacune des variantes sans utiliser l'héritage qui est le mécanisme de base par la programmation orientée objet. Ceci permet d'enrichir une classe avec des fonctionnalités supplémentaires.

Ce motif est dédié à la création de variantes d'une classe plutôt que d'avoir une seule classe prenant en compte ces variantes. Il permet aussi de réaliser des combinaisons de plusieurs variantes.

Ce motif de conception est donc généralement utilisé lorsqu'il n'est pas possible de prédéfinir le nombre de combinaisons induites par l'ajout de nombreuses fonctionnalités ou si ce nombre est trop important. Le principe du motif de conception décorateur est d'utiliser la composition : le décorateur contient un objet décoré. L'appel d'une méthode du décorateur provoque l'exécution de la méthode correspondante du décoré et des fonctionnalités ajoutées par le décorateur.

Le motif décorateur repose sur deux entités :

  • le décoré : interface ou classe qui définit les fonctionnalités de base
  • le décorateur : classe enrichie qui contient les fonctionnalités de base plus celles ajoutées

Le décorateur encapsule le décoré dont l'instance est généralement fournie dans les paramètres d'un constructeur. Il est important que l'interface du décorateur reprenne celle de l'objet décoré. Pour permettre de combiner les décorations, le décoré et le décorateur doivent implémenter une interface commune.

La combinaison peut alors être répétée pour construire un objet qui va contenir les différentes fonctionnalités proposées par les décorateurs utilisés.

Le motif de conception décorateur est particulièrement utile dans plusieurs cas :

  • définition de fonctionnalités génériques qui peuvent prendre plusieurs formes
  • définition de plusieurs fonctionnalités optionnelles

Il permet de créer un objet qui va être composé des fonctionnalités requises par ajouts successifs des différents décorateurs proposant les fonctionnalités requises.

Un des avantages de ce motif de conception est de n'avoir à créer qu'une seule classe pour proposer des fonctionnalités supplémentaires aux classes qui mettent en oeuvre ce motif. Avec l'héritage, il serait nécessaire de créer autant de classes filles que de classes concernées ou de gérer la fonctionnalité dans une classe mère en modifiant cette dernière pour prendre en compte cet ajout avec tous les risques que cela peut engendrer.

Pour mettre en oeuvre ce motif, il faut :

1) définir une interface qui va déclarer toutes les fonctionnalités des décorés.

Exemple : interface Traitement
package com.jmdoudoux.test.dp.decorateur;

public interface Traitement {
  public void Operation();
}

2) définir un décorateur de base qui implémente l'interface et possède une référence sur une instance de l'interface. Cette référence est le décoré qui va être enrichi des fonctionnalités du décorateur.

Exemple : classe abstraite TraitementDecorateur
package com.jmdoudoux.test.dp.decorateur;

public abstract class TraitementDecorateur implements Traitement {

  protected Traitement traitement;
  
  public TraitementDecorateur() 
  {
  }
  
  public TraitementDecorateur(Traitement traitement) 
  {
    this.traitement = traitement;
  }

  public void Operation() {
    if (traitement != null)
    {
      traitement.Operation();
    }
  }
}

3) définir les décorateurs qui héritent du décorateur de base et implémentent les fonctionnalités supplémentaires qu'ils sont chargés de proposer.

Exemple : TraitementDecorateur1
package com.jmdoudoux.test.dp.decorateur;

public class TraitementDecorateur1 extends TraitementDecorateur {

  public TraitementDecorateur1() {
    super();
  }

  public TraitementDecorateur1(Traitement traitement) {
    super(traitement);
  }

  @Override
  public void Operation() {
    if (traitement != null)
    {
      traitement.Operation();
    }
    System.out.println("TraitementDecorateur1.Operation()");
  }
}

Exemple : TraitementDecorateur2
package com.jmdoudoux.test.dp.decorateur;

public class TraitementDecorateur2 extends TraitementDecorateur {

  public TraitementDecorateur2() {
    super();
  }

  public TraitementDecorateur2(Traitement traitement) {
    super(traitement);
  }

  @Override
  public void Operation() {
    if (traitement != null) {
      traitement.Operation();
    }

    System.out.println("TraitementDecorateur2.Operation()");
  }
}

Exemple : TraitementDecorateur3
package com.jmdoudoux.test.dp.decorateur;

public class TraitementDecorateur3 extends TraitementDecorateur {

  public TraitementDecorateur3() {
    super();
  }

  public TraitementDecorateur3(Traitement traitement) {
    super(traitement);
  }

  @Override
  public void Operation() {
    if (traitement != null)
    {
      traitement.Operation();
    }
    System.out.println("TraitementDecorateur3.Operation()");
  }
}

Il est possible de fournir une classe d'implémentation par défaut.

Il est pratique d'utiliser le motif de conception fabrique pour construire l'objet décoré finale. Dans ce cas, une implémentation par défaut de l'interface peut être utile.

Exemple : TraitementTest.java
package com.jmdoudoux.test.dp.decorateur;

public class TraitementTest {

  public static void main(String[] args) {
    System.out.println("traitement 1 2 3");
    Traitement traitement123 = new TraitementDecorateur3(
      new TraitementDecorateur2(new TraitementDecorateur1()));
    traitement123.Operation();
    
    System.out.println("traitement 1 3");
    Traitement traitement13 = new TraitementDecorateur3(new TraitementDecorateur1());
    traitement13.Operation();
  }
}

Résultat d'exécution :
traitement 1 2 3
TraitementDecorateur1.Operation()
TraitementDecorateur2.Operation()
TraitementDecorateur3.Operation()
traitement 1 3
TraitementDecorateur1.Operation()
TraitementDecorateur3.Operation()

L'API de base de Java utilise le motif de conception décorateur notamment dans l'API IO

 

97.3. Les modèles de comportement

 

 

en construction
La suite de ce chapitre sera développée dans une version future de ce document

 


Développons en Java v 2.20   Copyright (C) 1999-2021 Jean-Michel DOUDOUX.   
[ Précédent ] [ Sommaire ] [ Suivant ] [ Télécharger ]      [ Accueil ] [ Commentez ]