Notes S6
Patrons de conception I
Qu’est-ce qu’un patron de conception ?
Un patron de conception (design pattern) est une solution à un problème de conception récurrent.
Ces patrons sont nommés et décrits dans des répertoires, comme le livre Design Patterns, Elements of Reusable Object-Oriented Software de Gamma et al.
Pourquoi les utiliser ?
- Diffuser largement les meilleures solutions connues à des problèmes récurrents
- Permettre de raisonner et communiquer à un plus haut niveau d’abstraction (car les solutions sont nommées)
- ⚠️ Ce ne sont pas une panacée : n’utiliser un patron que lorsque c’est justifié, les avantages doivent compenser les inconvénients (ex : augmentation de la complexité)
Ce qu’un patron n’est PAS
Un patron décrit comment organiser un ensemble de classes (pas des classes concrètes). Il ne peut donc pas être écrit une fois pour toutes dans une bibliothèque — c’est au programmeur de l’appliquer à chaque fois.
Description d’un patron
Un patron de conception est composé de :
- Son nom
- Une description du problème résolu
- Une description de la solution
- Une présentation des conséquences liées à son utilisation
Diagramme de classes
Un diagramme de classes décrit visuellement un ensemble de classes/interfaces et leurs relations :
- Héritage : classe hérite d’une autre ou implémente une interface
- Association : classe utilise une ou plusieurs instances d’une autre classe (annotée avec nom et arité,
*= arbitraire) - Instanciation : classe crée des instances d’une autre classe
Les classes fictives (dans les diagrammes de patrons) sont signalées par des bords discontinus.
1. Patron Builder (Bâtisseur)
Problème : La construction d’un objet immuable est complexe (ex : grosse matrice), nécessitant de spécifier tous les éléments en une seule fois.
Solution : Utiliser un objet mutable (bâtisseur) pour stocker l’état de l’objet en cours de construction, puis appeler build() à la fin.
public interface Matrix {
double get(int r, int c);
Matrix transpose();
Matrix inverse();
Matrix add(Matrix that);
Matrix multiply(double that);
Matrix multiply(Matrix that);
// … autres méthodes
}public final class DenseMatrix //diff de 0
implements Matrix { … }
public final class SparseMatrix // matrice creuse avec elements = 0
implements Matrix { … }Matrix est une interface donc on fournit un bâtisseur :
public final class MatrixBuilder {
private double[][] elements;
public MatrixBuilder(int r, int c) {
elements = new double[r][c];
}
public double get(int r, int c) {
return elements[r][c];
}
public void set(int r, int c, double v) {
elements[r][c] = v;
}
public Matrix build() {
return new DenseMatrix(elements);
}
}et aussi on peut faire :
// … comme avant
public Matrix build() {
if (density() > 0.5)
return new DenseMatrix(elements);
else
return new SparseMatrix(elements);
}
}Généralisation : Chaque fois que le processus de construction d’un objet est assez difficile pour être découpé en plusieurs étapes, on utilise un objet bâtisseur pour stocker l’état intermédiaire.
Le diagramme de classes ci-dessous illustre les classes (fictives) impliquées dans la construction d’une instance de la classe Product par une instance d’une classe Director, au moyen d’un bâtisseur de classe Builder.
Exemple réel : La classe String (de java.lang) modélise les chaînes de caractères, qui ne sont pas modifiables.
La classe StringBuilder sert de bâtisseur pour les chaînes de caractères. Elle possède entre autres une méthode append pour ajouter la représentation textuelle d’un objet à la chaîne en cours de construction, et la méthode toString pour obtenir la chaîne construite.
2. Patron Decorator (Décorateur)
Problème : On désire modifier le comportement d’un objet sans changer son interface.
Solution (généralisation) : “Emballer” l’objet dans un autre objet ayant la même interface mais un comportement différent. L’objet emballant laisse l’objet emballé faire le gros du travail, mais modifie son comportement si nécessaire. Aussi appelé Wrapper.
public interface Shape {
public boolean contains(Point p);
// … autres méthodes
}
public final class Circle implements Shape {
…
}
// … Polygon, etc.et on peut :
public final class Translated implements Shape {
private final Shape shape;
private final double dx, dy;
public Translated(…) { … }
public boolean contains(Point p) {
return s.contains(new Point(p.x() - dx, p.y() - dy)); //translation du points (inverse)
}
// … autres méthodes
}Exemples réels :
- Collections Java :
unmodifiableList,subList→ les classes mettant en œuvre ces vues sont des décorateurs - Entrées/sorties Java :
BufferedInputStreamest un décorateur ajoutant une mémoire tampon au flot sous-jacent - Interfaces graphiques : ajout d’ornements aux composants (bordures, barres de défilement) — d’où le nom Decorator
Par exemple : unmodifiablelist( une vue) est aussi un comparateur
La bibliothèque Java offre plusieurs méthodes permettant d’obtenir des vues sur des (parties de) collections, p.ex. subList dans List, unmodifiableList dans Collections, etc. Les classes mettant en œuvre ces vues sont des décorateurs.
Decorator vs Héritage
Problème avec l’héritage : il casse l’encapsulation. Ex : CountingHashSet qui hérite de HashSet et redéfinit add et addAll — le test échoue car addAll appelle add en interne dans HashSet, ce qui double-compte.
Solution avec Decorator : CountingSet implémente Set<E> et délègue à un Set<E> interne.
public final class CountingSet<E> implements Set<E> {
private final Set<E> s;
private int addCount = 0;
public CountingSet(Set<E> s) { this.s = s; }
@Override
public boolean add(E e) {
addCount += 1;
return s.add(e);
}
// …
}Avantage supplémentaire : ce décorateur s’applique à n’importe quel type d’ensemble, pas seulement HashSet !
Décorateur amélioré avec ForwardingSet
Principe de séparation des responsabilités (separation of concerns) :
ForwardingSet(abstraite) : décorateur par défaut qui transmet tous les messages à l’ensemble décoréCountingSet: hérite deForwardingSetet redéfinit uniquementaddetaddAll
Règle des classes : Une classe joue soit le rôle de classe instanciable (la déclarer final) soit de classe héritable (la déclarer abstract).
3. Patron Composite
public final class Group implements Shape {
private final List<Shape> shapes;
public Group(List<Shape> shapes) { … }
public boolean contains(Point p) {
for (Shape s: shapes)
if (s.contains(p))
return true;
return false;
}
// … autres méthodes
}Le groupe n’est pas une seule forme, mais a le type shape donc on le traite comme une forme normale (fait référence à un certain nombre d’instance de shape) (les flèches, montre que Group référence des instances de shape (un attribut shapes qui possèdent un nombre variable de shape)
Lorsque les objets d’un programme peuvent être composés entre eux pour former des macro-objets, il est judicieux de faire en sorte que ceux-ci aient le même type que les objets qu’ils composent. De la sorte, il est possible de traiter les objets et les macro-objets de manière uniforme.
Cela implique entre autres que les macro-objets peuvent être composés d’autre macro-objets, de manière récursive.
Cette idée est décrite par le patron Composite.
Exemples réels :
- Entrées/sorties Java :
SequenceInputStreamprend deux flots d’entrée et produit un flot composite (premier puis second) - Interfaces graphiques : les composants conteneurs (panneaux à onglets, etc.) sont eux-mêmes des composants → composition récursive
- Dans la série : on combinait 2 images qui se superposaient → composite car 3 images mélangées pour obtenir une 4ième
4. Patron Adapter (Adaptateur)
Problème : On désire utiliser une instance d’une classe là où une instance d’une autre classe est attendue (types incompatibles).
Exemple : Utiliser Collections.shuffle(List) sur un tableau Java.
final class ArrayAdapter<E> extends AbstractList<E> {
private final E[] array;
public ArrayAdapter(E[] array) {
this.array = array;
}
@Override
public E get(int index) {
return array[index];
}
@Override
public E set(int index, E newValue) {
E oldValue = array[index];
array[index] = newValue;
return oldValue;
}
@Override
public int size() {
return array.length;
}
}Une fois l’adaptateur défini, il est possible de l’utiliser pour mélanger un tableau au moyen de la méthode shuffle :
String[] array = …;
List<String> adaptedArray = new ArrayAdapter<>(array);
// mélange les éléments de array
Collections.shuffle(adaptedArray);
sout(Arrays.toString(array));Note : Arrays.asList(array) fourni dans la bibliothèque Java est déjà un adaptateur !
Exemples réels :
Arrays.asList: adapte un tableau en listeCollections.newSetFromMap: adapte uneMapenSetInputStreamReader: adapte unInputStream(octets) enReader(caractères)OutputStreamWriter: adapte unOutputStreamenWriter
Adapter ajuste un objet de base
5. Comparaison Decorator / Composite / Adapter
| Patron | Référence | Type de l’objet référencé | Empilement possible ? |
|---|---|---|---|
| Decorator | 1 seul objet | Même type que lui-même | ✅ Oui |
| Composite | Plusieurs objets | Même type que lui-même | ✅ Oui |
| Adapter | 1 seul objet | Type différent | ❌ Non |
Différence entre composite et decorator : un décorateur ne possède qu’une référence tandis que composite peut en avoir plusieurs (références stockées)
Différence entre Decorator et Adapter : un décorateur a le même type que l’objet qu’il décore, alors qu’un adaptateur a un type différent.
Exemple : Rallonge comme un décorateur (autre câble qui peut encore être rallongé), adaptateur (prise suisse, anglaise) → la prise anglaise est d’un autre type, je ne peux pas remettre le même adaptateur
Approche compositionnelle
Les trois patrons sont souvent utilisés ensemble pour créer des objets complexes par composition d’objets simples. En mémoire, cela forme une structure en arbre :
- Feuilles = objets de base
- Nœuds à 1 fils = décorateurs ou adaptateurs
- Nœuds à plusieurs fils = composites
Empilement possible de décorateurs :
InputStream s = new GZIPInputStream(
new BufferedInputStream(
new FileInputStream(…)));