Les patrons structurels (1/2)
Assembler objets et classes en structures souples : Adaptateur, Pont, Composite et Décorateur.
Après les patrons de création, qui répondent à la question « comment fabriquer mes objets ? », viennent les patrons structurels (structural patterns). Leur préoccupation est différente : une fois les objets créés, comment les assembler en structures plus grandes tout en gardant ces structures souples et efficaces ? Ils s'intéressent aux relations entre entités — qui contient qui, qui enveloppe qui, qui délègue à qui.
Quatre d'entre eux reposent sur une idée centrale, déjà rencontrée dans le livre : la composition plutôt que l'héritage (composition over inheritance). Plutôt que de tout entasser dans une hiérarchie de classes qui explose, on relie des objets par des références et on délègue le travail. L'adaptateur (Adapter) fait collaborer des interfaces incompatibles, le pont (Bridge) sépare deux dimensions qui évoluaient ensemble, le composite (Composite) traite arbres et feuilles de manière uniforme, et le décorateur (Decorator) empile des comportements en couches. Voyons chacun à travers le problème concret qu'il résout.
L'adaptateur (Adapter)
Intention. Permettre à des objets aux interfaces incompatibles de collaborer, en enveloppant l'un d'eux dans un objet qui traduit son interface.
Le problème
Imaginez que vous développez une application de suivi boursier. Elle télécharge les données de marché depuis plusieurs sources au format XML, puis affiche de jolis graphiques. Un jour, vous décidez de l'enrichir en intégrant une bibliothèque d'analyse tierce, très puissante. Mais il y a un hic : cette bibliothèque ne sait travailler qu'avec du JSON.
Vous ne pouvez pas l'utiliser telle quelle, puisqu'elle attend un format incompatible avec le vôtre. Modifier la bibliothèque pour qu'elle accepte du XML ? Cela risquerait de casser d'autres codes qui en dépendent — et de toute façon, vous n'avez peut-être même pas accès à son code source. L'approche est impossible.
Note
L'analogie de Shvets : lorsque vous voyagez des États-Unis vers l'Europe, votre prise américaine ne rentre pas dans une prise allemande. La solution n'est pas de recâbler votre ordinateur, mais d'intercaler un adaptateur de voyage doté d'une fiche européenne d'un côté et d'une prise américaine de l'autre.
La solution
Vous créez un adaptateur : un objet spécial qui convertit l'interface d'un objet pour qu'un autre puisse le comprendre. L'adaptateur enveloppe l'objet à traduire et cache la complexité de la conversion ; l'objet enveloppé n'a même pas conscience de l'adaptateur. Le mécanisme tient en trois temps : l'adaptateur expose une interface compatible avec le code client, le client appelle ses méthodes sans rien savoir, et l'adaptateur retransmet l'appel à l'objet enveloppé dans le format et l'ordre qu'il attend.
L'exemple canonique du livre est celui des chevilles : une cheville carrée que l'on veut faire entrer dans un trou rond. L'adaptateur se fait passer pour une cheville ronde dont le rayon correspond au plus petit cercle capable d'accueillir la cheville carrée.
class RoundHole {
constructor(private readonly radius: number) {}
getRadius(): number {
return this.radius;
}
fits(peg: RoundPeg): boolean {
return this.getRadius() >= peg.getRadius();
}
}
class RoundPeg {
constructor(private readonly radius: number) {}
getRadius(): number {
return this.radius;
}
}
// La classe incompatible : elle parle de largeur, pas de rayon.
class SquarePeg {
constructor(private readonly width: number) {}
getWidth(): number {
return this.width;
}
}
// L'adaptateur se fait passer pour une cheville ronde
// tout en enveloppant une cheville carrée.
class SquarePegAdapter extends RoundPeg {
constructor(private readonly peg: SquarePeg) {
super(0);
}
getRadius(): number {
return (this.peg.getWidth() * Math.sqrt(2)) / 2;
}
}
const hole = new RoundHole(5);
const small = new SquarePegAdapter(new SquarePeg(5));
const large = new SquarePegAdapter(new SquarePeg(10));
hole.fits(small); // true
hole.fits(large); // false Le client (RoundHole) ne connaît que l'interface ronde. Il manipule l'adaptateur sans jamais soupçonner qu'une cheville carrée se cache derrière.
Quand l'utiliser
Utilisez l'adaptateur quand vous voulez réutiliser une classe existante dont l'interface ne colle pas au reste de votre code — typiquement une classe tierce, héritée (legacy) ou simplement biscornue. L'adaptateur crée une couche intermédiaire de traduction entre votre code et cette classe. C'est aussi un bon outil pour ajouter une fonctionnalité commune manquante à plusieurs sous-classes, sans dupliquer ce code partout.
Astuce
Comme le client dialogue avec l'adaptateur via une interface stable (le principe ouvert/fermé, OCP), vous pouvez introduire de nouveaux adaptateurs ou remplacer le service tiers sans toucher au code client. Et comme la conversion vit dans sa propre classe, vous respectez le principe de responsabilité unique (SRP) en séparant la traduction de la logique métier.
Le piège honnête : l'adaptateur augmente la complexité globale en ajoutant des interfaces et des classes. Parfois, il est plus simple de modifier directement la classe de service pour qu'elle s'aligne sur le reste du code, quand cela est possible.
Le pont (Bridge)
Intention. Découper une grande classe (ou un ensemble de classes liées) en deux hiérarchies indépendantes — abstraction et implémentation — pour qu'elles évoluent séparément.
Le problème
Les mots « abstraction » et « implémentation » font peur ; prenons un exemple simple. Vous avez une classe Shape avec deux sous-classes, Circle et Square. Vous voulez maintenant gérer les couleurs, alors vous ajoutez des variantes Red et Blue. Mais comme vous avez déjà deux formes, il vous faut quatre combinaisons : BlueCircle, RedSquare, etc.
Le problème, c'est que cette explosion croît en progression géométrique. Ajouter une forme « triangle » impose deux nouvelles classes (une par couleur). Ajouter ensuite une couleur impose trois classes (une par forme). Plus on avance, pire c'est. La cause profonde : on essaie d'étendre la classe dans deux dimensions indépendantes à la fois — la forme et la couleur.
La solution
Le pont résout cela en passant de l'héritage à la composition. On extrait l'une des dimensions dans une hiérarchie de classes séparée, et la classe d'origine référence un objet de cette nouvelle hiérarchie au lieu de tout porter elle-même. Ici, on sort la couleur dans sa propre hiérarchie (Red, Blue) ; la forme garde une référence vers un objet couleur et lui délègue tout le travail lié à la couleur. Cette référence est le pont entre les deux hiérarchies. Dès lors, ajouter une couleur n'oblige plus à modifier les formes, et inversement.
Le vocabulaire du livre : l'abstraction est une couche de contrôle de haut niveau qui ne fait pas le travail elle-même, mais le délègue à la couche d'implémentation (la « plateforme »). Attention, il ne s'agit pas des interfaces ou classes abstraites de votre langage : ce sont des rôles de conception. L'exemple le plus parlant est celui des télécommandes (l'abstraction) et des appareils (l'implémentation).
// L'implémentation : opérations primitives, propres à chaque appareil.
interface Device {
isEnabled(): boolean;
enable(): void;
disable(): void;
getVolume(): number;
setVolume(percent: number): void;
}
// L'abstraction : logique de haut niveau, déléguée à un Device.
class RemoteControl {
constructor(protected device: Device) {}
togglePower(): void {
if (this.device.isEnabled()) this.device.disable();
else this.device.enable();
}
volumeUp(): void {
this.device.setVolume(this.device.getVolume() + 10);
}
}
// Abstraction raffinée : on étend la télécommande
// indépendamment des appareils.
class AdvancedRemoteControl extends RemoteControl {
mute(): void {
this.device.setVolume(0);
}
}
class Tv implements Device {
private on = false;
private volume = 30;
isEnabled() { return this.on; }
enable() { this.on = true; }
disable() { this.on = false; }
getVolume() { return this.volume; }
setVolume(percent: number) { this.volume = percent; }
}
// Le client relie une télécommande à un appareil.
const remote = new AdvancedRemoteControl(new Tv());
remote.togglePower();
remote.mute(); L'interface Device n'a pas à ressembler à celle de RemoteControl : typiquement, l'implémentation n'offre que des opérations primitives, tandis que l'abstraction compose des comportements plus riches par-dessus. Vous pouvez ajouter un nouvel appareil (radio, ampli) sans toucher aux télécommandes, et inversement.
Quand l'utiliser
Employez le pont quand vous voulez diviser une classe monolithique qui possède plusieurs variantes d'une même fonctionnalité (par exemple un code capable de fonctionner avec divers serveurs de base de données). Surtout, utilisez-le quand vous devez étendre une classe selon plusieurs dimensions orthogonales (indépendantes) : extraire une hiérarchie par dimension évite l'explosion combinatoire. Bonus : comme l'implémentation est un simple champ, vous pouvez la changer à l'exécution (runtime) par une affectation.
À retenir
Pont et adaptateur partagent une structure proche (tous deux reposent sur la délégation), mais répondent à des problèmes différents et à des moments différents. Le pont se conçoit en amont, pour développer deux parties d'une application indépendamment. L'adaptateur s'ajoute après coup, sur du code existant, pour faire cohabiter des classes incompatibles.
Le piège : appliqué à une classe déjà très cohésive (qui ne se divise pas naturellement en deux dimensions), le pont ne fait que compliquer inutilement le code.
Le composite (Composite)
Intention. Composer des objets en arborescences, puis traiter de manière uniforme un objet simple (feuille) et un conteneur d'objets (composite) à travers une même interface.
Le problème
Le composite n'a de sens que si le modèle de votre application peut se représenter sous forme d'arbre. Prenez deux types d'objets : des Products et des Boxes. Une boîte peut contenir plusieurs produits, mais aussi de plus petites boîtes, qui contiennent à leur tour des produits ou des boîtes encore plus petites, et ainsi de suite. Une commande ressemble alors à un arbre renversé.
Vous voulez calculer le prix total d'une telle commande. L'approche directe — déballer toutes les boîtes, parcourir tous les produits, additionner — serait jouable dans le monde réel. Mais dans un programme, ce n'est pas une simple boucle : il faudrait connaître à l'avance les classes parcourues, les niveaux d'imbrication et quantité de détails fastidieux. L'approche directe devient vite trop maladroite, voire impossible.
Note
L'analogie militaire : une armée est faite de divisions, chaque division de brigades, chaque brigade de pelotons, chaque peloton d'escouades, chaque escouade de soldats. Un ordre donné au sommet descend récursivement jusqu'à ce que chaque soldat sache quoi faire. C'est exactement le comportement d'un arbre composite.
La solution
Le composite propose de manipuler produits et boîtes à travers une interface commune déclarant une méthode de calcul du prix. Pour un produit, elle renvoie simplement son prix. Pour une boîte, elle parcourt chaque élément qu'elle contient, demande son prix et renvoie le total — et si un élément est lui-même une boîte, celle-ci fait de même récursivement, jusqu'aux feuilles.
Le grand avantage : le client n'a pas à connaître les classes concrètes des objets de l'arbre. Produit simple ou boîte sophistiquée, il les traite tous via l'interface commune. Ce sont les objets eux-mêmes qui font descendre la requête le long de l'arbre.
// L'interface commune aux feuilles et aux conteneurs.
interface Box {
getPrice(): number;
}
// La feuille : elle fait le vrai travail, sans rien déléguer.
class Product implements Box {
constructor(
private readonly title: string,
private readonly price: number,
) {}
getPrice(): number {
return this.price;
}
}
// Le composite : il délègue à ses enfants, puis « additionne ».
class CompositeBox implements Box {
private readonly children: Box[] = [];
constructor(private readonly packaging = 1) {}
add(...items: Box[]): void {
this.children.push(...items);
}
getPrice(): number {
const contents = this.children
.map((child) => child.getPrice())
.reduce((sum, price) => sum + price, 0);
return contents + this.packaging;
}
}
const order = new CompositeBox(2);
order.add(new Product("Souris", 25));
const innerBox = new CompositeBox(1);
innerBox.add(new Product("Clavier", 60), new Product("Câble", 5));
order.add(innerBox);
order.getPrice(); // 25 + (60 + 5 + 1) + 2 = 93 Remarquez que CompositeBox.getPrice() ne distingue jamais un produit d'une sous-boîte : il appelle getPrice() sur chaque enfant, et la récursion s'occupe du reste.
Quand l'utiliser
Utilisez le composite quand vous devez implémenter une structure d'objets en forme d'arbre, faite de feuilles simples et de conteneurs imbriquables. Utilisez-le aussi quand le code client doit traiter uniformément les éléments simples et complexes : grâce à l'interface partagée, le client n'a pas à se soucier de la classe concrète des objets. On exploite ainsi à plein le polymorphisme et la récursion.
Le piège : il peut être difficile de fournir une interface commune à des classes dont les comportements diffèrent trop. À vouloir tout généraliser, on obtient une interface fourre-tout, difficile à comprendre. Par ailleurs, placer add/remove dans l'interface commune viole le principe de ségrégation des interfaces (ces méthodes sont vides côté feuille) — mais c'est souvent le prix à payer pour traiter tous les éléments de la même façon.
Le décorateur (Decorator)
Intention. Attacher dynamiquement de nouveaux comportements à un objet en l'enveloppant dans des « emballeurs » (wrappers) successifs qui partagent son interface.
Le problème
Vous écrivez une bibliothèque de notifications. La version initiale repose sur une classe Notifier capable d'envoyer un message par e-mail à une liste d'adresses. Puis les utilisateurs en veulent plus : du SMS pour les incidents critiques, du Slack pour les équipes, du Facebook pour d'autres. Première réaction : étendre Notifier par héritage, une sous-classe par canal.
Mais quelqu'un demande, à juste titre : « pourquoi ne pas utiliser plusieurs canaux à la fois ? Si votre maison brûle, vous voulez être prévenu par tous les moyens. » Vous tentez alors de créer des sous-classes combinant les canaux (SMSAndSlackNotifier…) — et c'est l'explosion combinatoire. Chaque combinaison possible exige sa propre classe ; le code, côté bibliothèque comme côté client, gonfle de façon ingérable.
L'héritage montre ici ses deux limites fondamentales : il est statique (on ne peut pas changer le comportement d'un objet existant à l'exécution, seulement le remplacer par un autre), et une sous-classe ne peut avoir qu'un seul parent (pas de combinaison de comportements).
Astuce
L'analogie vestimentaire : quand vous avez froid, vous enfilez un pull. Encore froid ? Une veste par-dessus. Il pleut ? Un imperméable. Chaque vêtement « étend » votre comportement de base sans faire partie de vous, et vous pouvez en retirer n'importe lequel à tout moment. C'est exactement la composition dynamique du décorateur.
La solution
La clé est la composition : un objet emballeur (wrapper) implémente la même interface que l'objet cible, garde une référence vers lui, et délègue tous les appels — en ajoutant son grain de sel avant ou après. Comme le champ de l'emballeur accepte n'importe quel objet de cette interface, vous pouvez emballer un emballeur, et ainsi empiler les comportements en une pile (stack). Le dernier emballeur de la pile est celui que le client manipule ; comme tous partagent l'interface du notifieur de base, le reste du code ne voit aucune différence.
L'exemple du livre : un flux de données que l'on enveloppe de décorateurs de chiffrement et de compression.
// Le composant : l'interface partagée par tous.
interface DataSource {
writeData(data: string): void;
readData(): string;
}
// Le composant concret : le comportement de base.
class FileDataSource implements DataSource {
private content = "";
constructor(private readonly filename: string) {}
writeData(data: string): void {
this.content = data; // Écrit sur le disque.
}
readData(): string {
return this.content;
}
}
// Le décorateur de base : enveloppe et délègue tout.
class DataSourceDecorator implements DataSource {
constructor(protected wrappee: DataSource) {}
writeData(data: string): void {
this.wrappee.writeData(data);
}
readData(): string {
return this.wrappee.readData();
}
}
// Décorateurs concrets : ils agissent avant/après la délégation.
class CompressionDecorator extends DataSourceDecorator {
writeData(data: string): void {
super.writeData(`compress(${data})`);
}
readData(): string {
return super.readData().replace(/^compress((.*))$/, "$1");
}
}
class EncryptionDecorator extends DataSourceDecorator {
writeData(data: string): void {
super.writeData(`encrypt(${data})`);
}
readData(): string {
return super.readData().replace(/^encrypt((.*))$/, "$1");
}
}
// Le client assemble la pile selon la configuration.
let source: DataSource = new FileDataSource("salary.dat");
source = new CompressionDecorator(source);
source = new EncryptionDecorator(source);
// source contient : Encryption > Compression > FileDataSource
source.writeData("records");
source.readData(); // "records" À l'écriture, les données traversent Encryption, puis Compression, puis le fichier ; à la lecture, elles repassent en sens inverse. Le code client peut assembler des piles différentes à l'exécution, selon la configuration ou l'environnement, sans qu'aucune classe ne connaisse les autres.
Quand l'utiliser
Utilisez le décorateur quand vous devez ajouter des comportements à des objets à l'exécution sans casser le code qui les utilise. Il vous permet de structurer votre logique en couches, un décorateur par couche, et de composer ces couches à volonté. Utilisez-le aussi quand l'extension par héritage est malcommode ou impossible — par exemple si la classe est final et ne peut être étendue : l'envelopper devient le seul moyen de réutiliser son comportement.
Piège courant
Les limites à connaître : il est difficile de retirer un emballeur précis du milieu de la pile, et difficile de concevoir des décorateurs dont le comportement ne dépende pas de leur ordre dans la pile (chiffrer puis compresser n'est pas équivalent à compresser puis chiffrer). Le code de configuration des couches peut aussi devenir franchement laid.
Ne confondez pas avec ses cousins. L'adaptateur change l'interface d'un objet ; le décorateur la conserve tout en l'enrichissant. Le composite « additionne » les résultats de ses multiples enfants ; un décorateur est comme un composite qui n'aurait qu'un seul enfant, mais qui lui ajoute des responsabilités au lieu d'agréger.
Tableau récapitulatif
| Patron | Intention | Quand l'utiliser |
|---|---|---|
| Adaptateur (Adapter) | Faire collaborer des interfaces incompatibles en enveloppant l'une pour la traduire. | Réutiliser une classe tierce ou héritée dont l'interface ne colle pas à votre code. |
| Pont (Bridge) | Séparer abstraction et implémentation en deux hiérarchies indépendantes. | Étendre une classe selon plusieurs dimensions orthogonales sans explosion combinatoire ; changer l'implémentation à l'exécution. |
| Composite (Composite) | Composer des objets en arbres et traiter feuilles et conteneurs de façon uniforme. | Modéliser une structure arborescente où le client doit ignorer la différence entre élément simple et conteneur. |
| Décorateur (Decorator) | Ajouter dynamiquement des comportements en empilant des emballeurs de même interface. | Combiner des comportements optionnels à l'exécution, sans multiplier les sous-classes ni casser le code client. |
À retenir
- Les patrons structurels assemblent objets et classes en structures plus grandes, en privilégiant la composition à l'héritage pour rester souples.
- L'adaptateur est un traducteur : il enveloppe une classe à l'interface incompatible (XML/JSON, prise électrique) et la rend utilisable sans la modifier. On l'ajoute généralement après coup.
- Le pont combat l'explosion combinatoire en extrayant une dimension (la couleur, l'appareil) dans une hiérarchie séparée. On le conçoit en amont ; il permet aussi de changer l'implémentation au runtime.
- Le composite modélise des arborescences (produits dans des boîtes, hiérarchie militaire) et laisse feuilles et conteneurs partager une interface, exploitant récursion et polymorphisme.
- Le décorateur empile des comportements en emballant un objet dans des wrappers de même interface (e-mail + SMS + Slack, chiffrement + compression), avec l'avantage de la composition dynamique sur l'héritage statique.
- Méfiez-vous de la sur-ingénierie : un adaptateur superflu quand on peut corriger la source, un pont sur une classe déjà cohésive, ou un composite sur un modèle qui n'est pas un arbre ajoutent de la complexité sans bénéfice.