Aller au contenu

POO : composition et patrons de conception

Composer, c’est assembler un objet à partir d’autres objets qui lui rendent des services, au lieu d’hériter de leur comportement.

Héritage (« est un ») Composition (« a un »)
Lien Fort (statique, à la compilation) Souple (modifiable à l’exécution)
Réutilisation Tout le parent Seulement ce qu’on délègue
Encapsulation Les sous-classes voient l’intérieur du parent Chaque objet cache son intérieur
Changement de comportement à l’exécution Non Oui (remplacer le composant)
// Héritage : trois classes pour des combinaisons de comportements
class Oiseau {
/* ... */
}
class OiseauQuiVole extends Oiseau {
voler() {
/* ... */
}
}
class OiseauQuiNage extends Oiseau {
nager() {
/* ... */
}
}
// et un oiseau qui vole ET nage ? explosion des classes
// Composition : des comportements interchangeables
interface Deplacement {
deplacer(): string;
}
const marche: Deplacement = { deplacer: () => 'marche' };
const vol: Deplacement = { deplacer: () => 'vole' };
const nage: Deplacement = { deplacer: () => 'nage' };
class Animal {
constructor(
private readonly nom: string,
private readonly modes: Deplacement[],
) {}
seDeplacer(): string {
return `${this.nom} ${this.modes.map((m) => m.deplacer()).join(' et ')}`;
}
}
new Animal('Canard', [marche, vol, nage]).seDeplacer(); // Canard marche et vole et nage

Règle de conception : préférer la composition à l’héritage, et réserver l’héritage aux hiérarchies stables et réellement « est un ».

Un mixin ajoute des capacités à une classe sans hiérarchie profonde :

type Constructeur<T = {}> = new (...args: any[]) => T;
function Horodate<TBase extends Constructeur>(Base: TBase) {
return class extends Base {
creeLe = new Date();
modifieLe = new Date();
toucher() {
this.modifieLe = new Date();
}
};
}
class Document {
constructor(public titre: string) {}
}
const DocumentHorodate = Horodate(Document);
const d = new DocumentHorodate('Rapport');
d.toucher();
console.log(d.titre, d.creeLe);

Pratique, mais plus difficile à lire et à déboguer que la composition simple : l’utiliser pour des capacités transverses (horodatage, journalisation), pas pour structurer le domaine.

Rendre un algorithme interchangeable :

interface StrategieLivraison {
coutCentimes(poidsGrammes: number): number;
}
const retrait: StrategieLivraison = { coutCentimes: () => 0 };
const standard: StrategieLivraison = {
coutCentimes: (p) => 490 + Math.ceil(p / 1000) * 100,
};
const express: StrategieLivraison = {
coutCentimes: (p) => 1290 + Math.ceil(p / 1000) * 200,
};
class Commande {
constructor(
private poids: number,
private livraison: StrategieLivraison,
) {}
changerLivraison(l: StrategieLivraison) {
this.livraison = l;
}
get fraisCentimes() {
return this.livraison.coutCentimes(this.poids);
}
}

Remplace un switch sur le mode de livraison ; ajouter un mode ne modifie pas Commande. En TypeScript, un objet ou une simple fonction suffit souvent.

Centraliser la création d’objets :

interface Exporteur {
exporter(donnees: unknown[]): string;
}
class ExporteurJson implements Exporteur {
exporter(donnees: unknown[]) {
return JSON.stringify(donnees, null, 2);
}
}
class ExporteurCsv implements Exporteur {
exporter(donnees: unknown[]) {
const lignes = donnees as Record<string, unknown>[];
if (lignes.length === 0) return '';
const colonnes = Object.keys(lignes[0]!);
return [
colonnes.join(','),
...lignes.map((l) =>
colonnes.map((c) => JSON.stringify(l[c] ?? '')).join(','),
),
].join('\n');
}
}
type Format = 'json' | 'csv';
function creerExporteur(format: Format): Exporteur {
switch (format) {
case 'json':
return new ExporteurJson();
case 'csv':
return new ExporteurCsv();
}
}
console.log(creerExporteur('csv').exporter([{ nom: 'Alice', age: 30 }]));

L’appelant dépend de Exporteur et du nom du format, pas des classes concrètes. Le switch est à un seul endroit : la fabrique.

Un objet publie des événements, d’autres s’y abonnent sans qu’il les connaisse :

type Ecouteur<T> = (evenement: T) => void;
class Emetteur<T> {
private ecouteurs = new Set<Ecouteur<T>>();
abonner(ecouteur: Ecouteur<T>): () => void {
this.ecouteurs.add(ecouteur);
return () => this.ecouteurs.delete(ecouteur); // fonction de désabonnement
}
emettre(evenement: T): void {
for (const ecouteur of this.ecouteurs) ecouteur(evenement);
}
}
class Stock {
readonly changements = new Emetteur<{
reference: string;
quantite: number;
}>();
private quantites = new Map<string, number>();
retirer(reference: string, quantite: number) {
const restant = (this.quantites.get(reference) ?? 0) - quantite;
this.quantites.set(reference, restant);
this.changements.emettre({ reference, quantite: restant });
}
}
const stock = new Stock();
const arreter = stock.changements.abonner((e) => {
if (e.quantite < 5) console.warn(`stock bas : ${e.reference}`);
});
// plus tard : arreter();

Node.js fournit EventEmitter (module node:events) et le navigateur EventTarget. Toujours prévoir le désabonnement : un écouteur oublié retient l’objet en mémoire (fuite).

Ajouter un comportement en enveloppant un objet de même interface :

interface Depot {
trouver(id: number): Promise<string | undefined>;
}
class DepotAvecCache implements Depot {
private cache = new Map<number, string | undefined>();
constructor(private readonly interne: Depot) {}
async trouver(id: number) {
if (this.cache.has(id)) return this.cache.get(id);
const valeur = await this.interne.trouver(id);
this.cache.set(id, valeur);
return valeur;
}
}
class DepotJournalise implements Depot {
constructor(private readonly interne: Depot) {}
async trouver(id: number) {
console.log('recherche', id);
return this.interne.trouver(id);
}
}
const depot: Depot = new DepotJournalise(new DepotAvecCache(depotReel));

Chaque couche ajoute une capacité (cache, journal, mesure) sans modifier le dépôt d’origine. Ne pas confondre avec les « décorateurs » @decorateur de TypeScript, une syntaxe distincte (annotations sur des classes et méthodes).

Faire correspondre une interface existante à celle qu’on attend :

interface Notificateur {
envoyer(destinataire: string, message: string): Promise<void>;
}
// bibliothèque tierce dont l'API est différente
class ClientCourrielTiers {
async postMessage(options: { to: string; text: string }): Promise<void> {
/* ... */
}
}
class AdaptateurCourriel implements Notificateur {
constructor(private readonly client: ClientCourrielTiers) {}
envoyer(destinataire: string, message: string) {
return this.client.postMessage({ to: destinataire, text: message });
}
}

Isole le code de l’application d’une bibliothèque externe : en changer ne modifie qu’un adaptateur.

class Configuration {
private static instance?: Configuration;
private constructor(public readonly port: number) {}
static obtenir(): Configuration {
return (this.instance ??= new Configuration(
Number(process.env.PORT ?? 3000),
));
}
}
Inconvénients Alternative
État global caché, dépendances invisibles Injection : créer l’instance une fois à la racine de l’application et la passer aux consommateurs
Difficile à tester (état partagé entre tests) Instance passée en paramètre, remplaçable
Couplage fort Module ES : un module est déjà évalué une seule fois (export const config = …)

En TypeScript, un module qui exporte un objet est un singleton naturel ; mais préférer passer la dépendance explicitement.

Besoin Patron
Algorithmes interchangeables Stratégie
Création complexe ou variable Fabrique
Réagir aux changements d’un autre objet Observateur
Ajouter cache, journal, mesure Décorateur (enrobage)
Intégrer une API différente Adaptateur
Plusieurs capacités transverses Mixin, ou composition

Les patrons sont un vocabulaire commun, pas une liste à appliquer : introduire un patron quand le problème apparaît (variation réelle, test difficile), pas par anticipation.

Symptôme Piste
Hiérarchie qui grossit à chaque variante Passer à la composition ou à la stratégie
Un switch sur un type répété à plusieurs endroits Polymorphisme, ou fabrique + interface
Fuite mémoire avec des écouteurs Se désabonner (fonction renvoyée par abonner, removeEventListener)
Mixin dont les types sont illisibles Remplacer par de la composition
Singleton impossible à tester Injection de dépendances
Trop d’abstractions pour un besoin simple Retirer le patron : une fonction suffit

Ubuntu 24.04 et 26.04 : différences à connaître

Section intitulée « Ubuntu 24.04 et 26.04 : différences à connaître »

Aucune.