Les patrons structurels (2/2)
Simplifier et optimiser : Façade, Poids-mouche (Flyweight) et Procuration (Proxy).
Les premiers patrons structurels assemblaient des objets pour gagner en flexibilité. Ce second volet poursuit avec trois patrons qui partagent une même obsession : maîtriser la complexité et le coût. La façade range derrière une porte unique un sous-système touffu. Le poids-mouche fait tenir des millions d'objets dans une RAM qui ne devrait pas suffire. La procuration glisse un intermédiaire devant un objet pour contrôler quand et comment on y accède.
Ces trois patrons ont un air de famille trompeur avec l'adaptateur et le décorateur du chapitre précédent : tous « enveloppent » quelque chose. Mais comme nous le verrons en fin de chapitre, c'est l'intention qui les distingue, jamais leur silhouette. Suivons chaque patron du même fil : le problème d'abord, le patron comme réponse ensuite.
La façade (Facade)
Intention
La façade (Facade) fournit une interface simplifiée et unifiée à une bibliothèque, un framework ou tout autre ensemble complexe de classes.
Le problème
Imaginez que votre code doive collaborer avec un large ensemble d'objets appartenant à une bibliothèque sophistiquée. En temps normal, il faut initialiser tous ces objets, suivre leurs dépendances, appeler les méthodes dans le bon ordre, leur fournir les données au bon format, et ainsi de suite.
Résultat : la logique métier de vos classes se retrouve étroitement couplée aux détails d'implémentation de classes tierces. Le code devient difficile à comprendre et à maintenir. Pire, le jour où vous voudrez changer de bibliothèque, ce couplage diffus se révèlera douloureux à démêler.
Shvets prend l'exemple d'une application qui publie de courtes vidéos de chats sur les réseaux sociaux. Elle pourrait utiliser une bibliothèque professionnelle de conversion vidéo aux dizaines de fonctionnalités. Mais tout ce dont elle a réellement besoin, c'est une classe avec une seule méthode : encode(fichier, format).
Note
Analogie du livre : quand vous appelez une boutique pour passer commande par téléphone, l'opérateur est votre façade vers tous les services et départements du magasin. Il vous offre une interface vocale simple par-dessus le système de commande, les passerelles de paiement et les divers services de livraison.
La solution
Une façade est une classe qui offre une interface simple vers un sous-système complexe comportant beaucoup de pièces mobiles. Elle peut proposer moins de fonctionnalités que le sous-système brut, mais elle inclut exactement ce dont les clients ont besoin — rien de plus.
Au lieu de faire dialoguer votre code avec des dizaines de classes du framework, vous créez une classe façade qui encapsule cette mécanique et la cache au reste du code. Bénéfice secondaire majeur : le jour où vous mettez à jour le framework ou le remplacez, la seule chose à réécrire est l'implémentation de la façade.
// Classes du framework tiers : on ne les contrôle pas,
// on ne peut donc pas les simplifier.
class VideoFile {
constructor(public readonly nom: string) {}
}
class CodecFactory {
static extract(file: VideoFile): Codec {
/* lit l'en-tête et déduit le codec source */
return new OggCodec();
}
}
interface Codec {}
class OggCodec implements Codec {}
class Mpeg4Codec implements Codec {}
class BitrateReader {
static read(nom: string, codec: Codec): Buffer {
return Buffer.from(nom);
}
static convert(buffer: Buffer, codec: Codec): Buffer {
return buffer;
}
}
class AudioMixer {
fix(result: Buffer): Buffer {
return result;
}
}
// La façade cache la complexité derrière une interface simple.
// C'est un compromis assumé entre richesse et simplicité.
class VideoConverter {
convert(nom: string, format: "mp4" | "ogg"): Buffer {
const file = new VideoFile(nom);
const source = CodecFactory.extract(file);
const cible = format === "mp4" ? new Mpeg4Codec() : new OggCodec();
const buffer = BitrateReader.read(nom, source);
const result = BitrateReader.convert(buffer, cible);
return new AudioMixer().fix(result);
}
}
// Le client ignore tout du sous-système.
const converter = new VideoConverter();
const mp4 = converter.convert("video-de-chat.ogg", "mp4"); Le client appelle une seule méthode. Toute la chorégraphie d'initialisation, d'extraction de codec, de lecture de débit et de mixage audio reste cloisonnée dans la façade.
Quand l'utiliser
- Vous avez besoin d'une interface limitée mais simple vers un sous-système complexe. Avec le temps, les sous-systèmes se sophistiquent — appliquer des patrons crée souvent davantage de classes. La façade offre un raccourci vers les fonctions les plus utilisées qui satisfont la majorité des besoins.
- Vous voulez structurer un sous-système en couches. Créez une façade comme point d'entrée de chaque niveau. En forçant les sous-systèmes à ne communiquer qu'à travers leurs façades, vous réduisez leur couplage mutuel.
Attention
Le piège classique : une façade peut devenir un objet-dieu couplé à toutes les classes de l'application. Si elle grossit trop, extrayez une partie de son comportement dans une façade affinée plutôt que de tout y entasser.
Le poids-mouche (Flyweight)
Intention
Le poids-mouche (Flyweight), aussi connu sous le nom de cache, permet de faire tenir davantage d'objets dans la RAM disponible en partageant les parties communes de l'état entre de multiples objets, au lieu de conserver toutes les données dans chacun.
Le problème
Pour vous détendre après le travail, vous décidez de créer un petit jeu vidéo : les joueurs se déplacent sur une carte et se tirent dessus. Vous voulez un système de particules réaliste comme signature du jeu — des quantités massives de balles, de missiles et d'éclats d'explosion volant à travers la carte.
Le jeu tourne parfaitement sur votre machine. Vous l'envoyez à un ami pour un essai. Sur son ordinateur, moins puissant, le jeu plante après quelques minutes : mémoire insuffisante. Au plus fort de l'action, les nouvelles particules ne tiennent plus dans la RAM restante.
En inspectant la classe Particle, vous remarquez que les champs couleur et sprite consomment beaucoup plus de mémoire que les autres — et surtout qu'ils stockent des données quasi identiques d'une particule à l'autre. Toutes les balles ont la même couleur et le même sprite. En revanche, les coordonnées, le vecteur de mouvement et la vitesse sont uniques à chaque particule et changent dans le temps.
La solution
Shvets nomme ces deux familles de données :
- L'état intrinsèque : la donnée constante qui vit dans l'objet, que les autres peuvent lire mais pas modifier (la couleur, le sprite). Elle est dupliquée à l'identique partout.
- L'état extrinsèque : la donnée contextuelle, souvent modifiée « de l'extérieur » (coordonnées, vitesse, vecteur). Elle est unique à chaque objet.
Le poids-mouche suggère d'arrêter de stocker l'état extrinsèque dans l'objet. À la place, on le passe en paramètre aux méthodes qui en ont besoin. Seul l'état intrinsèque reste dans l'objet, ce qui permet de le réutiliser dans des contextes différents. Dans le jeu, trois poids-mouches suffisent alors à représenter toutes les particules : une balle, un missile, un éclat.
Où va l'état extrinsèque ? Dans un objet contexte qui stocke l'état variable et une référence vers le poids-mouche partagé. Ces objets contexte restent nombreux, mais ils sont désormais minuscules : mille petits contextes réutilisent un seul gros poids-mouche au lieu d'en dupliquer les données mille fois.
À retenir
Un poids-mouche doit être immuable. Comme le même objet est partagé entre plusieurs contextes, il initialise son état une seule fois, via le constructeur, et n'expose ni setter ni champ public. Sans cette garantie, modifier un poids-mouche corromprait silencieusement tous les contextes qui le réutilisent.
Pour accéder commodément aux poids-mouches, on ajoute une fabrique qui gère une réserve (un pool). On lui passe l'état intrinsèque voulu : elle renvoie un poids-mouche existant qui correspond, ou en crée un nouveau et l'ajoute à la réserve.
Voici l'exemple de la forêt rendue à l'écran, fidèle au livre : TreeType (le poids-mouche) porte la texture et la couleur partagées ; Tree (le contexte) ne garde que des coordonnées et une référence.
// Poids-mouche : l'état intrinsèque, lourd et partagé.
// La texture et la couleur sont volumineuses : les dupliquer
// dans chaque arbre gaspillerait énormément de mémoire.
class TreeType {
constructor(
public readonly nom: string,
public readonly couleur: string,
public readonly texture: string,
) {}
draw(canvas: Canvas, x: number, y: number): void {
// 1. Crée un bitmap du type/couleur/texture donnés.
// 2. Le dessine sur le canvas aux coordonnées x, y.
}
}
// Fabrique : réutilise un poids-mouche existant ou en crée un.
class TreeFactory {
private static types = new Map<string, TreeType>();
static getTreeType(
nom: string,
couleur: string,
texture: string,
): TreeType {
const cle = `${nom}-${couleur}-${texture}`;
let type = TreeFactory.types.get(cle);
if (!type) {
type = new TreeType(nom, couleur, texture);
TreeFactory.types.set(cle, type);
}
return type;
}
}
// Contexte : l'état extrinsèque, minuscule. On peut en créer
// des millions, car il ne porte que deux entiers et une réf.
class Tree {
constructor(
private readonly x: number,
private readonly y: number,
private readonly type: TreeType,
) {}
draw(canvas: Canvas): void {
this.type.draw(canvas, this.x, this.y);
}
}
// La forêt est cliente du poids-mouche.
class Forest {
private trees: Tree[] = [];
plantTree(
x: number,
y: number,
nom: string,
couleur: string,
texture: string,
): void {
const type = TreeFactory.getTreeType(nom, couleur, texture);
this.trees.push(new Tree(x, y, type));
}
draw(canvas: Canvas): void {
for (const tree of this.trees) tree.draw(canvas);
}
}
interface Canvas {} Mille Tree aux mêmes nom, couleur et texture partagent un seul TreeType. La donnée lourde n'existe plus qu'en un exemplaire par variété d'arbre.
Quand l'utiliser
N'utilisez le poids-mouche que lorsque votre programme doit gérer un nombre colossal d'objets qui peinent à tenir en RAM. Le bénéfice est réel quand, simultanément :
- l'application doit créer une multitude d'objets similaires ;
- cela épuise la RAM de l'appareil cible ;
- les objets contiennent un état dupliqué que l'on peut extraire et partager.
Piège courant
Le poids-mouche n'est qu'une optimisation. Avant de l'appliquer, assurez-vous que le problème de RAM existe vraiment et ne peut être résolu autrement. Le prix à payer : du temps CPU (recalculer le contexte à chaque appel) échangé contre de la mémoire, et un code nettement plus compliqué — les nouveaux venus se demanderont longtemps pourquoi l'état d'une entité a été coupé en deux.
La procuration (Proxy)
Intention
La procuration (Proxy) fournit un substitut ou un intermédiaire ayant la même interface qu'un autre objet, afin de contrôler l'accès à celui-ci et d'exécuter quelque chose avant ou après que la requête atteigne l'objet réel.
Le problème
Pourquoi vouloir contrôler l'accès à un objet ? Prenons un objet massif qui consomme énormément de ressources système — typiquement un connecteur de base de données dont les requêtes sont lentes. Vous en avez besoin de temps en temps, pas toujours.
Vous pourriez implémenter une initialisation paresseuse : ne créer cet objet que lorsqu'il est réellement nécessaire. Mais alors chaque client devrait exécuter ce code d'initialisation différée, ce qui provoquerait beaucoup de duplication. Dans l'idéal, on placerait ce code directement dans la classe de l'objet — sauf que ce n'est pas toujours possible : la classe peut appartenir à une bibliothèque tierce fermée que l'on ne peut pas modifier.
Note
Analogie du livre : une carte de crédit est une procuration vers un compte bancaire, lui-même une procuration vers une liasse de billets. Les trois exposent la même interface — on s'en sert pour payer. Le consommateur est ravi de ne pas trimballer de liquide, et le commerçant l'est tout autant : l'argent atterrit sur son compte sans risque de vol en chemin.
La solution
La procuration propose de créer une nouvelle classe avec la même interface que l'objet de service d'origine. On passe ensuite l'objet procuration à tous les clients de l'objet réel. À la réception d'une requête, la procuration peut créer le vrai objet de service et lui déléguer le travail — en intercalant sa propre logique avant ou après.
Puisque la procuration implémente la même interface que la classe d'origine, elle peut être passée à n'importe quel client qui attend un objet de service réel, sans que celui-ci s'en aperçoive.
Voici l'exemple du livre : une bibliothèque tierce d'intégration YouTube, très inefficace, qui retélécharge la même vidéo à chaque demande. La procuration de cache implémente la même interface et mémorise les résultats.
// L'interface du service distant.
interface ThirdPartyYoutubeLib {
listVideos(): string[];
getVideoInfo(id: string): string;
downloadVideo(id: string): void;
}
// Le service réel : chaque appel part sur le réseau.
// L'application ralentit si beaucoup de requêtes identiques
// sont lancées en même temps.
class ThirdPartyYoutubeClass implements ThirdPartyYoutubeLib {
listVideos(): string[] {
/* requête API vers YouTube */ return [];
}
getVideoInfo(id: string): string {
/* métadonnées d'une vidéo */ return `info:${id}`;
}
downloadVideo(id: string): void {
/* télécharge le fichier vidéo */
}
}
// La procuration de cache : même interface, délègue au service
// seulement quand une vraie requête est indispensable.
class CachedYoutubeProxy implements ThirdPartyYoutubeLib {
private listCache: string[] | null = null;
private videoCache = new Map<string, string>();
constructor(private readonly service: ThirdPartyYoutubeLib) {}
listVideos(): string[] {
if (this.listCache === null) {
this.listCache = this.service.listVideos();
}
return this.listCache;
}
getVideoInfo(id: string): string {
let info = this.videoCache.get(id);
if (info === undefined) {
info = this.service.getVideoInfo(id);
this.videoCache.set(id, info);
}
return info;
}
downloadVideo(id: string): void {
this.service.downloadVideo(id);
}
}
// Le client travaille via l'interface : il ne distingue pas
// le vrai service de sa procuration. On les permute librement.
class YoutubeManager {
constructor(private readonly service: ThirdPartyYoutubeLib) {}
renderVideoPage(id: string): void {
const info = this.service.getVideoInfo(id);
// Affiche la page de la vidéo.
}
}
// L'application configure la procuration à la volée.
const reel = new ThirdPartyYoutubeClass();
const proxy = new CachedYoutubeProxy(reel);
const manager = new YoutubeManager(proxy); YoutubeManager est inchangé : tant qu'il dialogue avec l'interface, on peut lui glisser la procuration à la place du service réel en toute transparence.
Quand l'utiliser
La procuration a des dizaines d'usages. Les plus courants, selon Shvets :
- Initialisation paresseuse (procuration virtuelle) : un objet de service lourd qui gaspille les ressources en restant toujours actif, alors qu'on ne l'utilise que par intermittence. La procuration retarde sa création jusqu'au moment où il est vraiment nécessaire.
- Contrôle d'accès (procuration de protection) : la procuration ne transmet la requête au service que si les identifiants du client satisfont certains critères — utile quand l'objet est une ressource critique convoitée par des applications variées, malveillantes comprises.
- Exécution locale d'un service distant (procuration distante) : la procuration achemine la requête sur le réseau et gère tous les détails déplaisants de la communication.
- Journalisation des requêtes (procuration de journalisation) : elle enregistre chaque requête avant de la transmettre au service.
- Mise en cache des résultats (procuration de cache) : pour des requêtes récurrentes au résultat identique, la procuration utilise les paramètres de la requête comme clés de cache — exactement l'exemple ci-dessus.
- Référence intelligente (smart reference) : la procuration suit les clients qui détiennent une référence au service ; quand la liste se vide, elle peut libérer l'objet lourd et ses ressources.
Astuce
Vous appliquez déjà le principe ouvert/fermé : on introduit de nouvelles procurations sans toucher au service ni aux clients. Le revers : davantage de classes, et une réponse du service parfois retardée par le travail supplémentaire de la procuration.
Façade, Adaptateur, Décorateur, Procuration : tous enveloppent, mais...
Ces quatre patrons « enveloppent » un objet ou un sous-système. Ce qui les sépare n'est pas leur structure — souvent identique — mais leur intention, comme le souligne Shvets.
À retenir
- L'adaptateur (Adapter) donne une interface différente à l'objet enveloppé, pour le rendre utilisable là où on ne le pouvait pas. Il enveloppe en général un seul objet.
- La façade (Facade) définit une interface nouvelle et simplifiée pour tout un sous-système d'objets ; elle n'ajoute aucune fonctionnalité, et le sous-système ignore son existence.
- Le décorateur (Decorator) fournit une interface enrichie : même interface, comportement augmenté. Sa composition est toujours pilotée par le client.
- La procuration (Proxy) offre la même interface que l'objet de service, ce qui les rend interchangeables, et gère elle-même le cycle de vie de son service.
Décorateur et procuration ont des structures jumelles mais des intentions opposées : le premier ajoute des responsabilités sous contrôle du client, le second contrôle l'accès et gère son service seul. Façade et procuration tamponnent tous deux une entité complexe et l'initialisent eux-mêmes ; mais seule la procuration partage l'interface de l'objet réel.
Tableau récapitulatif
| Patron | Intention | Quand l'utiliser |
|---|---|---|
| Façade (Facade) | Offrir une interface simple et unifiée à un sous-système complexe. | Vous n'utilisez qu'une fraction d'une grosse bibliothèque, ou vous voulez découpler/structurer un sous-système en couches via des points d'entrée. |
| Poids-mouche (Flyweight) | Partager l'état intrinsèque immuable entre une multitude d'objets pour réduire la RAM. | Un nombre colossal d'objets similaires sature la mémoire et leur état contient une part dupliquée extractible. |
| Procuration (Proxy) | Substituer un intermédiaire de même interface pour contrôler l'accès à l'objet réel. | Initialisation paresseuse, cache, contrôle d'accès, journalisation, accès distant ou référence intelligente, sans modifier le service ni les clients. |
À retenir
- La façade range la complexité derrière une porte unique : une interface simple par-dessus un sous-système touffu, qui isole le client et facilite le remplacement de la bibliothèque sous-jacente. Méfiez-vous de l'objet-dieu.
- Le poids-mouche sépare l'état intrinsèque (partagé, immuable) de l'état extrinsèque (contextuel, passé en paramètre) pour faire tenir des millions d'objets en mémoire. Une fabrique gère la réserve d'objets partagés.
- Le poids-mouche n'est qu'une optimisation : ne l'appliquez que face à un vrai problème de RAM, en acceptant un code plus compliqué et un possible surcoût CPU.
- La procuration garde la même interface que le service pour s'y substituer de façon transparente : chargement paresseux, cache, contrôle d'accès, journalisation, accès distant.
- L'intention prime sur la structure : adaptateur (interface différente), façade (interface simplifiée d'un sous-système), décorateur (interface enrichie, pilotée par le client), procuration (même interface, cycle de vie autogéré).