Les bases de la POO
Objets, classes et les quatre piliers — abstraction, encapsulation, héritage, polymorphisme — puis les relations entre objets.
Avant de plonger dans les patrons de conception, Alexander Shvets prend le temps de poser les fondations. Les design patterns ne tombent pas du ciel : ce sont des réponses élégantes à des problèmes récurrents de conception orientée objet. Pour les comprendre — et surtout pour comprendre pourquoi ils existent — il faut d'abord maîtriser le vocabulaire et les mécanismes de la POO. C'est l'objet de ce chapitre.
La programmation orientée objet (object-oriented programming) est un paradigme qui consiste à emballer des morceaux de données, et le comportement lié à ces données, dans des paquets spéciaux appelés objets. Ces objets sont fabriqués à partir d'un jeu de « plans » (blueprints) définis par le programmeur : les classes. Shvets illustre tout cela avec des chats — fidèles à cette tradition, nous garderons des exemples animaliers, traduits en TypeScript idiomatique.
Objets et classes
Imaginez un chat nommé Oscar. Oscar est un objet, une instance de la classe Chat. Tout chat possède un certain nombre d'attributs standard : un nom, un sexe, un âge, un poids, une couleur, une nourriture préférée. Ce sont les champs (fields) de la classe. Tous les chats se comportent aussi de façon similaire : ils respirent, mangent, courent, dorment et miaulent. Ce sont les méthodes de la classe. Collectivement, champs et méthodes forment les membres de la classe.
Les données stockées dans les champs d'un objet sont appelées son état (state) ; l'ensemble de ses méthodes définit son comportement (behavior).
class Chat {
constructor(
public nom: string,
public sexe: "male" | "femelle",
public poids: number, // l'état de l'objet
) {}
miauler(): void {
console.log(`${this.nom} : Miaou !`); // un comportement
}
}
const oscar = new Chat("Oscar", "male", 4.5);
const luna = new Chat("Luna", "femelle", 3.2); Luna, le chat de votre amie, est aussi une instance de Chat. Elle possède exactement le même jeu d'attributs qu'Oscar ; seules les valeurs diffèrent. Une classe est donc bien un plan qui définit la structure des objets, et un objet est une instance concrète de ce plan.
Les hiérarchies de classes
Un vrai programme contient plus d'une classe, et certaines s'organisent en hiérarchies. Supposons que votre voisin ait un chien, Fido. Chiens et chats ont beaucoup en commun : nom, sexe, âge et couleur sont des attributs des deux ; tous deux respirent, dorment et courent de la même manière. On peut donc définir une classe de base Animal qui regroupe ces attributs et comportements communs.
Une classe parente comme Animal est appelée superclasse ; ses enfants sont des sous-classes. Les sous-classes héritent de l'état et du comportement du parent, et ne définissent que ce qui diffère : Chat ajoutera miauler, Chien ajoutera aboyer. Une sous-classe peut aussi redéfinir (override) une méthode héritée, soit pour remplacer complètement son comportement, soit pour l'enrichir.
Les quatre piliers de la POO
La POO repose sur quatre concepts qui la distinguent des autres paradigmes : l'abstraction, l'encapsulation, l'héritage et le polymorphisme.
L'abstraction
La plupart du temps, en POO, on façonne les objets du programme à partir d'objets du monde réel. Mais un objet logiciel ne représente jamais l'original à 100 % — et c'est rarement nécessaire. L'abstraction (abstraction) consiste à ne modéliser que les attributs et comportements pertinents pour le problème que l'on résout, en ignorant tout le reste.
L'exemple canonique de Shvets : une classe Avion peut exister à la fois dans un simulateur de vol et dans une application de réservation. Dans le premier cas, elle portera les détails du vol réel (vitesse, altitude, cap, carburant). Dans le second, on ne se soucie que du plan des sièges et de leur disponibilité. Même objet du monde réel, deux modèles radicalement différents — chacun n'expose que ce qui compte pour son contexte.
// Contexte « réservation » : seuls les sièges comptent.
class AvionReservation {
private sieges: Map<string, boolean> = new Map();
estDisponible(siege: string): boolean {
return this.sieges.get(siege) ?? false;
}
}
// Contexte « simulateur » : la physique du vol compte.
class AvionSimulateur {
private altitude = 0;
private capDegres = 0;
monterEnAltitude(metres: number): void {
this.altitude += metres;
}
} Note
L'abstraction est un modèle d'un objet ou d'un phénomène du monde réel, limité à un contexte précis, qui représente avec exactitude tous les détails pertinents pour ce contexte et omet tout le reste. C'est le premier réflexe de conception : que dois-je vraiment modéliser ici ?
L'encapsulation
Pour démarrer une voiture, vous tournez une clé ou pressez un bouton. Vous n'avez pas à brancher des fils sous le capot, faire tourner le vilebrequin et amorcer le cycle moteur : ces détails sont cachés sous le capot. Vous ne disposez que d'une interface simple — un démarreur, un volant, des pédales. C'est exactement l'encapsulation (encapsulation) : cacher l'état interne d'un objet et n'exposer qu'une partie publique, ouverte aux interactions avec les autres objets.
Concrètement, on contrôle la visibilité des membres. Un membre private n'est accessible que depuis l'intérieur de sa propre classe ; un membre protected l'est aussi depuis les sous-classes ; un membre public est ouvert à tous.
class CompteBancaire {
// État caché : personne ne le modifie directement.
private solde = 0;
// Interface publique : le seul point d'entrée contrôlé.
deposer(montant: number): void {
if (montant <= 0) throw new Error("Montant invalide");
this.solde += montant;
}
consulterSolde(): number {
return this.solde;
}
}
const compte = new CompteBancaire();
compte.deposer(100);
// compte.solde = -999; // refusé : 'solde' est privé. En cachant solde, on garantit qu'il ne peut jamais devenir incohérent : toute modification passe par deposer, qui valide. L'encapsulation protège les invariants de l'objet.
Astuce
Encapsuler, ce n'est pas seulement « cacher des champs ». C'est exposer une interface stable et restreinte tout en gardant la liberté de changer l'implémentation interne quand on veut. Tant que la signature publique ne bouge pas, le reste du système n'est pas affecté.
L'encapsulation prend une dimension supplémentaire avec les interfaces. Un aéroport pourrait déclarer qu'il accepte tout objet capable d'arriver et de partir — qu'il s'agisse d'un Avion, d'un Helicoptere ou d'un improbable GriffonDomestique. Tant que chacun respecte la signature déclarée dans l'interface, l'aéroport sait travailler avec eux, sans rien connaître de leur implémentation.
interface ObjetVolant {
arriver(): void;
partir(): void;
}
class Helicoptere implements ObjetVolant {
arriver(): void {/* descente verticale */}
partir(): void {/* rotor */}
} L'héritage
L'héritage (inheritance) est la capacité de bâtir de nouvelles classes par-dessus des classes existantes. Son principal bénéfice est la réutilisation de code : pour créer une classe légèrement différente d'une autre, inutile de dupliquer ; on étend la classe existante et on place la fonctionnalité supplémentaire dans la sous-classe, qui hérite des champs et méthodes de la superclasse.
abstract class Animal {
constructor(public nom: string) {}
respirer(): void {
console.log(`${this.nom} respire.`);
}
}
class Chien extends Animal {
aboyer(): void {
console.log(`${this.nom} : Wouf !`);
}
}
const fido = new Chien("Fido");
fido.respirer(); // hérité d'Animal
fido.aboyer(); // propre à Chien Mais l'héritage a un coût, et Shvets insiste honnêtement dessus. Une sous-classe possède la même interface que sa classe parente : vous ne pouvez pas cacher une méthode déclarée dans la superclasse. Vous devez aussi implémenter toutes les méthodes abstraites héritées, même celles qui n'ont aucun sens pour votre sous-classe. Enfin, dans la plupart des langages, une classe ne peut étendre qu'une seule superclasse, tandis qu'elle peut implémenter plusieurs interfaces à la fois.
Attention
L'héritage crée un couplage fort : la sous-classe hérite de tout, y compris ce dont elle ne veut pas, et reste enchaînée à l'évolution de son parent. Ce constat est au cœur de bien des patrons : c'est pourquoi le livre répétera le principe « préférer la composition à l'héritage » (composition over inheritance). Gardez ce coût en tête.
Le polymorphisme
La plupart des animaux émettent un son. On peut anticiper que toutes les sous-classes devront fournir leur propre version d'une méthode faireUnSon. On la déclare donc abstraite dès la classe de base : cela permet d'omettre toute implémentation par défaut tout en forçant chaque sous-classe à fournir la sienne.
Imaginez maintenant que l'on jette plusieurs chats et chiens dans un grand sac. Les yeux fermés, on en sort un au hasard. On ne sait pas ce que c'est — mais si on le serre dans nos bras, l'animal émettra un son qui dépend de sa classe concrète. C'est le polymorphisme (polymorphism) : la capacité d'un programme à détecter la classe réelle d'un objet et à appeler son implémentation, même quand son type réel est inconnu dans le contexte courant.
abstract class Animal {
abstract faireUnSon(): void; // pas d'implémentation par défaut
}
class Chat extends Animal {
faireUnSon(): void {
console.log("Miaou !");
}
}
class Chien extends Animal {
faireUnSon(): void {
console.log("Wouf !");
}
}
// On manipule des Animal, sans connaître le type concret.
const sac: Animal[] = [new Chat(), new Chien()];
for (const a of sac) a.faireUnSon();
// Miaou !
// Wouf ! Dans la boucle, le programme ne connaît pas le type concret de a. Pourtant, grâce au polymorphisme, il retrouve à l'exécution la sous-classe réelle et exécute le bon comportement. Autre façon de le voir : le polymorphisme permet à un objet de « faire semblant » d'être autre chose — généralement une classe qu'il étend ou une interface qu'il implémente. Dans le sac, chats et chiens se faisaient passer pour de simples Animal.
Interfaces et classes abstraites
Deux outils incarnent ces piliers. Une interface déclare uniquement quoi (les signatures de méthodes) sans dire comment : c'est un contrat pur. Une classe abstraite peut, elle, mêler méthodes abstraites (à implémenter par les sous-classes) et méthodes concrètes (déjà codées, partagées). En TypeScript, on choisit l'interface pour exprimer un contrat sans état que des classes indépendantes peuvent honorer, et la classe abstraite pour factoriser du code commun dans une hiérarchie.
Les relations entre objets
Au-delà de l'héritage, les objets entretiennent d'autres types de relations. Shvets les présente de la plus faible à la plus forte, car les distinguer est essentiel pour lire les diagrammes UML — et pour comprendre les patrons à venir.
Dépendance
La dépendance (dependency) est la relation la plus faible. Il n'y a pas de lien permanent entre les objets : typiquement, un objet accepte un autre objet en paramètre de méthode, l'instancie ou l'utilise localement. La règle pour la repérer : il existe une dépendance entre deux classes si modifier la définition de l'une oblige à modifier l'autre. Un professeur qui dépend de la grille des salaires, par exemple.
class Professeur {
// Dépendance : 'Grille' n'apparaît que le temps de l'appel.
calculerSalaire(grille: Grille): number {
return grille.tarifHoraire() * 35;
}
} Association
L'association (association) est une relation où un objet utilise ou interagit durablement avec un autre. On l'emploie en général pour représenter un champ de la classe : le lien est toujours là, on peut à tout moment demander à une commande son client. L'association peut être bidirectionnelle. Un professeur qui communique en permanence avec ses étudiants en est un exemple.
class Commande {
// Association : le client est un champ, le lien persiste.
constructor(private client: Client) {}
obtenirClient(): Client {
return this.client;
}
} Agrégation et composition
Viennent ensuite deux relations « tout-partie » (« a-un ») qu'il faut absolument distinguer.
La composition (composition) est une relation tout-partie dans laquelle un objet est composé d'une ou plusieurs instances d'un autre, avec une contrainte forte : la partie ne peut exister qu'au sein du conteneur. Une université est composée de départements ; un département n'a aucun sens en dehors de son université. Le conteneur contrôle le cycle de vie de la partie : si l'université disparaît, ses départements aussi.
L'agrégation (aggregation) est une variante plus souple de la composition : un objet contient simplement une référence vers un autre. Le conteneur ne contrôle pas le cycle de vie de la partie. Celle-ci peut exister sans le conteneur et peut même être liée à plusieurs conteneurs à la fois. Un département contient des professeurs, mais un professeur continue d'exister si le département ferme, et peut appartenir à plusieurs départements.
// Composition : le conteneur CRÉE et POSSÈDE ses parties.
class Universite {
private departements: Departement[];
constructor(noms: string[]) {
// Les départements naissent et meurent avec l'université.
this.departements = noms.map((n) => new Departement(n));
}
}
// Agrégation : le conteneur reçoit des parties déjà vivantes.
class Departement {
// Les professeurs préexistent et survivront au département.
constructor(
public nom: string,
private professeurs: Professeur[] = [],
) {}
} À retenir
Le critère décisif est le cycle de vie. En composition, la partie naît et meurt avec le tout (on l'instancie à l'intérieur). En agrégation, la partie est passée de l'extérieur et lui survit. Cette nuance reviendra sans cesse : beaucoup de patrons reposent sur l'agrégation pour rester souples et faiblement couplés.
Implémentation et héritage
Deux dernières relations, déjà rencontrées, complètent le tableau. L'implémentation d'interface : une classe respecte le contrat déclaré par une interface (implements). L'héritage : une classe « est-un » cas particulier d'une autre et étend son comportement (extends). Notez la nuance de langage : l'agrégation et la composition se lisent « a-un », tandis que l'héritage et l'implémentation se lisent « est-un ».
Piège courant
Rien n'oblige à tout modéliser en hiérarchies d'héritage. Empiler des superclasses « au cas où » est une forme courante de sur-ingénierie. Demandez-vous toujours si une association ou une agrégation suffirait : c'est souvent le cas, et c'est presque toujours plus souple.
Tableau récapitulatif des relations
De la plus faible à la plus forte, voici les relations entre objets et leur signification :
| Relation | Lecture | Force | En bref |
|---|---|---|---|
| Dépendance | utilise | la plus faible | Lien éphémère : un paramètre, une instanciation locale. |
| Association | utilise (durablement) | faible | Un champ qui pointe vers un autre objet. |
| Agrégation | a-un | moyenne | Le tout référence la partie, sans contrôler sa vie. |
| Composition | a-un | forte | Le tout possède la partie ; elle meurt avec lui. |
| Implémentation | est-un (contrat) | — | La classe honore une interface (implements). |
| Héritage | est-un | la plus forte | La sous-classe hérite de tout (extends), couplage fort. |
À retenir
- La POO organise le code autour d'objets qui réunissent un état (champs) et un comportement (méthodes) ; les classes sont les plans, les objets en sont les instances.
- Abstraction : ne modéliser que ce qui est pertinent pour le contexte (l'
Aviondu simulateur n'est pas celui de la réservation). - Encapsulation : cacher l'état interne (
private/protected) et n'exposer qu'une interface publique stable, qui protège les invariants. - Héritage : puissant pour réutiliser, mais à coût élevé — la sous-classe hérite de tout et subit un couplage fort ; d'où le principe « préférer la composition à l'héritage ».
- Polymorphisme : appeler à l'exécution la bonne implémentation selon le type réel de l'objet, via une méthode commune redéfinie.
- Relations, de la plus faible à la plus forte : dépendance, association, agrégation et composition (« a-un », la composition liant le cycle de vie de la partie au tout), implémentation et héritage (« est-un »).